Compare commits

...

10 commits

Author SHA1 Message Date
josh
2becdb4a56
fix(chat): drop mistral models and harden title generation (#1498)
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
2026-05-18 07:08:46 -07:00
josh
107a43a803
drop kimi-k2-0905, default to kimi-k2.5 (#1487) 2026-04-17 07:05:40 -07:00
Vincent Voyer
d25b71c0a1
fix: use optional chaining to fix lint errors (#1485) 2026-04-16 18:43:22 +02:00
dancer
146b3cb8cf
update links in README.md (#1476) 2026-04-01 15:11:37 -07:00
Jeremy
691c3cb05f
chore: conditionalize mfe config behind demo env var (#1463) 2026-03-20 03:21:31 -07:00
dancer
f9652b452a
feat: v1 — persistent shell, model gateway, artifact improvements (#1462) 2026-03-20 02:37:02 -07:00
Hayden Bleasel
3651670fb9
Redesign sidebar (#1455)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 20:30:52 -07:00
dancer
66762d023d Revert "conditional basepath with IS_DEMO env"
This reverts commit 15f626b401.
2026-03-14 00:05:30 +00:00
dancer
15f626b401 conditional basepath with IS_DEMO env 2026-03-13 23:39:12 +00:00
dancer
27f2f9a9df show guest nav when signed out 2026-03-13 23:32:36 +00:00
178 changed files with 5544 additions and 8413 deletions

View file

@ -1,24 +1,15 @@
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
BETTER_AUTH_SECRET=****
BETTER_AUTH_URL=****
# generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
AUTH_SECRET=****
# 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
# required for non-vercel deployments, vercel uses OIDC automatically
# https://vercel.com/ai-gateway
AI_GATEWAY_API_KEY=****
# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/vercel-blob
# https://vercel.com/docs/vercel-blob
BLOB_READ_WRITE_TOKEN=****
# Instructions to create a PostgreSQL database here: https://vercel.com/docs/postgres
# 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: 9.12.3
version: 10.32.1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:

23
.gitignore vendored
View file

@ -1,46 +1,27 @@
# 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
next-env.d.ts
tsconfig.tsbuildinfo

View file

@ -1,4 +1,4 @@
<a href="https://chat.vercel.ai/">
<a href="https://chatbot.ai-sdk.dev/demo">
<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.dev"><strong>Read Docs</strong></a> ·
<a href="https://chatbot.ai-sdk.dev/docs"><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. The default model is [OpenAI](https://openai.com) GPT-4.1 Mini, with support for Anthropic, Google, and xAI models.
This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. Models are configured in `lib/ai/models.ts` with per-model provider routing. Included models: Mistral, Moonshot, DeepSeek, OpenAI, and xAI.
### AI Gateway Authentication

View file

@ -1,7 +1,10 @@
"use server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { createUser, getUser } from "@/lib/db/queries";
import { signIn } from "./auth";
const authFormSchema = z.object({
email: z.string().email(),
@ -22,11 +25,10 @@ export const login = async (
password: formData.get("password"),
});
await auth.api.signInEmail({
body: {
email: validatedData.email,
password: validatedData.password,
},
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
});
return { status: "success" };
@ -59,17 +61,17 @@ export const register = async (
password: formData.get("password"),
});
const result = await auth.api.signUpEmail({
body: {
email: validatedData.email,
password: validatedData.password,
name: validatedData.email,
},
});
const [user] = await getUser(validatedData.email);
if (!result) {
return { status: "failed" };
if (user) {
return { status: "user_exists" } as RegisterActionState;
}
await createUser(validatedData.email, validatedData.password);
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
});
return { status: "success" };
} catch (error) {
@ -77,11 +79,6 @@ export const register = async (
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

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

View file

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

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

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

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

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

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

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

View file

@ -2,36 +2,30 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useActionState, useEffect, useState } from "react";
import { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { useSession } from "@/lib/client";
import { AuthForm } from "@/components/chat/auth-form";
import { SubmitButton } from "@/components/chat/submit-button";
import { toast } from "@/components/chat/toast";
import { type LoginActionState, login } from "../actions";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [isSuccessful, setIsSuccessful] = useState(false);
const [state, formAction] = useActionState<LoginActionState, FormData>(
login,
{
status: "idle",
}
{ status: "idle" }
);
const { refetch } = useSession();
const { update: updateSession } = useSession();
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession 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",
@ -39,8 +33,8 @@ export default function Page() {
});
} else if (state.status === "success") {
setIsSuccessful(true);
refetch();
router.push("/");
updateSession();
router.refresh();
}
}, [state.status]);
@ -50,28 +44,23 @@ export default function Page() {
};
return (
<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>
<>
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
<p className="text-sm text-muted-foreground">
Sign in to your account to continue
</p>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
<p className="text-center text-[13px] text-muted-foreground">
{"No account? "}
<Link
className="text-foreground underline-offset-4 hover:underline"
href="/register"
>
Sign up
</Link>
</p>
</AuthForm>
</>
);
}

View file

@ -2,29 +2,26 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useActionState, useEffect, useState } from "react";
import { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { useSession } from "@/lib/client";
import { AuthForm } from "@/components/chat/auth-form";
import { SubmitButton } from "@/components/chat/submit-button";
import { toast } from "@/components/chat/toast";
import { type RegisterActionState, register } from "../actions";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [isSuccessful, setIsSuccessful] = useState(false);
const [state, formAction] = useActionState<RegisterActionState, FormData>(
register,
{
status: "idle",
}
{ status: "idle" }
);
const { refetch } = useSession();
const { update: updateSession } = useSession();
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
useEffect(() => {
if (state.status === "user_exists") {
toast({ type: "error", description: "Account already exists!" });
@ -36,11 +33,10 @@ export default function Page() {
description: "Failed validating your submission!",
});
} else if (state.status === "success") {
toast({ type: "success", description: "Account created successfully!" });
toast({ type: "success", description: "Account created!" });
setIsSuccessful(true);
refetch();
router.push("/");
updateSession();
router.refresh();
}
}, [state.status]);
@ -50,28 +46,21 @@ export default function Page() {
};
return (
<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>
<>
<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>
</>
);
}

View file

@ -2,11 +2,14 @@
import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers";
import type { VisibilityType } from "@/components/visibility-selector";
import { auth } from "@/app/(auth)/auth";
import type { VisibilityType } from "@/components/chat/visibility-selector";
import { titleModel } from "@/lib/ai/models";
import { titlePrompt } from "@/lib/ai/prompts";
import { getTitleModel } from "@/lib/ai/providers";
import {
deleteMessagesByChatIdAfterTimestamp,
getChatById,
getMessageById,
updateChatVisibilityById,
} from "@/lib/db/queries";
@ -26,6 +29,9 @@ export async function generateTitleFromUserMessage({
model: getTitleModel(),
system: titlePrompt,
prompt: getTextFromMessage(message),
providerOptions: {
gateway: { order: titleModel.gatewayOrder },
},
});
return text
.replace(/^[#*"\s]+/, "")
@ -34,7 +40,20 @@ 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,
@ -49,5 +68,15 @@ 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,20 @@
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 { getSession } from "@/lib/auth";
import { auth } from "@/app/(auth)/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 getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:suggestions").toResponse();

View file

@ -1,7 +1,14 @@
import { getSession } from "@/lib/auth";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
const voteSchema = z.object({
chatId: z.string(),
messageId: z.string(),
type: z.enum(["up", "down"]),
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get("chatId");
@ -13,7 +20,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();
@ -35,21 +42,23 @@ export async function GET(request: Request) {
}
export async function PATCH(request: Request) {
const {
chatId,
messageId,
type,
}: { chatId: string; messageId: string; type: "up" | "down" } =
await request.json();
let chatId: string;
let messageId: string;
let type: "up" | "down";
if (!chatId || !messageId || !type) {
try {
const parsed = voteSchema.parse(await request.json());
chatId = parsed.chatId;
messageId = parsed.messageId;
type = parsed.type;
} catch {
return new ChatbotError(
"bad_request:api",
"Parameters chatId, messageId, and type are required."
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();

View file

@ -1,81 +1,3 @@
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 />
</>
);
export default function Page() {
return null;
}

View file

@ -1,35 +1,53 @@
import { cookies } from "next/headers";
import Script from "next/script";
import { Suspense } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { DataStreamProvider } from "@/components/data-stream-provider";
import { Toaster } from "sonner";
import { AppSidebar } from "@/components/chat/app-sidebar";
import { DataStreamProvider } from "@/components/chat/data-stream-provider";
import { ChatShell } from "@/components/chat/shell";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { getSession } from "@/lib/auth";
import { ActiveChatProvider } from "@/hooks/use-active-chat";
import { auth } from "../(auth)/auth";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Script
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
strategy="beforeInteractive"
strategy="lazyOnload"
/>
<DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh" />}>
<SidebarWrapper>{children}</SidebarWrapper>
<Suspense fallback={<div className="flex h-dvh bg-sidebar" />}>
<SidebarShell>{children}</SidebarShell>
</Suspense>
</DataStreamProvider>
</>
);
}
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
const [session, cookieStore] = await Promise.all([getSession(), cookies()]);
async function SidebarShell({ children }: { children: React.ReactNode }) {
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
return (
<SidebarProvider defaultOpen={!isCollapsed}>
<AppSidebar user={session?.user} />
<SidebarInset>{children}</SidebarInset>
<SidebarInset>
<Toaster
position="top-center"
theme="system"
toastOptions={{
className:
"!bg-card !text-foreground !border-border/50 !shadow-[var(--shadow-float)]",
}}
/>
<Suspense fallback={<div className="flex h-dvh" />}>
<ActiveChatProvider>
<ChatShell />
</ActiveChatProvider>
</Suspense>
{children}
</SidebarInset>
</SidebarProvider>
);
}

View file

@ -1,52 +1,3 @@
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 (
<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 />
</>
);
return null;
}

View file

@ -1,138 +1,42 @@
@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";
/* 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-md: calc(var(--radius) - 2px);
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-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-background);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
@ -142,24 +46,156 @@
--color-sidebar-ring: var(--sidebar-ring);
}
/*
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.
: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;
}
}
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(--color-gray-200, currentcolor);
border-color: var(--border);
}
}
@layer base {
body {
overflow-x: hidden;
position: relative;
}
html {
overflow-x: hidden;
}
h1,
h2,
h3,
h4,
h5,
h6 {
letter-spacing: -0.025em;
line-height: 1.2;
}
p {
line-height: 1.6;
}
}
button:focus-visible,
select:focus-visible,
[role="button"]:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: none;
}
@utility text-balance {
text-wrap: balance;
}
@ -176,6 +212,14 @@
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;
@ -192,22 +236,6 @@
}
}
@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;
@ -227,92 +255,246 @@
}
}
@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,
.cm-editor {
@apply bg-transparent! outline-hidden! text-[13px]! leading-[1.6]!;
font-family:
"SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", ui-monospace,
monospace !important;
}
.cm-gutters {
@apply bg-background! dark:bg-zinc-800! outline-hidden! selection:bg-zinc-900!;
@apply bg-transparent! border-r-0! outline-hidden!;
}
.cm-gutter.cm-lineNumbers {
@apply min-w-[3rem] text-muted-foreground/40 text-[11px]!;
}
.cm-scroller {
@apply overflow-auto!;
}
.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
.ͼo.cm-selectionBackground,
.ͼo.cm-content::selection {
@apply bg-zinc-200! dark:bg-zinc-900!;
background: oklch(0.55 0.12 250 / 0.15) !important;
}
.dark
.ͼo.cm-focused
> .cm-scroller
> .cm-selectionLayer
.cm-selectionBackground,
.dark .ͼo.cm-selectionBackground,
.dark .ͼo.cm-content::selection {
background: oklch(0.55 0.12 250 / 0.2) !important;
}
.cm-activeLine {
@apply bg-muted/50! rounded-sm!;
}
.cm-activeLine,
.cm-activeLineGutter {
@apply bg-transparent!;
}
.cm-activeLine {
@apply rounded-r-sm!;
.cm-activeLineGutter .cm-gutterElement {
color: var(--foreground) !important;
opacity: 0.7;
}
.cm-lineNumbers {
@apply min-w-7;
.cm-gutter.cm-lineNumbers .cm-gutterElement {
padding-right: 12px !important;
}
.cm-foldGutter {
@apply min-w-3;
}
.cm-lineNumbers .cm-activeLineGutter {
@apply rounded-l-sm!;
.cm-cursor {
border-left-color: oklch(0.55 0.12 250) !important;
border-left-width: 2px !important;
}
.cm-matchingBracket {
background: oklch(0.55 0.12 250 / 0.12) !important;
outline: 1px solid oklch(0.55 0.12 250 / 0.3);
border-radius: 2px;
}
.suggestion-highlight {
@apply 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);
@apply cursor-pointer rounded-sm bg-blue-200 transition-colors hover:bg-blue-300 dark:bg-blue-500/40 dark:text-blue-50 dark:hover:bg-blue-400/50;
user-select: none;
-webkit-user-select: none;
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: oklch(0 0 0 / 0.12) transparent;
}
body {
@apply bg-background text-foreground;
.dark * {
scrollbar-color: oklch(1 0 0 / 0.1) transparent;
}
*::-webkit-scrollbar {
width: 4px;
height: 4px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: oklch(0 0 0 / 0.12);
border-radius: 9999px;
}
*::-webkit-scrollbar-thumb:hover {
background: oklch(0 0 0 / 0.25);
}
.dark *::-webkit-scrollbar-thumb {
background: oklch(1 0 0 / 0.1);
}
.dark *::-webkit-scrollbar-thumb:hover {
background: oklch(1 0 0 / 0.2);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
}
[data-testid="artifact"] {
isolation: isolate;
}

View file

@ -1,11 +1,10 @@
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 "katex/dist/katex.min.css";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
import { SessionProvider } from "next-auth/react";
export const metadata: Metadata = {
metadataBase: new URL("https://chat.vercel.ai"),
@ -14,7 +13,7 @@ export const metadata: Metadata = {
};
export const viewport = {
maximumScale: 1, // Disable auto-zoom on mobile Safari
maximumScale: 1,
};
const geist = Geist({
@ -57,10 +56,6 @@ 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
>
@ -79,10 +74,12 @@ export default function RootLayout({
disableTransitionOnChange
enableSystem
>
<Toaster position="top-center" />
{children}
<SessionProvider
basePath={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth`}
>
<TooltipProvider>{children}</TooltipProvider>
</SessionProvider>
</ThemeProvider>
<Analytics />
</body>
</html>
);

View file

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

View file

@ -1,75 +1,59 @@
import { streamObject } from "ai";
import { z } from "zod";
import { streamText } from "ai";
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
import { getArtifactModel } from "@/lib/ai/providers";
import { getLanguageModel } 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 }) => {
onCreateDocument: async ({ title, dataStream, modelId }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
system: codePrompt,
const { fullStream } = streamText({
model: getLanguageModel(modelId),
system: `${codePrompt}\n\nOutput ONLY the code. No explanations, no markdown fences, no wrapping.`,
prompt: title,
schema: z.object({
code: z.string(),
}),
});
for await (const delta of fullStream) {
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;
}
if (delta.type === "text-delta") {
draftContent += delta.text;
dataStream.write({
type: "data-codeDelta",
data: stripFences(draftContent),
transient: true,
});
}
}
return draftContent;
return stripFences(draftContent);
},
onUpdateDocument: async ({ document, description, dataStream }) => {
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
system: updateDocumentPrompt(document.content, "code"),
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.`,
prompt: description,
schema: z.object({
code: z.string(),
}),
});
for await (const delta of fullStream) {
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;
}
if (delta.type === "text-delta") {
draftContent += delta.text;
dataStream.write({
type: "data-codeDelta",
data: stripFences(draftContent),
transient: true,
});
}
}
return draftContent;
return stripFences(draftContent);
},
});

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import { DiffView } from "@/components/diffview";
import { DocumentSkeleton } from "@/components/document-skeleton";
import { Artifact } from "@/components/chat/create-artifact";
import { DiffView } from "@/components/chat/diffview";
import { DocumentSkeleton } from "@/components/chat/document-skeleton";
import {
ClockRewind,
CopyIcon,
@ -9,8 +9,8 @@ import {
PenIcon,
RedoIcon,
UndoIcon,
} from "@/components/icons";
import { Editor } from "@/components/text-editor";
} from "@/components/chat/icons";
import { Editor } from "@/components/chat/text-editor";
import type { Suggestion } from "@/lib/db/schema";
import { getSuggestions } from "../actions";
@ -69,21 +69,28 @@ export const textArtifact = new Artifact<"text", TextArtifactMetadata>({
}
if (mode === "diff") {
const oldContent = getDocumentContentById(currentVersionIndex - 1);
const newContent = getDocumentContentById(currentVersionIndex);
const selectedContent = getDocumentContentById(currentVersionIndex);
const prevContent =
currentVersionIndex > 0
? getDocumentContentById(currentVersionIndex - 1)
: selectedContent;
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">
<DiffView newContent={selectedContent} oldContent={prevContent} />
</div>
);
}
return (
<div className="flex flex-row px-4 py-8 md:p-20">
<div className="flex flex-row px-4 py-8 md:px-16 md:py-12 lg:px-20">
<Editor
content={content}
currentVersionIndex={currentVersionIndex}
isCurrentVersion={isCurrentVersion}
onSaveContent={onSaveContent}
status={status}
suggestions={metadata ? metadata.suggestions : []}
suggestions={isCurrentVersion && 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 { getArtifactModel } from "@/lib/ai/providers";
import { getLanguageModel } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const textDocumentHandler = createDocumentHandler<"text">({
kind: "text",
onCreateDocument: async ({ title, dataStream }) => {
onCreateDocument: async ({ title, dataStream, modelId }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getArtifactModel(),
model: getLanguageModel(modelId),
system:
"Write about the given topic. Markdown is supported. Use headings wherever appropriate.",
experimental_transform: smoothStream({ chunking: "word" }),
@ -17,16 +17,11 @@ export const textDocumentHandler = createDocumentHandler<"text">({
});
for await (const delta of fullStream) {
const { type } = delta;
if (type === "text-delta") {
const { text } = delta;
draftContent += text;
if (delta.type === "text-delta") {
draftContent += delta.text;
dataStream.write({
type: "data-textDelta",
data: text,
data: delta.text,
transient: true,
});
}
@ -34,35 +29,22 @@ export const textDocumentHandler = createDocumentHandler<"text">({
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream }) => {
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getArtifactModel(),
model: getLanguageModel(modelId),
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) {
const { type } = delta;
if (type === "text-delta") {
const { text } = delta;
draftContent += text;
if (delta.type === "text-delta") {
draftContent += delta.text;
dataStream.write({
type: "data-textDelta",
data: text,
data: delta.text,
transient: true,
});
}

View file

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

View file

@ -55,9 +55,7 @@ export const MessageContent = ({
}: MessageContentProps) => (
<div
className={cn(
"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",
"flex min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm text-foreground",
className
)}
{...props}

View file

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

View file

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

View file

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

View file

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

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

View file

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

@ -0,0 +1,165 @@
"use client";
import {
MessageSquareIcon,
PanelLeftIcon,
PenSquareIcon,
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { User } from "next-auth";
import { useState } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import {
getChatHistoryPaginationKey,
SidebarHistory,
} from "@/components/chat/sidebar-history";
import { SidebarUserNav } from "@/components/chat/sidebar-user-nav";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function AppSidebar({ user }: { user: User | undefined }) {
const router = useRouter();
const { setOpenMobile, toggleSidebar } = useSidebar();
const { mutate } = useSWRConfig();
const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
const handleDeleteAll = () => {
setShowDeleteAllDialog(false);
router.replace("/");
mutate(unstable_serialize(getChatHistoryPaginationKey), [], {
revalidate: false,
});
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, {
method: "DELETE",
});
toast.success("All chats deleted");
};
return (
<>
<Sidebar collapsible="icon">
<SidebarHeader className="pb-0 pt-3">
<SidebarMenu>
<SidebarMenuItem className="flex flex-row items-center justify-between">
<div className="group/logo relative flex items-center justify-center">
<SidebarMenuButton
asChild
className="size-8 !px-0 items-center justify-center group-data-[collapsible=icon]:group-hover/logo:opacity-0"
tooltip="Chatbot"
>
<Link href="/" onClick={() => setOpenMobile(false)}>
<MessageSquareIcon className="size-4 text-sidebar-foreground/50" />
</Link>
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
className="pointer-events-none absolute inset-0 size-8 opacity-0 group-data-[collapsible=icon]:pointer-events-auto group-data-[collapsible=icon]:group-hover/logo:opacity-100"
onClick={() => toggleSidebar()}
>
<PanelLeftIcon className="size-4" />
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent className="hidden md:block" side="right">
Open sidebar
</TooltipContent>
</Tooltip>
</div>
<div className="group-data-[collapsible=icon]:hidden">
<SidebarTrigger className="text-sidebar-foreground/60 transition-colors duration-150 hover:text-sidebar-foreground" />
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="pt-1">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="h-8 rounded-lg border border-sidebar-border text-[13px] text-sidebar-foreground/70 transition-colors duration-150 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
onClick={() => {
setOpenMobile(false);
router.push("/");
}}
tooltip="New Chat"
>
<PenSquareIcon className="size-4" />
<span className="font-medium">New chat</span>
</SidebarMenuButton>
</SidebarMenuItem>
{user && (
<SidebarMenuItem>
<SidebarMenuButton
className="rounded-lg text-sidebar-foreground/40 transition-colors duration-150 hover:bg-destructive/10 hover:text-destructive"
onClick={() => setShowDeleteAllDialog(true)}
tooltip="Delete All Chats"
>
<TrashIcon className="size-4" />
<span className="text-[13px]">Delete all</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarHistory user={user} />
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border pt-2 pb-3">
{user && <SidebarUserNav user={user} />}
</SidebarFooter>
<SidebarRail />
</Sidebar>
<AlertDialog
onOpenChange={setShowDeleteAllDialog}
open={showDeleteAllDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete all chats?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete all
your chats and remove them from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteAll}>
Delete All
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,119 @@
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,14 +1,13 @@
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="h-fit p-2 dark:hover:bg-zinc-700"
<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"
data-testid="artifact-close-button"
onClick={() => {
setArtifact((currentArtifact) =>
@ -20,10 +19,10 @@ function PureArtifactCloseButton() {
: { ...initialArtifactData, status: "idle" }
);
}}
variant="outline"
type="button"
>
<CrossIcon size={18} />
</Button>
<CrossIcon size={16} />
</button>
);
}

View file

@ -0,0 +1,482 @@
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,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,40 +15,33 @@ export function AuthForm({
defaultEmail?: string;
}) {
return (
<Form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
<Form action={action} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="email"
>
Email Address
<Label className="font-normal text-muted-foreground" htmlFor="email">
Email
</Label>
<Input
autoComplete="email"
autoFocus
className="bg-muted text-md md:text-sm"
className="h-10 rounded-lg border-border/50 bg-muted/50 text-sm transition-colors focus:border-foreground/20 focus:bg-muted"
defaultValue={defaultEmail}
id="email"
name="email"
placeholder="user@acme.com"
placeholder="you@someo.ne"
required
type="email"
/>
</div>
<div className="flex flex-col gap-2">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="password"
>
<Label className="font-normal text-muted-foreground" htmlFor="password">
Password
</Label>
<Input
className="bg-muted text-md md:text-sm"
className="h-10 rounded-lg border-border/50 bg-muted/50 text-sm transition-colors focus:border-foreground/20 focus:bg-muted"
id="password"
name="password"
placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
required
type="password"
/>

View file

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

View file

@ -20,6 +20,7 @@ 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) {
@ -40,8 +41,6 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
editorRef.current = null;
}
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
useEffect(() => {
@ -59,17 +58,44 @@ 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],
extensions: [
basicSetup,
python(),
oneDark,
updateListener,
scrollListener,
],
selection: currentSelection,
});
editorRef.current.setState(newState);
}
}, [onSaveContent]);
}, [onSaveContent, status]);
useEffect(() => {
if (status !== "streaming") {
userScrolledRef.current = false;
}
}, [status]);
useEffect(() => {
if (editorRef.current && content) {
@ -86,36 +112,43 @@ 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 pb-[calc(80dvh)] text-sm"
className="not-prose relative w-full min-h-[300px] pb-[calc(50dvh)]"
ref={containerRef}
/>
);
}
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;
}
export const CodeEditor = memo(PureCodeEditor, (prevProps, nextProps) => {
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
return false;
}
if (prevProps.content !== nextProps.content) {
return false;
}
return true;
}
if (prevProps.status !== nextProps.status) {
return false;
}
export const CodeEditor = memo(PureCodeEditor, areEqual);
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
return true;
});

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,7 +31,6 @@ 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);
@ -67,9 +66,13 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
};
}, [resize, stopResizing]);
const consoleContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
if (consoleOutputs.length > 0) {
consoleContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}
}, [consoleOutputs.length]);
useEffect(() => {
if (!isArtifactVisible) {
@ -101,38 +104,35 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
<div
className={cn(
"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,
}
"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 }
)}
ref={consoleContainerRef}
style={{ height }}
>
<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 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>
<Button
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
className="size-7 text-muted-foreground/50 hover:text-foreground"
onClick={() => setConsoleOutputs([])}
size="icon"
size="icon-sm"
variant="ghost"
>
<CrossSmallIcon />
</Button>
</div>
<div>
{consoleOutputs.map((consoleOutput, index) => (
<div className="bg-background">
{[...consoleOutputs].reverse().map((consoleOutput, index) => (
<div
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"
className="flex border-b border-border/30 px-4 py-2.5 font-mono text-[12px] leading-relaxed"
key={consoleOutput.id}
>
<div
className={cn("w-12 shrink-0", {
className={cn("w-10 shrink-0 tabular-nums", {
"text-muted-foreground": [
"in_progress",
"loading_packages",
@ -141,16 +141,14 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
"text-red-400": consoleOutput.status === "failed",
})}
>
[{index + 1}]
[{consoleOutputs.length - index}]
</div>
{["in_progress", "loading_packages"].includes(
consoleOutput.status
) ? (
<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">
<div className="flex items-center gap-2">
<Spinner className="size-3.5" />
<span className="text-muted-foreground">
{consoleOutput.status === "in_progress"
? "Initializing..."
: consoleOutput.status === "loading_packages"
@ -158,23 +156,25 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
content.type === "text" ? content.value : null
)
: null}
</div>
</span>
</div>
) : (
<div className="flex w-full flex-col gap-2 overflow-x-scroll text-zinc-900 dark:text-zinc-50">
<div className="no-scrollbar flex w-full min-w-0 flex-col gap-2 overflow-x-auto text-foreground">
{consoleOutput.contents.map((content) =>
content.type === "image" ? (
<picture key={`${consoleOutput.id}-${content.value}`}>
<picture
key={`${consoleOutput.id}-img-${content.value.slice(0, 32)}`}
>
<img
alt="output"
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
className="max-w-full rounded-md"
src={content.value}
/>
</picture>
) : (
<div
className="w-full whitespace-pre-line break-words"
key={`${consoleOutput.id}-${content.value}`}
key={`${consoleOutput.id}-txt-${content.value.slice(0, 32)}`}
>
{content.value}
</div>
@ -184,7 +184,6 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
)}
</div>
))}
<div ref={consoleEndRef} />
</div>
</div>
</>

View file

@ -23,7 +23,6 @@ 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-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300";
"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 rounded-sm px-0.5 -mx-0.5";
break;
case DiffType.Deleted:
className =
"bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300";
"bg-red-500/15 line-through text-red-600 dark:text-red-400 rounded-sm px-0.5 -mx-0.5 opacity-70";
break;
default:
className = "";
@ -60,10 +60,10 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
const parser = DOMParser.fromSchema(diffSchema);
const oldHtmlContent = renderToString(
<Streamdown>{oldContent}</Streamdown>
<MessageResponse>{oldContent}</MessageResponse>
);
const newHtmlContent = renderToString(
<Streamdown>{newContent}</Streamdown>
<MessageResponse>{newContent}</MessageResponse>
);
const oldContainer = document.createElement("div");
@ -86,6 +86,15 @@ 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 () => {
@ -96,5 +105,10 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
};
}, [oldContent, newContent]);
return <div className="diff-editor" ref={editorRef} />;
return (
<div
className="diff-editor prose dark:prose-invert prose-neutral relative max-w-none"
ref={editorRef}
/>
);
};

View file

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

@ -37,7 +37,7 @@ function PureDocumentToolResult({
return (
<button
className="flex w-fit cursor-pointer flex-row items-start gap-3 rounded-xl border bg-background px-3 py-2"
className="flex w-fit cursor-pointer flex-row items-center gap-2 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="mt-1 text-muted-foreground">
<div className="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 } // for create
| { id: string; description: string } // for update
| { documentId: string }; // for request-suggestions
| { title: string; kind: ArtifactKind }
| { id: string; description: string }
| { documentId: string };
isReadonly: boolean;
};
@ -130,7 +130,7 @@ function PureDocumentToolCall({
type="button"
>
<div className="flex flex-row items-start gap-3">
<div className="mt-1 text-zinc-500">
<div className="mt-1 text-neutral-500">
{type === "create" ? (
<FileIcon />
) : type === "update" ? (

View file

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

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,
setMode,
onEdit,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMode?: (mode: "view" | "edit") => void;
onEdit?: () => void;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
@ -47,22 +47,25 @@ 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">
<div className="relative">
{setMode && (
<Actions className="-mr-0.5 justify-end opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
<div className="flex items-center gap-0.5">
{onEdit && (
<Action
className="absolute top-0 -left-10 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
className="size-7 text-muted-foreground/50 hover:text-foreground"
data-testid="message-edit-button"
onClick={() => setMode("edit")}
onClick={onEdit}
tooltip="Edit"
>
<PencilEditIcon />
</Action>
)}
<Action onClick={handleCopy} tooltip="Copy">
<Action
className="size-7 text-muted-foreground/50 hover:text-foreground"
onClick={handleCopy}
tooltip="Copy"
>
<CopyIcon />
</Action>
</div>
@ -71,12 +74,17 @@ export function PureMessageActions({
}
return (
<Actions className="-ml-0.5">
<Action onClick={handleCopy} tooltip="Copy">
<Actions className="-ml-0.5 opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
<Action
className="text-muted-foreground/50 hover:text-foreground"
onClick={handleCopy}
tooltip="Copy"
>
<CopyIcon />
</Action>
<Action
className="text-muted-foreground/50 hover:text-foreground"
data-testid="message-upvote"
disabled={vote?.isUpvoted}
onClick={() => {
@ -129,6 +137,7 @@ 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,33 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import { deleteTrailingMessages } from "@/app/(chat)/actions";
import type { ChatMessage } from "@/lib/types";
export async function submitEditedMessage({
message,
text,
setMessages,
regenerate,
}: {
message: ChatMessage;
text: string;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
}) {
await deleteTrailingMessages({ id: message.id });
setMessages((messages) => {
const index = messages.findIndex((m) => m.id === message.id);
if (index === -1) {
return messages;
}
return [
...messages.slice(0, index),
{ ...message, parts: [{ type: "text" as const, text }] },
];
});
regenerate();
}

View file

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

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

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

View file

@ -3,7 +3,15 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import type { UIMessage } from "ai";
import equal from "fast-deep-equal";
import { ArrowUpIcon, CheckIcon } from "lucide-react";
import {
ArrowUpIcon,
BrainIcon,
EyeIcon,
LockIcon,
WrenchIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import {
type ChangeEvent,
type Dispatch,
@ -15,6 +23,7 @@ import {
useState,
} from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { useLocalStorage, useWindowSize } from "usehooks-ts";
import {
ModelSelector,
@ -28,11 +37,11 @@ import {
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector";
import {
type ChatModel,
chatModels,
DEFAULT_CHAT_MODEL,
modelsByProvider,
type ModelCapabilities,
} from "@/lib/ai/models";
import { signIn, useSession } from "@/lib/client";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
import {
@ -41,15 +50,20 @@ import {
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from "./ai-elements/prompt-input";
} from "../ai-elements/prompt-input";
import { Button } from "../ui/button";
import { PaperclipIcon, StopIcon } from "./icons";
import { PreviewAttachment } from "./preview-attachment";
import {
type SlashCommand,
SlashCommandMenu,
slashCommands,
} from "./slash-commands";
import { SuggestedActions } from "./suggested-actions";
import { Button } from "./ui/button";
import type { VisibilityType } from "./visibility-selector";
function setCookie(name: string, value: string) {
const maxAge = 60 * 60 * 24 * 365; // 1 year
const maxAge = 60 * 60 * 24 * 365;
// biome-ignore lint/suspicious/noDocumentCookie: needed for client-side cookie setting
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`;
}
@ -69,6 +83,9 @@ function PureMultimodalInput({
selectedVisibilityType,
selectedModelId,
onModelChange,
editingMessage,
onCancelEdit,
isLoading,
}: {
chatId: string;
input: string;
@ -79,17 +96,21 @@ function PureMultimodalInput({
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: UIMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
sendMessage:
| UseChatHelpers<ChatMessage>["sendMessage"]
| (() => Promise<void>);
className?: string;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
onModelChange?: (modelId: string) => void;
editingMessage?: ChatMessage | null;
onCancelEdit?: () => void;
isLoading?: boolean;
}) {
const router = useRouter();
const { setTheme, resolvedTheme } = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { width } = useWindowSize();
const { data: session, refetch: refetchSession } = useSession();
const [isSigningIn, setIsSigningIn] = useState(false);
const hasAutoFocused = useRef(false);
useEffect(() => {
if (!hasAutoFocused.current && width) {
@ -109,12 +130,9 @@ 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(() => {
@ -122,11 +140,80 @@ function PureMultimodalInput({
}, [input, setLocalStorageInput]);
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(event.target.value);
const val = event.target.value;
setInput(val);
if (val.startsWith("/") && !val.includes(" ")) {
setSlashOpen(true);
setSlashQuery(val.slice(1));
setSlashIndex(0);
} else {
setSlashOpen(false);
}
};
const handleSlashSelect = (cmd: SlashCommand) => {
setSlashOpen(false);
setInput("");
switch (cmd.action) {
case "new":
router.push("/");
break;
case "clear":
setMessages(() => []);
break;
case "rename":
toast("Rename is available from the sidebar chat menu.");
break;
case "model": {
const modelBtn = document.querySelector<HTMLButtonElement>(
"[data-testid='model-selector']"
);
modelBtn?.click();
break;
}
case "theme":
setTheme(resolvedTheme === "dark" ? "light" : "dark");
break;
case "delete":
toast("Delete this chat?", {
action: {
label: "Delete",
onClick: () => {
fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatId}`,
{ method: "DELETE" }
);
router.push("/");
toast.success("Chat deleted");
},
},
});
break;
case "purge":
toast("Delete all chats?", {
action: {
label: "Delete all",
onClick: () => {
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, {
method: "DELETE",
});
router.push("/");
toast.success("All chats deleted");
},
},
});
break;
default:
break;
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
const [slashOpen, setSlashOpen] = useState(false);
const [slashQuery, setSlashQuery] = useState("");
const [slashIndex, setSlashIndex] = useState(0);
const submitForm = useCallback(() => {
window.history.pushState(
@ -216,8 +303,8 @@ function PureMultimodalInput({
...currentAttachments,
...successfullyUploadedAttachments,
]);
} catch (error) {
console.error("Error uploading files!", error);
} catch (_error) {
toast.error("Failed to upload files");
} finally {
setUploadQueue([]);
}
@ -240,7 +327,6 @@ function PureMultimodalInput({
return;
}
// Prevent default paste behavior for images
event.preventDefault();
setUploadQueue((prev) => [...prev, "Pasted image"]);
@ -263,8 +349,7 @@ function PureMultimodalInput({
...curr,
...(successfullyUploadedAttachments as Attachment[]),
]);
} catch (error) {
console.error("Error uploading pasted images:", error);
} catch (_error) {
toast.error("Failed to upload pasted image(s)");
} finally {
setUploadQueue([]);
@ -273,7 +358,6 @@ function PureMultimodalInput({
[setAttachments, uploadFile]
);
// Add paste event listener to textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
@ -286,7 +370,25 @@ function PureMultimodalInput({
return (
<div className={cn("relative flex w-full flex-col gap-4", className)}>
{messages.length === 0 &&
{editingMessage && onCancelEdit && (
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span>Editing message</span>
<button
className="rounded px-1.5 py-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground"
onMouseDown={(e) => {
e.preventDefault();
onCancelEdit();
}}
type="button"
>
Cancel
</button>
</div>
)}
{!editingMessage &&
!isLoading &&
messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
<SuggestedActions
@ -305,24 +407,32 @@ function PureMultimodalInput({
type="file"
/>
<div className="relative">
{slashOpen && (
<SlashCommandMenu
onClose={() => setSlashOpen(false)}
onSelect={handleSlashSelect}
query={slashQuery}
selectedIndex={slashIndex}
/>
)}
</div>
<PromptInput
className="[&>div]:rounded-xl"
onSubmit={async () => {
className="[&>div]:rounded-2xl [&>div]:border [&>div]:border-border/30 [&>div]:bg-card/70 [&>div]:shadow-[var(--shadow-composer)] [&>div]:transition-shadow [&>div]:duration-300 [&>div]:focus-within:shadow-[var(--shadow-composer-focus)]"
onSubmit={() => {
if (input.startsWith("/")) {
const query = input.slice(1).trim();
const cmd = slashCommands.find((c) => c.name === query);
if (cmd) {
handleSlashSelect(cmd);
}
return;
}
if (!input.trim() && attachments.length === 0) {
return;
}
if (!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") {
if (status === "ready" || status === "error") {
submitForm();
} else {
toast.error("Please wait for the model to finish its response!");
@ -331,7 +441,7 @@ function PureMultimodalInput({
>
{(attachments.length > 0 || uploadQueue.length > 0) && (
<div
className="flex flex-row items-end gap-2 overflow-x-scroll"
className="flex w-full self-start flex-row gap-2 overflow-x-auto px-3 pt-3 no-scrollbar"
data-testid="attachments-preview"
>
{attachments.map((attachment) => (
@ -363,14 +473,49 @@ function PureMultimodalInput({
</div>
)}
<PromptInputTextarea
className="p-6 min-h-24"
className="min-h-24 text-[13px] leading-relaxed px-4 pt-3.5 pb-1.5 placeholder:text-muted-foreground/35"
data-testid="multimodal-input"
onChange={handleInput}
placeholder="Send a message..."
onKeyDown={(e) => {
if (slashOpen) {
const filtered = slashCommands.filter((cmd) =>
cmd.name.startsWith(slashQuery.toLowerCase())
);
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashIndex((i) => Math.min(i + 1, filtered.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
if (filtered[slashIndex]) {
handleSlashSelect(filtered[slashIndex]);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashOpen(false);
return;
}
}
if (e.key === "Escape" && editingMessage && onCancelEdit) {
e.preventDefault();
onCancelEdit();
}
}}
placeholder={
editingMessage ? "Edit your message..." : "Ask anything..."
}
ref={textareaRef}
value={input}
/>
<PromptInputFooter>
<PromptInputFooter className="px-3 pb-3">
<PromptInputTools>
<AttachmentsButton
fileInputRef={fileInputRef}
@ -387,7 +532,12 @@ function PureMultimodalInput({
<StopButton setMessages={setMessages} stop={stop} />
) : (
<PromptInputSubmit
className="rounded-full"
className={cn(
"h-7 w-7 rounded-xl transition-all duration-200",
input.trim()
? "bg-foreground text-background hover:opacity-85 active:scale-95"
: "bg-muted text-muted-foreground/25 cursor-not-allowed"
)}
data-testid="send-button"
disabled={!input.trim() || uploadQueue.length > 0}
status={status}
@ -420,6 +570,15 @@ 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;
}
@ -434,14 +593,26 @@ function PureAttachmentsButton({
status: UseChatHelpers<ChatMessage>["status"];
selectedModelId: string;
}) {
const isReasoningModel =
selectedModelId.includes("reasoning") || selectedModelId.includes("think");
const { data: modelsResponse } = useSWR(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`,
(url: string) => fetch(url).then((r) => r.json()),
{ revalidateOnFocus: false, dedupingInterval: 3_600_000 }
);
const caps: Record<string, ModelCapabilities> | undefined =
modelsResponse?.capabilities ?? modelsResponse;
const hasVision = caps?.[selectedModelId]?.vision ?? false;
return (
<Button
className="aspect-square h-8 rounded-lg p-1 transition-colors hover:bg-accent"
className={cn(
"h-7 w-7 rounded-lg border border-border/40 p-1 transition-colors",
hasVision
? "text-foreground hover:border-border hover:text-foreground"
: "text-muted-foreground/30 cursor-not-allowed"
)}
data-testid="attachments-button"
disabled={status !== "ready" || isReasoningModel}
disabled={status !== "ready" || !hasVision}
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
@ -463,26 +634,31 @@ 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 =
chatModels.find((m) => m.id === selectedModelId) ??
chatModels.find((m) => m.id === DEFAULT_CHAT_MODEL) ??
chatModels[0];
activeModels.find((m: ChatModel) => m.id === selectedModelId) ??
activeModels.find((m: ChatModel) => m.id === DEFAULT_CHAT_MODEL) ??
activeModels[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-8 w-[200px] justify-between px-2" variant="ghost">
<Button
className="h-7 max-w-[200px] justify-between gap-1.5 rounded-lg px-2 text-[12px] text-muted-foreground transition-colors hover:text-foreground"
data-testid="model-selector"
variant="ghost"
>
{provider && <ModelSelectorLogo provider={provider} />}
<ModelSelectorName>{selectedModel.name}</ModelSelectorName>
</Button>
@ -490,35 +666,123 @@ function PureModelSelectorCompact({
<ModelSelectorContent>
<ModelSelectorInput placeholder="Search models..." />
<ModelSelectorList>
{Object.entries(modelsByProvider).map(
([providerKey, providerModels]) => (
{(() => {
const curatedIds = new Set(chatModels.map((m) => m.id));
const allModels = dynamicModels
? [
...chatModels,
...dynamicModels.filter((m) => !curatedIds.has(m.id)),
]
: chatModels;
const grouped: Record<
string,
{ model: ChatModel; curated: boolean }[]
> = {};
for (const model of allModels) {
const key = curatedIds.has(model.id)
? "_available"
: model.provider;
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push({ model, curated: curatedIds.has(model.id) });
}
const sortedKeys = Object.keys(grouped).sort((a, b) => {
if (a === "_available") {
return -1;
}
if (b === "_available") {
return 1;
}
return a.localeCompare(b);
});
const providerNames: Record<string, string> = {
alibaba: "Alibaba",
anthropic: "Anthropic",
"arcee-ai": "Arcee AI",
bytedance: "ByteDance",
cohere: "Cohere",
deepseek: "DeepSeek",
google: "Google",
inception: "Inception",
kwaipilot: "Kwaipilot",
meituan: "Meituan",
meta: "Meta",
minimax: "MiniMax",
mistral: "Mistral",
moonshotai: "Moonshot",
morph: "Morph",
nvidia: "Nvidia",
openai: "OpenAI",
perplexity: "Perplexity",
"prime-intellect": "Prime Intellect",
xiaomi: "Xiaomi",
xai: "xAI",
zai: "Zai",
};
return sortedKeys.map((key) => (
<ModelSelectorGroup
heading={providerNames[providerKey] ?? providerKey}
key={providerKey}
heading={
key === "_available"
? "Available"
: (providerNames[key] ?? key)
}
key={key}
>
{providerModels.map((model) => {
{grouped[key].map(({ model, curated }) => {
const logoProvider = model.id.split("/")[0];
return (
<ModelSelectorItem
className={cn(
"flex w-full",
model.id === selectedModel.id &&
"border-b border-dashed border-foreground/50",
!curated && "opacity-40 cursor-default"
)}
key={model.id}
onSelect={() => {
if (!curated) {
return;
}
onModelChange?.(model.id);
setCookie("chat-model", model.id);
setOpen(false);
setTimeout(() => {
document
.querySelector<HTMLTextAreaElement>(
"[data-testid='multimodal-input']"
)
?.focus();
}, 50);
}}
value={model.id}
>
<ModelSelectorLogo provider={logoProvider} />
<ModelSelectorName>{model.name}</ModelSelectorName>
{model.id === selectedModel.id && (
<CheckIcon className="ml-auto size-4" />
)}
<div className="ml-auto flex items-center gap-2 text-foreground/70">
{capabilities?.[model.id]?.tools && (
<WrenchIcon className="size-3.5" />
)}
{capabilities?.[model.id]?.vision && (
<EyeIcon className="size-3.5" />
)}
{capabilities?.[model.id]?.reasoning && (
<BrainIcon className="size-3.5" />
)}
{!curated && (
<LockIcon className="size-3 text-muted-foreground/50" />
)}
</div>
</ModelSelectorItem>
);
})}
</ModelSelectorGroup>
)
)}
));
})()}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
@ -536,7 +800,7 @@ function PureStopButton({
}) {
return (
<Button
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"
className="h-7 w-7 rounded-xl bg-foreground p-1 text-background transition-all duration-200 hover:opacity-85 active:scale-95 disabled:bg-muted disabled:text-muted-foreground/25 disabled:cursor-not-allowed"
data-testid="stop-button"
onClick={(event) => {
event.preventDefault();

View file

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

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

View file

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

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

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

@ -2,14 +2,6 @@ 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,
@ -19,12 +11,20 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
} from "../ui/dropdown-menu";
import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "./ui/sidebar";
} from "../ui/sidebar";
import {
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from "./icons";
const PureChatItem = ({
chat,
@ -44,16 +44,20 @@ const PureChatItem = ({
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<SidebarMenuButton
asChild
className="h-8 rounded-none text-[13px] text-sidebar-foreground/50 transition-all duration-150 hover:bg-transparent hover:text-sidebar-foreground data-active:bg-transparent data-active:font-normal data-active:text-sidebar-foreground/50 data-[active=true]:text-sidebar-foreground data-[active=true]:font-medium data-[active=true]:border-b data-[active=true]:border-dashed data-[active=true]:border-sidebar-foreground/50"
isActive={isActive}
>
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
<span>{chat.title}</span>
<span className="truncate">{chat.title}</span>
</Link>
</SidebarMenuButton>
<DropdownMenu modal={true}>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
className="mr-0.5 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
className="mr-0.5 rounded-md text-sidebar-foreground/50 ring-0 transition-colors duration-150 focus-visible:ring-0 hover:text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
showOnHover={!isActive}
>
<MoreHorizontalIcon />
@ -100,8 +104,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,6 +3,7 @@
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";
@ -19,10 +20,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";
@ -97,7 +98,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: AuthUser | undefined }) {
export function SidebarHistory({ user }: { user: User | undefined }) {
const { setOpenMobile } = useSidebar();
const pathname = usePathname();
const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null;
@ -111,7 +112,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
} = useSWRInfinite<ChatHistory>(
user ? getChatHistoryPaginationKey : () => null,
fetcher,
{ fallbackData: [] }
{ fallbackData: [], revalidateOnFocus: false }
);
const router = useRouter();
@ -132,43 +133,32 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
setShowDeleteDialog(false);
const deletePromise = fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatToDelete}`,
{
method: "DELETE",
if (isCurrentChat) {
router.replace("/");
}
mutate((chatHistories) => {
if (chatHistories) {
return chatHistories.map((chatHistory) => ({
...chatHistory,
chats: chatHistory.chats.filter((chat) => chat.id !== chatToDelete),
}));
}
});
fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatToDelete}`,
{ method: "DELETE" }
);
toast.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",
});
toast.success("Chat deleted");
};
if (!user) {
return (
<SidebarGroup>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
Login to save and revisit previous chats!
</div>
</SidebarGroupContent>
@ -178,19 +168,19 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
if (isLoading) {
return (
<SidebarGroup>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Today
</div>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
History
</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col">
<div className="flex flex-col gap-0.5 px-1">
{[44, 32, 28, 64, 52].map((item) => (
<div
className="flex h-8 items-center gap-2 rounded-md px-2"
className="flex h-8 items-center gap-2 rounded-lg px-2"
key={item}
>
<div
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
className="h-3 max-w-(--skeleton-width) flex-1 animate-pulse rounded-md bg-sidebar-foreground/[0.06]"
style={
{
"--skeleton-width": `${item}%`,
@ -207,9 +197,12 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
if (hasEmptyChatHistory) {
return (
<SidebarGroup>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
History
</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
Your conversations will appear here once you start chatting!
</div>
</SidebarGroupContent>
@ -219,7 +212,10 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
return (
<>
<SidebarGroup>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
History
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{paginatedChatHistories &&
@ -231,10 +227,10 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
const groupedChats = groupChatsByDate(chatsFromHistory);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
{groupedChats.today.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Today
</div>
{groupedChats.today.map((chat) => (
@ -254,7 +250,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
{groupedChats.yesterday.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Yesterday
</div>
{groupedChats.yesterday.map((chat) => (
@ -274,7 +270,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
{groupedChats.lastWeek.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Last 7 days
</div>
{groupedChats.lastWeek.map((chat) => (
@ -294,7 +290,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
{groupedChats.lastMonth.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Last 30 days
</div>
{groupedChats.lastMonth.map((chat) => (
@ -314,8 +310,8 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
{groupedChats.older.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Older than last month
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Older
</div>
{groupedChats.older.map((chat) => (
<ChatItem
@ -344,16 +340,12 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
}}
/>
{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">
{hasReachedEnd ? null : (
<div className="mt-1 flex flex-row items-center gap-2 px-4 py-2 text-sidebar-foreground/50">
<div className="animate-spin">
<LoaderIcon />
</div>
<div>Loading Chats...</div>
<div className="text-[11px]">Loading...</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,8 +1,9 @@
"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,
@ -16,64 +17,67 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { signOut, useSession } from "@/lib/client";
import { guestRegex } from "@/lib/constants";
import { LoaderIcon } from "./icons";
import { toast } from "./toast";
export function SidebarUserNav({
user,
}: {
user: { email?: string | null; isAnonymous?: boolean | null };
}) {
function emailToHue(email: string): number {
let hash = 0;
for (const char of email) {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
}
return Math.abs(hash) % 360;
}
export function SidebarUserNav({ user }: { user: User }) {
const router = useRouter();
const { data, isPending } = useSession();
const { data, status } = useSession();
const { setTheme, resolvedTheme } = useTheme();
const isGuest = data?.user?.isAnonymous ?? user.isAnonymous ?? false;
const isGuest = guestRegex.test(data?.user?.email ?? "");
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{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
{status === "loading" ? (
<SidebarMenuButton className="h-10 justify-between rounded-lg bg-transparent text-sidebar-foreground/50 transition-colors duration-150 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div className="flex flex-row items-center gap-2">
<div className="size-6 animate-pulse rounded-full bg-sidebar-foreground/10" />
<span className="animate-pulse rounded-md bg-sidebar-foreground/10 text-transparent text-[13px]">
Loading...
</span>
</div>
<div className="animate-spin text-zinc-500">
<div className="animate-spin text-sidebar-foreground/50">
<LoaderIcon />
</div>
</SidebarMenuButton>
) : (
<SidebarMenuButton
className="h-10 bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
className="h-8 px-2 rounded-lg bg-transparent text-sidebar-foreground/70 transition-colors duration-150 hover:text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-testid="user-nav-button"
>
<Image
alt={user.email ?? "User Avatar"}
className="rounded-full"
height={24}
src={`https://avatar.vercel.sh/${user.email}`}
width={24}
<div
className="size-5 shrink-0 rounded-full ring-1 ring-sidebar-border/50"
style={{
background: `linear-gradient(135deg, oklch(0.35 0.08 ${emailToHue(user.email ?? "")}), oklch(0.25 0.05 ${emailToHue(user.email ?? "") + 40}))`,
}}
/>
<span className="truncate" data-testid="user-email">
<span className="truncate text-[13px]" data-testid="user-email">
{isGuest ? "Guest" : user?.email}
</span>
<ChevronUp className="ml-auto" />
<ChevronUp className="ml-auto size-3.5 text-sidebar-foreground/50" />
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-popper-anchor-width)"
className="w-(--radix-popper-anchor-width) rounded-lg border border-border/60 bg-card/95 backdrop-blur-xl shadow-[var(--shadow-float)]"
data-testid="user-nav-menu"
side="top"
>
<DropdownMenuItem
className="cursor-pointer"
className="cursor-pointer text-[13px]"
data-testid="user-nav-item-theme"
onSelect={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
@ -84,9 +88,9 @@ export function SidebarUserNav({
<DropdownMenuSeparator />
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button
className="w-full cursor-pointer"
className="w-full cursor-pointer text-[13px]"
onClick={() => {
if (isPending) {
if (status === "loading") {
toast({
type: "error",
description:
@ -100,12 +104,7 @@ export function SidebarUserNav({
router.push("/login");
} else {
signOut({
fetchOptions: {
onSuccess: () => {
router.push("/");
router.refresh();
},
},
redirectTo: "/",
});
}
}}

View file

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

View file

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

View file

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

View file

@ -3,8 +3,9 @@
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 = {
@ -14,28 +15,33 @@ type SuggestedActionsProps = {
};
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
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?",
];
const suggestedActions = suggestions;
return (
<div
className="grid w-full gap-2 sm:grid-cols-2"
className="flex w-full gap-2.5 overflow-x-auto pb-1 sm:grid sm:grid-cols-2 sm:overflow-visible"
data-testid="suggested-actions"
style={{
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
msOverflowStyle: "none",
}}
>
{suggestedActions.map((suggestedAction, index) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 20 }}
className="min-w-[200px] shrink-0 sm:min-w-0 sm:shrink"
exit={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 16 }}
key={suggestedAction}
transition={{ delay: 0.05 * index }}
transition={{
delay: 0.06 * index,
duration: 0.4,
ease: [0.22, 1, 0.36, 1],
}}
>
<Suggestion
className="h-auto w-full whitespace-normal p-3 text-left"
className="h-auto w-full whitespace-nowrap rounded-xl border border-border/50 bg-card/30 px-4 py-3 text-left text-[12px] leading-relaxed text-muted-foreground transition-all duration-200 sm:whitespace-normal sm:p-4 sm:text-[13px] hover:-translate-y-0.5 hover:bg-card/60 hover:text-foreground hover:shadow-[var(--shadow-card)]"
onClick={(suggestion) => {
window.history.pushState(
{},

View file

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

@ -3,8 +3,9 @@
import { exampleSetup } from "prosemirror-example-setup";
import { inputRules } from "prosemirror-inputrules";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { memo, useEffect, useRef } from "react";
import { type Decoration, DecorationSet, EditorView } from "prosemirror-view";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { Suggestion } from "@/lib/db/schema";
import {
@ -21,7 +22,9 @@ import {
projectWithPositions,
suggestionsPlugin,
suggestionsPluginKey,
type UISuggestion,
} from "@/lib/editor/suggestions";
import { SuggestionDialog } from "./suggestion";
type EditorProps = {
content: string;
@ -30,6 +33,9 @@ type EditorProps = {
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Suggestion[];
onSuggestionSelect?: (suggestion: UISuggestion | null) => void;
onSuggestionApply?: () => void;
activeSuggestion?: UISuggestion | null;
};
function PureEditor({
@ -40,6 +46,10 @@ 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) {
@ -63,6 +73,21 @@ 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;
},
},
});
}
@ -72,8 +97,6 @@ function PureEditor({
editorRef.current = null;
}
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
useEffect(() => {
@ -134,6 +157,8 @@ function PureEditor({
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
);
suggestionsRef.current = projectedSuggestions;
const decorations = createDecorations(
projectedSuggestions,
editorRef.current
@ -145,8 +170,61 @@ 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 relative" ref={containerRef} />
<>
<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
)}
</>
);
}

View file

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

View file

@ -1,12 +1,8 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import cx from "classnames";
import {
AnimatePresence,
motion,
useMotionValue,
useTransform,
} from "framer-motion";
import { motion, useMotionValue, useTransform } from "framer-motion";
import { WrenchIcon, XIcon } from "lucide-react";
import { nanoid } from "nanoid";
import {
type Dispatch,
@ -245,24 +241,18 @@ 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 }}
@ -270,45 +260,53 @@ export const Tools = ({
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
<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}
/>
{[...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}
/>
))}
</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>>;
@ -317,6 +315,10 @@ 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>>();
@ -368,7 +370,12 @@ const PureToolbar = ({
throw new Error("Artifact definition not found!");
}
const toolsByArtifactKind = artifactDefinition.toolbar;
const toolsByArtifactKind = consoleError
? [
createFixErrorTool(consoleError, documentId),
...artifactDefinition.toolbar.slice(1),
]
: artifactDefinition.toolbar;
if (toolsByArtifactKind.length === 0) {
return null;
@ -377,26 +384,8 @@ const PureToolbar = ({
return (
<TooltipProvider delayDuration={0}>
<motion.div
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"
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"
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
initial={{ opacity: 0, y: -20, scale: 1 }}
onAnimationComplete={() => {
@ -423,6 +412,17 @@ 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,16 +445,17 @@ const PureToolbar = ({
setSelectedTool={setSelectedTool}
/>
) : (
<Tools
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
key="tools"
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
tools={toolsByArtifactKind}
/>
<>
{artifactActions}
<Tools
isAnimating={isAnimating}
key="tools"
selectedTool={selectedTool}
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
tools={toolsByArtifactKind}
/>
</>
)}
</motion.div>
</TooltipProvider>
@ -471,6 +472,15 @@ 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

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

@ -68,7 +68,12 @@ export function VisibilitySelector({
className
)}
>
<Button data-testid="visibility-selector" size="sm" variant="outline">
<Button
className="gap-1.5 rounded-lg border-border/50 text-muted-foreground shadow-none transition-colors hover:text-foreground focus-visible:ring-0 focus-visible:border-border/50 active:translate-y-0"
data-testid="visibility-selector"
size="sm"
variant="outline"
>
{selectedVisibility?.icon}
<span className="md:sr-only">{selectedVisibility?.label}</span>
<ChevronDownIcon />

View file

@ -1,39 +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/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

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

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

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

@ -36,7 +36,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"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",
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
@ -58,7 +58,7 @@ function AlertDialogContent({
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"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",
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-lg bg-background p-6 ring-1 ring-foreground/5 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
@ -99,6 +99,22 @@ 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
@ -107,7 +123,7 @@ function AlertDialogTitle({
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"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",
"text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
@ -122,21 +138,8 @@ 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(
"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",
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}

View file

@ -5,19 +5,20 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"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",
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border 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",
"border-border bg-input/30 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {

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

View file

@ -5,28 +5,29 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"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",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none active:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
default: "bg-primary text-primary-foreground hover:bg-primary/80",
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",
"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",
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 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",
default:
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},

View file

@ -2,7 +2,6 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
@ -12,6 +11,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon } from "lucide-react"
function Command({
className,
@ -21,7 +25,7 @@ function Command({
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
"flex size-full flex-col overflow-hidden rounded-4xl bg-popover p-1 text-popover-foreground",
className
)}
{...props}
@ -34,7 +38,7 @@ function CommandDialog({
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
@ -49,12 +53,13 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-4xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
<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>
{children}
</DialogContent>
</Dialog>
)
@ -65,19 +70,20 @@ function CommandInput({
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<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 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>
)
}
@ -90,7 +96,7 @@ function CommandList({
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
@ -99,12 +105,13 @@ function CommandList({
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
@ -118,7 +125,7 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"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",
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-3 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
@ -133,7 +140,7 @@ function CommandSeparator({
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
className={cn("my-1 h-px bg-border/50", className)}
{...props}
/>
)
@ -141,17 +148,20 @@ function CommandSeparator({
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"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",
"group/command-item relative flex cursor-default items-center gap-2 rounded-lg px-3 py-2 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-2xl data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
/>
>
{children}
</CommandPrimitive.Item>
)
}
@ -163,7 +173,7 @@ function CommandShortcut({
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
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(
"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",
"fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
@ -56,24 +56,28 @@ function DialogContent({
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"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",
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-background p-6 text-sm ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
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 data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
@ -85,7 +89,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
@ -125,7 +129,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
@ -138,7 +142,10 @@ function DialogDescription({
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)

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