feat: v1 — persistent shell, model gateway, artifact improvements (#1462)

This commit is contained in:
dancer 2026-03-20 09:37:02 +00:00 committed by GitHub
parent 3651670fb9
commit f9652b452a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
161 changed files with 5166 additions and 8009 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

@ -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,30 +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-neutral-50">
Sign In
</h3>
<p className="text-gray-500 text-sm dark:text-neutral-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-neutral-400">
{"Don't have an account? "}
<Link
className="font-semibold text-gray-800 hover:underline dark:text-neutral-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,30 +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-neutral-50">
Sign Up
</h3>
<p className="text-gray-500 text-sm dark:text-neutral-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-neutral-400">
{"Already have an account? "}
<Link
className="font-semibold text-gray-800 hover:underline dark:text-neutral-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,
@ -262,7 +317,7 @@ export async function POST(request: Request) {
);
}
} catch (_) {
// ignore redis errors
/* non-critical */
}
},
});
@ -295,7 +350,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,13 +1,10 @@
@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";
@ -51,100 +48,154 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--background: oklch(0.985 0 0);
--foreground: oklch(0.12 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.12 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(57.61% 0.2508 258.23);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 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.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--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.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 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.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(57.61% 0.2508 258.23);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--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.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--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 outline-ring/50;
@apply border-border ring-0;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "ss01", "ss02", "cv01";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--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;
}
@ -161,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;
@ -177,17 +236,6 @@
}
}
@layer base {
body {
overflow-x: hidden;
position: relative;
}
html {
overflow-x: hidden;
}
}
.skeleton {
* {
pointer-events: none !important;
@ -207,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-neutral-800! outline-hidden! selection:bg-neutral-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-neutral-200! dark:bg-neutral-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,12 +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 { TooltipProvider } from "@/components/ui/tooltip";
import { SessionProvider } from "next-auth/react";
export const metadata: Metadata = {
metadataBase: new URL("https://chat.vercel.ai"),
@ -15,7 +13,7 @@ export const metadata: Metadata = {
};
export const viewport = {
maximumScale: 1, // Disable auto-zoom on mobile Safari
maximumScale: 1,
};
const geist = Geist({
@ -58,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
>
@ -74,18 +68,18 @@ export default function RootLayout({
/>
</head>
<body className="antialiased">
<TooltipProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange
enableSystem
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange
enableSystem
>
<SessionProvider
basePath={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth`}
>
<Toaster position="top-center" />
{children}
</ThemeProvider>
</TooltipProvider>
<Analytics />
<TooltipProvider>{children}</TooltipProvider>
</SessionProvider>
</ThemeProvider>
</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

@ -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,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-neutral-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-neutral-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-neutral-200 bg-background md:border-l dark:border-neutral-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,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

@ -8,6 +8,7 @@ import {
} 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";
@ -15,8 +16,8 @@ import { unstable_serialize } from "swr/infinite";
import {
getChatHistoryPaginationKey,
SidebarHistory,
} from "@/components/sidebar-history";
import { SidebarUserNav } from "@/components/sidebar-user-nav";
} from "@/components/chat/sidebar-history";
import { SidebarUserNav } from "@/components/chat/sidebar-user-nav";
import {
Sidebar,
SidebarContent,
@ -31,7 +32,6 @@ import {
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import type { AuthUser } from "@/lib/auth";
import {
AlertDialog,
AlertDialogAction,
@ -41,98 +41,91 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
} from "../ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function AppSidebar({ user }: { user: AuthUser | undefined }) {
export function AppSidebar({ user }: { user: User | undefined }) {
const router = useRouter();
const { setOpenMobile, toggleSidebar } = 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",
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
className="border-r-0 [&_[data-sidebar=menu-button]]:bg-transparent [&_[data-sidebar=menu-button]]:hover:bg-transparent [&_[data-sidebar=menu-button]]:active:bg-transparent [&_[data-sidebar=menu-button][data-active]]:bg-transparent"
collapsible="icon"
>
<SidebarHeader>
<Sidebar collapsible="icon">
<SidebarHeader className="pb-0 pt-3">
<SidebarMenu>
<SidebarMenuItem className="flex flex-row items-center justify-between">
<div className="group/logo relative">
<div className="group/logo relative flex items-center justify-center">
<SidebarMenuButton
asChild
className="size-8 group-data-[collapsible=icon]:group-hover/logo:opacity-0"
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 />
<MessageSquareIcon className="size-4 text-sidebar-foreground/50" />
</Link>
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
className="absolute inset-0 size-8 opacity-0 group-data-[collapsible=icon]:group-hover/logo:opacity-100"
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 />
<PanelLeftIcon className="size-4" />
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">Open sidebar</TooltipContent>
<TooltipContent className="hidden md:block" side="right">
Open sidebar
</TooltipContent>
</Tooltip>
</div>
<div className="group-data-[collapsible=icon]:hidden">
<SidebarTrigger />
<SidebarTrigger className="text-sidebar-foreground/60 transition-colors duration-150 hover:text-sidebar-foreground" />
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<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("/");
router.refresh();
}}
tooltip="New Chat"
>
<PenSquareIcon />
<span>New chat</span>
<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 />
<span>Delete all chats</span>
<TrashIcon className="size-4" />
<span className="text-[13px]">Delete all</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
@ -141,7 +134,9 @@ export function AppSidebar({ user }: { user: AuthUser | undefined }) {
</SidebarGroup>
<SidebarHistory user={user} />
</SidebarContent>
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
<SidebarFooter className="border-t border-sidebar-border pt-2 pb-3">
{user && <SidebarUserNav user={user} />}
</SidebarFooter>
<SidebarRail />
</Sidebar>

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-neutral-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 || !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-neutral-600 dark:text-neutral-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-neutral-600 dark:text-neutral-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

@ -1,8 +1,10 @@
"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";
@ -15,8 +17,32 @@ function PureChatHeader({
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
}) {
const { state, toggleSidebar, isMobile } = useSidebar();
if (state === "collapsed" && !isMobile) {
return null;
}
return (
<header className="sticky top-0 flex items-center gap-2 bg-background p-3">
<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}
@ -26,12 +52,12 @@ function PureChatHeader({
<Button
asChild
className="hidden bg-neutral-900 px-4 text-neutral-50 hover:bg-neutral-800 md:ml-auto md:flex dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
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="noreferrer"
target="_noblank"
href="https://vercel.com/templates/next.js/chatbot"
rel="noopener noreferrer"
target="_blank"
>
<VercelIcon size={16} />
Deploy with Vercel

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-neutral-200 border-t bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-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-neutral-200 border-b bg-muted px-2 py-1 dark:border-neutral-700">
<div className="flex flex-row items-center gap-3 pl-2 text-sm text-neutral-800 dark:text-neutral-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-neutral-200 dark:hover:bg-neutral-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-neutral-200 border-b bg-neutral-50 px-4 py-2 font-mono text-sm dark:border-neutral-700 dark:bg-neutral-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-neutral-900 dark:text-neutral-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-neutral-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-neutral-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-neutral-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-neutral-100 dark:hover:bg-neutral-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-neutral-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-neutral-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;
};

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

@ -73,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,
};
@ -92,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";
@ -23,7 +24,6 @@ import {
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import type { AuthUser } from "@/lib/auth";
import type { Chat } from "@/lib/db/schema";
import { fetcher } from "@/lib/utils";
import { LoaderIcon } from "./icons";
@ -98,7 +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;
@ -112,7 +112,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
} = useSWRInfinite<ChatHistory>(
user ? getChatHistoryPaginationKey : () => null,
fetcher,
{ fallbackData: [] }
{ fallbackData: [], revalidateOnFocus: false }
);
const router = useRouter();
@ -133,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 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-neutral-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>
@ -180,16 +169,18 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
if (isLoading) {
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Your chats</SidebarGroupLabel>
<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 +198,11 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
if (hasEmptyChatHistory) {
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Your chats</SidebarGroupLabel>
<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-neutral-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>
@ -220,7 +213,9 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
return (
<>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Your chats</SidebarGroupLabel>
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
History
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{paginatedChatHistories &&
@ -232,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) => (
@ -255,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) => (
@ -275,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) => (
@ -295,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) => (
@ -315,7 +310,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
{groupedChats.older.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">
Older
</div>
{groupedChats.older.map((chat) => (
@ -346,11 +341,11 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
/>
{hasReachedEnd ? null : (
<div className="mt-4 flex flex-row items-center gap-2 p-2 text-neutral-500 dark:text-neutral-400">
<div className="mt-1 flex flex-row items-center gap-2 px-4 py-2 text-sidebar-foreground/50">
<div className="animate-spin">
<LoaderIcon />
</div>
<div className="text-xs">Loading...</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

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

View file

@ -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-neutral-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-neutral-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-neutral-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,136 +0,0 @@
"use client";
import { LogIn, LogOut, Moon, Sun } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { signOut, useSession } from "@/lib/client";
import { LoaderIcon } from "./icons";
import { toast } from "./toast";
export function SidebarUserNav({
user,
}: {
user: { email?: string | null; isAnonymous?: boolean | null };
}) {
const router = useRouter();
const { isMobile } = useSidebar();
const { data, isPending } = useSession();
const { setTheme, resolvedTheme } = useTheme();
const isGuest = data?.user?.isAnonymous ?? user.isAnonymous ?? false;
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{isPending ? (
<SidebarMenuButton
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
size="lg"
>
<div className="size-8 animate-pulse rounded-full bg-neutral-500/30" />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="animate-pulse rounded-md bg-neutral-500/30 text-transparent">
Loading
</span>
</div>
<div className="ml-auto animate-spin text-neutral-500">
<LoaderIcon />
</div>
</SidebarMenuButton>
) : (
<SidebarMenuButton
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-testid="user-nav-button"
size="lg"
>
<Image
alt={user.email ?? "User Avatar"}
className="size-8 rounded-full"
height={32}
src={`https://avatar.vercel.sh/${user.email}`}
width={32}
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span
className="truncate font-medium"
data-testid="user-email"
>
{isGuest ? "Guest" : user?.email}
</span>
</div>
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
data-testid="user-nav-menu"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuItem
className="cursor-pointer"
data-testid="user-nav-item-theme"
onSelect={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
>
{resolvedTheme === "light" ? <Moon /> : <Sun />}
{`Toggle ${resolvedTheme === "light" ? "dark" : "light"} mode`}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button
className="w-full cursor-pointer"
onClick={() => {
if (isPending) {
toast({
type: "error",
description:
"Checking authentication status, please try again!",
});
return;
}
if (isGuest) {
router.push("/login");
} else {
signOut({
fetchOptions: {
onSuccess: () => {
router.push("/");
router.refresh();
},
},
});
}
}}
type="button"
>
{isGuest ? <LogIn /> : <LogOut />}
{isGuest ? "Login to your account" : "Sign out"}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

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

@ -58,7 +58,7 @@ function AlertDialogContent({
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl 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",
"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}

View file

@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"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: {

View file

@ -15,7 +15,7 @@ import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
import { SearchIcon } from "lucide-react"
function Command({
className,
@ -155,13 +155,12 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-lg px-3 py-2 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-2xl data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
"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}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}

View file

@ -43,7 +43,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl bg-popover p-1 text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
@ -73,7 +73,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-xl px-3 py-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
"group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-lg px-3 py-2 text-sm outline-hidden select-none transition-colors duration-150 focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:focus:bg-destructive/10 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -244,7 +244,7 @@ function DropdownMenuSubContent({
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-2xl bg-popover p-1 text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn("z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)

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

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

View file

@ -37,7 +37,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"fixed inset-0 z-50 bg-black/50 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-open:duration-300 data-closed:animate-out data-closed:fade-out-0 data-closed:duration-200",
className
)}
{...props}
@ -62,7 +62,7 @@ function SheetContent({
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
"fixed z-50 flex flex-col bg-background bg-clip-padding text-sm shadow-2xl data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-[85%] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-[85%] data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:duration-400 data-open:ease-[cubic-bezier(0.32,0.72,0,1)] data-[side=bottom]:data-open:slide-in-from-bottom-full data-[side=left]:data-open:slide-in-from-left-full data-[side=right]:data-open:slide-in-from-right-full data-[side=top]:data-open:slide-in-from-top-full data-closed:animate-out data-closed:fade-out-0 data-closed:duration-300 data-closed:ease-[cubic-bezier(0.32,0.72,0,1)] data-[side=bottom]:data-closed:slide-out-to-bottom-full data-[side=left]:data-closed:slide-out-to-left-full data-[side=right]:data-closed:slide-out-to-right-full data-[side=top]:data-closed:slide-out-to-top-full",
className
)}
{...props}

View file

@ -68,8 +68,6 @@ function SidebarProvider({
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
@ -81,18 +79,15 @@ function SidebarProvider({
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
@ -108,8 +103,6 @@ function SidebarProvider({
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
@ -137,7 +130,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
"group/sidebar-wrapper flex min-h-svh w-full bg-sidebar",
className
)}
{...props}
@ -186,19 +179,16 @@ function Sidebar({
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
className="inset-x-0 bottom-0 top-auto h-[70dvh] w-full rounded-t-2xl border-t border-border/30 bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
showCloseButton={false}
side="bottom"
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
<div className="mx-auto mt-2 h-1 w-10 rounded-full bg-sidebar-foreground/20" />
<div className="flex h-full w-full flex-col overflow-y-auto pt-2">{children}</div>
</SheetContent>
</Sheet>
)
@ -213,11 +203,10 @@ function Sidebar({
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
@ -229,11 +218,10 @@ function Sidebar({
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
className
)}
{...props}
@ -241,7 +229,7 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border-none group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
@ -277,27 +265,48 @@ function SidebarTrigger({
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar, state } = useSidebar()
const isCollapsed = state === "collapsed"
return (
<button
data-sidebar="rail"
<div
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
"group/rail absolute inset-y-0 z-20 hidden w-4 overflow-visible group-data-[side=left]:-right-4 sm:block",
className
)}
{...props}
/>
>
<button
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
className="absolute inset-y-0 left-0 w-4 cursor-w-resize [[data-side=left][data-state=collapsed]_&]:cursor-e-resize"
{...props}
/>
<button
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
className={cn(
"absolute left-0 h-3 w-3 cursor-e-resize",
isCollapsed ? "top-0" : "top-[calc(3.5rem-6px)] cursor-w-resize"
)}
/>
<button
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
className={cn(
"absolute left-3 h-[6px] w-[100vw] cursor-e-resize",
isCollapsed ? "top-0" : "top-[calc(3.5rem-6px)] cursor-w-resize"
)}
/>
<div className={cn(
"pointer-events-none absolute bottom-0 left-0 w-[100vw] rounded-tl-[12px] border-t border-l border-sidebar-border opacity-0 transition-opacity duration-150 group-hover/rail:opacity-100",
isCollapsed ? "top-0" : "top-14"
)} />
</div>
)
}
@ -306,7 +315,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
"relative flex w-full flex-1 flex-col bg-sidebar [transform:translate3d(0,0,0)]",
className
)}
{...props}
@ -404,7 +413,7 @@ function SidebarGroupLabel({
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"flex h-8 shrink-0 items-center rounded-md px-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
@ -469,16 +478,16 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg px-3 py-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden px-2.5 text-left text-[13px] text-sidebar-foreground/70 outline-hidden transition-colors duration-150 group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:text-sidebar-accent-foreground data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: "hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-9 text-sm",
default: "h-8 text-[13px]",
sm: "h-8 text-xs",
lg: "h-14 px-3 text-sm group-data-[collapsible=icon]:p-0!",
},
@ -556,7 +565,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground/40 outline-hidden transition-colors duration-150 group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-foreground/60 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
@ -590,7 +599,6 @@ function SidebarMenuSkeleton({
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})

View file

@ -1,107 +0,0 @@
"use client";
import { isAfter } from "date-fns";
import { motion } from "framer-motion";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { useWindowSize } from "usehooks-ts";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document } from "@/lib/db/schema";
import { getDocumentTimestampByIndex } from "@/lib/utils";
import { LoaderIcon } from "./icons";
import { Button } from "./ui/button";
type VersionFooterProps = {
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
documents: Document[] | undefined;
currentVersionIndex: number;
};
export const VersionFooter = ({
handleVersionChange,
documents,
currentVersionIndex,
}: VersionFooterProps) => {
const { artifact } = useArtifact();
const { width } = useWindowSize();
const isMobile = width < 768;
const { mutate } = useSWRConfig();
const [isMutating, setIsMutating] = useState(false);
if (!documents) {
return;
}
return (
<motion.div
animate={{ y: 0 }}
className="absolute bottom-0 z-50 flex w-full flex-col justify-between gap-4 border-t bg-background p-4 lg:flex-row"
exit={{ y: isMobile ? 200 : 77 }}
initial={{ y: isMobile ? 200 : 77 }}
transition={{ type: "spring", stiffness: 140, damping: 20 }}
>
<div>
<div>You are viewing a previous version</div>
<div className="text-muted-foreground text-sm">
Restore this version to make edits
</div>
</div>
<div className="flex flex-row gap-4">
<Button
disabled={isMutating}
onClick={async () => {
setIsMutating(true);
mutate(
`/api/document?id=${artifact.documentId}`,
await fetch(
`/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
)
)
)
),
]
: [],
}
);
}}
>
<div>Restore this version</div>
{isMutating && (
<div className="animate-spin">
<LoaderIcon />
</div>
)}
</Button>
<Button
onClick={() => {
handleVersionChange("latest");
}}
variant="outline"
>
Back to latest version
</Button>
</div>
</motion.div>
);
};

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