demo(vercel-ai-sdk): add Next.js app routes, auth, and assets

This commit is contained in:
Musa 2026-01-08 15:20:47 -08:00
parent e69964028e
commit b7af8ab536
34 changed files with 2702 additions and 0 deletions

View file

@ -0,0 +1,84 @@
"use server";
import { z } from "zod";
import { createUser, getUser } from "@/lib/db/queries";
import { signIn } from "./auth";
const authFormSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
export type LoginActionState = {
status: "idle" | "in_progress" | "success" | "failed" | "invalid_data";
};
export const login = async (
_: LoginActionState,
formData: FormData
): Promise<LoginActionState> => {
try {
const validatedData = authFormSchema.parse({
email: formData.get("email"),
password: formData.get("password"),
});
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
});
return { status: "success" };
} catch (error) {
if (error instanceof z.ZodError) {
return { status: "invalid_data" };
}
return { status: "failed" };
}
};
export type RegisterActionState = {
status:
| "idle"
| "in_progress"
| "success"
| "failed"
| "user_exists"
| "invalid_data";
};
export const register = async (
_: RegisterActionState,
formData: FormData
): Promise<RegisterActionState> => {
try {
const validatedData = authFormSchema.parse({
email: formData.get("email"),
password: formData.get("password"),
});
const [user] = await getUser(validatedData.email);
if (user) {
return { status: "user_exists" } as RegisterActionState;
}
await createUser(validatedData.email, validatedData.password);
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
});
return { status: "success" };
} catch (error) {
if (error instanceof z.ZodError) {
return { status: "invalid_data" };
}
return { status: "failed" };
}
};

View file

@ -0,0 +1,2 @@
// biome-ignore lint/performance/noBarrelFile: "Required"
export { GET, POST } from "@/app/(auth)/auth";

View file

@ -0,0 +1,21 @@
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 redirectUrl = searchParams.get("redirectUrl") || "/";
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
secureCookie: !isDevelopmentEnvironment,
});
if (token) {
return NextResponse.redirect(new URL("/", request.url));
}
return signIn("guest", { redirect: true, redirectTo: redirectUrl });
}

View file

@ -0,0 +1,13 @@
import type { NextAuthConfig } from "next-auth";
export const authConfig = {
pages: {
signIn: "/login",
newUser: "/",
},
providers: [
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js
// while this file is also used in non-Node.js environments
],
callbacks: {},
} satisfies NextAuthConfig;

View file

@ -0,0 +1,95 @@
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"];
}
// biome-ignore lint/nursery/useConsistentTypeDefinitions: "Required"
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: {},
async authorize({ email, password }: any) {
const users = await getUser(email);
if (users.length === 0) {
await compare(password, DUMMY_PASSWORD);
return null;
}
const [user] = users;
if (!user.password) {
await compare(password, DUMMY_PASSWORD);
return null;
}
const passwordsMatch = await compare(password, user.password);
if (!passwordsMatch) {
return null;
}
return { ...user, type: "regular" };
},
}),
Credentials({
id: "guest",
credentials: {},
async authorize() {
const [guestUser] = await createGuestUser();
return { ...guestUser, type: "guest" };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id as string;
token.type = user.type;
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id;
session.user.type = token.type;
}
return session;
},
},
});

View file

@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useActionState, useEffect, useState } from "react";
import { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { type LoginActionState, login } from "../actions";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [isSuccessful, setIsSuccessful] = useState(false);
const [state, formAction] = useActionState<LoginActionState, FormData>(
login,
{
status: "idle",
}
);
const { update: updateSession } = useSession();
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
useEffect(() => {
if (state.status === "failed") {
toast({
type: "error",
description: "Invalid credentials!",
});
} else if (state.status === "invalid_data") {
toast({
type: "error",
description: "Failed validating your submission!",
});
} else if (state.status === "success") {
setIsSuccessful(true);
updateSession();
router.refresh();
}
}, [state.status]);
const handleSubmit = (formData: FormData) => {
setEmail(formData.get("email") as string);
formAction(formData);
};
return (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
<h3 className="font-semibold text-xl dark:text-zinc-50">Sign In</h3>
<p className="text-gray-500 text-sm dark:text-zinc-400">
Use your email and password to sign in
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
{"Don't have an account? "}
<Link
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
href="/register"
>
Sign up
</Link>
{" for free."}
</p>
</AuthForm>
</div>
</div>
);
}

View file

@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useActionState, useEffect, useState } from "react";
import { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { type RegisterActionState, register } from "../actions";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [isSuccessful, setIsSuccessful] = useState(false);
const [state, formAction] = useActionState<RegisterActionState, FormData>(
register,
{
status: "idle",
}
);
const { update: updateSession } = useSession();
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
useEffect(() => {
if (state.status === "user_exists") {
toast({ type: "error", description: "Account already exists!" });
} else if (state.status === "failed") {
toast({ type: "error", description: "Failed to create account!" });
} else if (state.status === "invalid_data") {
toast({
type: "error",
description: "Failed validating your submission!",
});
} else if (state.status === "success") {
toast({ type: "success", description: "Account created successfully!" });
setIsSuccessful(true);
updateSession();
router.refresh();
}
}, [state.status]);
const handleSubmit = (formData: FormData) => {
setEmail(formData.get("email") as string);
formAction(formData);
};
return (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
<h3 className="font-semibold text-xl dark:text-zinc-50">Sign Up</h3>
<p className="text-gray-500 text-sm dark:text-zinc-400">
Create an account with your email and password
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign Up</SubmitButton>
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
{"Already have an account? "}
<Link
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
href="/login"
>
Sign in
</Link>
{" instead."}
</p>
</AuthForm>
</div>
</div>
);
}

View file

@ -0,0 +1,51 @@
"use server";
import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers";
import type { VisibilityType } from "@/components/visibility-selector";
import { titlePrompt } from "@/lib/ai/prompts";
import { getTitleModel } from "@/lib/ai/providers";
import {
deleteMessagesByChatIdAfterTimestamp,
getMessageById,
updateChatVisibilityById,
} from "@/lib/db/queries";
import { getTextFromMessage } from "@/lib/utils";
export async function saveChatModelAsCookie(model: string) {
const cookieStore = await cookies();
cookieStore.set("chat-model", model);
}
export async function generateTitleFromUserMessage({
message,
}: {
message: UIMessage;
}) {
const { text: title } = await generateText({
model: getTitleModel(),
system: titlePrompt,
prompt: getTextFromMessage(message),
});
return title;
}
export async function deleteTrailingMessages({ id }: { id: string }) {
const [message] = await getMessageById({ id });
await deleteMessagesByChatIdAfterTimestamp({
chatId: message.chatId,
timestamp: message.createdAt,
});
}
export async function updateChatVisibility({
chatId,
visibility,
}: {
chatId: string;
visibility: VisibilityType;
}) {
await updateChatVisibilityById({ chatId, visibility });
}

View file

@ -0,0 +1,113 @@
import { createUIMessageStream, JsonToSseTransformStream } from "ai";
import { differenceInSeconds } from "date-fns";
import { auth } from "@/app/(auth)/auth";
import {
getChatById,
getMessagesByChatId,
getStreamIdsByChatId,
} from "@/lib/db/queries";
import type { Chat } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { ChatMessage } from "@/lib/types";
import { getStreamContext } from "../../route";
export async function GET(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: chatId } = await params;
const streamContext = getStreamContext();
const resumeRequestedAt = new Date();
if (!streamContext) {
return new Response(null, { status: 204 });
}
if (!chatId) {
return new ChatSDKError("bad_request:api").toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:chat").toResponse();
}
let chat: Chat | null;
try {
chat = await getChatById({ id: chatId });
} catch {
return new ChatSDKError("not_found:chat").toResponse();
}
if (!chat) {
return new ChatSDKError("not_found:chat").toResponse();
}
if (chat.visibility === "private" && chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse();
}
const streamIds = await getStreamIdsByChatId({ chatId });
if (!streamIds.length) {
return new ChatSDKError("not_found:stream").toResponse();
}
const recentStreamId = streamIds.at(-1);
if (!recentStreamId) {
return new ChatSDKError("not_found:stream").toResponse();
}
const emptyDataStream = createUIMessageStream<ChatMessage>({
// biome-ignore lint/suspicious/noEmptyBlockStatements: "Needs to exist"
execute: () => {},
});
const stream = await streamContext.resumableStream(recentStreamId, () =>
emptyDataStream.pipeThrough(new JsonToSseTransformStream())
);
/*
* For when the generation is streaming during SSR
* but the resumable stream has concluded at this point.
*/
if (!stream) {
const messages = await getMessagesByChatId({ id: chatId });
const mostRecentMessage = messages.at(-1);
if (!mostRecentMessage) {
return new Response(emptyDataStream, { status: 200 });
}
if (mostRecentMessage.role !== "assistant") {
return new Response(emptyDataStream, { status: 200 });
}
const messageCreatedAt = new Date(mostRecentMessage.createdAt);
if (differenceInSeconds(resumeRequestedAt, messageCreatedAt) > 15) {
return new Response(emptyDataStream, { status: 200 });
}
const restoredStream = createUIMessageStream<ChatMessage>({
execute: ({ writer }) => {
writer.write({
type: "data-appendMessage",
data: JSON.stringify(mostRecentMessage),
transient: true,
});
},
});
return new Response(
restoredStream.pipeThrough(new JsonToSseTransformStream()),
{ status: 200 }
);
}
return new Response(stream, { status: 200 });
}

View file

@ -0,0 +1,317 @@
import { geolocation } from "@vercel/functions";
import {
convertToModelMessages,
createUIMessageStream,
JsonToSseTransformStream,
smoothStream,
stepCountIs,
streamText,
} from "ai";
import { after } from "next/server";
import {
createResumableStreamContext,
type ResumableStreamContext,
} from "resumable-stream";
import { auth, type UserType } from "@/app/(auth)/auth";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
import { getLanguageModel } from "@/lib/ai/providers";
import { getWeather } from "@/lib/ai/tools/get-weather";
import { getCurrencyExchange } from "@/lib/ai/tools/get-currency-exchange";
import { isProductionEnvironment } from "@/lib/constants";
import {
createStreamId,
deleteChatById,
getChatById,
getMessageCountByUserId,
getMessagesByChatId,
saveChat,
saveMessages,
updateChatTitleById,
updateMessage,
} from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema";
import { ChatSDKError } from "@/lib/errors";
import type { ChatMessage } from "@/lib/types";
import { convertToUIMessages, generateUUID } from "@/lib/utils";
import { generateTitleFromUserMessage } from "../../actions";
import { type PostRequestBody, postRequestBodySchema } from "./schema";
export const maxDuration = 60;
let globalStreamContext: ResumableStreamContext | null = null;
export function getStreamContext() {
if (!globalStreamContext) {
try {
globalStreamContext = createResumableStreamContext({
waitUntil: after,
});
} catch (error: any) {
if (error.message.includes("REDIS_URL")) {
console.log(
" > Resumable streams are disabled due to missing REDIS_URL"
);
} else {
console.error(error);
}
}
}
return globalStreamContext;
}
export async function POST(request: Request) {
let requestBody: PostRequestBody;
try {
const json = await request.json();
requestBody = postRequestBodySchema.parse(json);
} catch (_) {
return new ChatSDKError("bad_request:api").toResponse();
}
try {
const { id, message, messages, selectedChatModel, selectedVisibilityType } =
requestBody;
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:chat").toResponse();
}
const userType: UserType = session.user.type;
const messageCount = await getMessageCountByUserId({
id: session.user.id,
differenceInHours: 24,
});
if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) {
return new ChatSDKError("rate_limit:chat").toResponse();
}
// Check if this is a tool approval flow (all messages sent)
const isToolApprovalFlow = Boolean(messages);
const chat = await getChatById({ id });
let messagesFromDb: DBMessage[] = [];
let titlePromise: Promise<string> | null = null;
if (chat) {
if (chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse();
}
// Only fetch messages if chat already exists and not tool approval
if (!isToolApprovalFlow) {
messagesFromDb = await getMessagesByChatId({ id });
}
} else if (message?.role === "user") {
// Save chat immediately with placeholder title
await saveChat({
id,
userId: session.user.id,
title: "New chat",
visibility: selectedVisibilityType,
});
// Start title generation in parallel (don't await)
titlePromise = generateTitleFromUserMessage({ message });
}
// Use all messages for tool approval, otherwise DB messages + new message
const uiMessages = isToolApprovalFlow
? (messages as ChatMessage[])
: [...convertToUIMessages(messagesFromDb), message as ChatMessage];
const { longitude, latitude, city, country } = geolocation(request);
const requestHints: RequestHints = {
longitude,
latitude,
city,
country,
};
// Only save user messages to the database (not tool approval responses)
if (message?.role === "user") {
await saveMessages({
messages: [
{
chatId: id,
id: message.id,
role: "user",
parts: message.parts,
attachments: [],
createdAt: new Date(),
},
],
});
}
const streamId = generateUUID();
await createStreamId({ streamId, chatId: id });
const stream = createUIMessageStream({
// Pass original messages for tool approval continuation
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
execute: async ({ writer: dataStream }) => {
// Handle title generation in parallel
if (titlePromise) {
titlePromise.then((title) => {
updateChatTitleById({ chatId: id, title });
dataStream.write({ type: "data-chat-title", data: title });
});
}
const isReasoningModel =
selectedChatModel.includes("reasoning") ||
selectedChatModel.includes("thinking");
const result = streamText({
model: getLanguageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel, requestHints }),
messages: await convertToModelMessages(uiMessages),
stopWhen: stepCountIs(5),
experimental_activeTools: isReasoningModel
? []
: ["getWeather", "getCurrencyExchange"],
experimental_transform: isReasoningModel
? undefined
: smoothStream({ chunking: "word" }),
providerOptions: isReasoningModel
? {
anthropic: {
thinking: { type: "enabled", budgetTokens: 10_000 },
},
}
: undefined,
tools: {
getWeather,
getCurrencyExchange,
},
experimental_telemetry: {
isEnabled: isProductionEnvironment,
functionId: "stream-text",
},
});
result.consumeStream();
dataStream.merge(
result.toUIMessageStream({
sendReasoning: true,
})
);
},
generateId: generateUUID,
onFinish: async ({ messages: finishedMessages }) => {
if (isToolApprovalFlow) {
// For tool approval, update existing messages (tool state changed) and save new ones
for (const finishedMsg of finishedMessages) {
const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id);
if (existingMsg) {
// Update existing message with new parts (tool state changed)
await updateMessage({
id: finishedMsg.id,
parts: finishedMsg.parts,
});
} else {
// Save new message
await saveMessages({
messages: [
{
id: finishedMsg.id,
role: finishedMsg.role,
parts: finishedMsg.parts,
createdAt: new Date(),
attachments: [],
chatId: id,
},
],
});
}
}
} else if (finishedMessages.length > 0) {
// Normal flow - save all finished messages
await saveMessages({
messages: finishedMessages.map((currentMessage) => ({
id: currentMessage.id,
role: currentMessage.role,
parts: currentMessage.parts,
createdAt: new Date(),
attachments: [],
chatId: id,
})),
});
}
},
onError: () => {
return "Oops, an error occurred!";
},
});
const streamContext = getStreamContext();
if (streamContext) {
try {
const resumableStream = await streamContext.resumableStream(
streamId,
() => stream.pipeThrough(new JsonToSseTransformStream())
);
if (resumableStream) {
return new Response(resumableStream);
}
} catch (error) {
console.error("Failed to create resumable stream:", error);
}
}
return new Response(stream.pipeThrough(new JsonToSseTransformStream()));
} catch (error) {
const vercelId = request.headers.get("x-vercel-id");
if (error instanceof ChatSDKError) {
return error.toResponse();
}
// Check for Vercel AI Gateway credit card error
if (
error instanceof Error &&
error.message?.includes(
"AI Gateway requires a valid credit card on file to service requests"
)
) {
return new ChatSDKError("bad_request:activate_gateway").toResponse();
}
console.error("Unhandled error in chat API:", error, { vercelId });
return new ChatSDKError("offline:chat").toResponse();
}
}
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return new ChatSDKError("bad_request:api").toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:chat").toResponse();
}
const chat = await getChatById({ id });
if (chat?.userId !== session.user.id) {
return new ChatSDKError("forbidden:chat").toResponse();
}
const deletedChat = await deleteChatById({ id });
return Response.json(deletedChat, { status: 200 });
}

View file

@ -0,0 +1,39 @@
import { z } from "zod";
const textPartSchema = z.object({
type: z.enum(["text"]),
text: z.string().min(1).max(2000),
});
const filePartSchema = z.object({
type: z.enum(["file"]),
mediaType: z.enum(["image/jpeg", "image/png"]),
name: z.string().min(1).max(100),
url: z.string().url(),
});
const partSchema = z.union([textPartSchema, filePartSchema]);
const userMessageSchema = z.object({
id: z.string().uuid(),
role: z.enum(["user"]),
parts: z.array(partSchema),
});
// For tool approval flows, we accept all messages (more permissive schema)
const messageSchema = z.object({
id: z.string(),
role: z.string(),
parts: z.array(z.any()),
});
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
// Either a single new message or all messages (for tool approvals)
message: userMessageSchema.optional(),
messages: z.array(messageSchema).optional(),
selectedChatModel: z.string(),
selectedVisibilityType: z.enum(["public", "private"]),
});
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;

View file

@ -0,0 +1,126 @@
import { auth } from "@/app/(auth)/auth";
import type { ArtifactKind } from "@/components/artifact";
import {
deleteDocumentsByIdAfterTimestamp,
getDocumentsById,
saveDocument,
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return new ChatSDKError(
"bad_request:api",
"Parameter id is missing"
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:document").toResponse();
}
const documents = await getDocumentsById({ id });
const [document] = documents;
if (!document) {
return new ChatSDKError("not_found:document").toResponse();
}
if (document.userId !== session.user.id) {
return new ChatSDKError("forbidden:document").toResponse();
}
return Response.json(documents, { status: 200 });
}
export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return new ChatSDKError(
"bad_request:api",
"Parameter id is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("not_found:document").toResponse();
}
const {
content,
title,
kind,
}: { content: string; title: string; kind: ArtifactKind } =
await request.json();
const documents = await getDocumentsById({ id });
if (documents.length > 0) {
const [doc] = documents;
if (doc.userId !== session.user.id) {
return new ChatSDKError("forbidden:document").toResponse();
}
}
const document = await saveDocument({
id,
content,
title,
kind,
userId: session.user.id,
});
return Response.json(document, { status: 200 });
}
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const timestamp = searchParams.get("timestamp");
if (!id) {
return new ChatSDKError(
"bad_request:api",
"Parameter id is required."
).toResponse();
}
if (!timestamp) {
return new ChatSDKError(
"bad_request:api",
"Parameter timestamp is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:document").toResponse();
}
const documents = await getDocumentsById({ id });
const [document] = documents;
if (document.userId !== session.user.id) {
return new ChatSDKError("forbidden:document").toResponse();
}
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
id,
timestamp: new Date(timestamp),
});
return Response.json(documentsDeleted, { status: 200 });
}

View file

@ -0,0 +1,68 @@
import { put } from "@vercel/blob";
import { NextResponse } from "next/server";
import { z } from "zod";
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 auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (request.body === null) {
return new Response("Request body is empty", { status: 400 });
}
try {
const formData = await request.formData();
const file = formData.get("file") as Blob;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
const validatedFile = FileSchema.safeParse({ file });
if (!validatedFile.success) {
const errorMessage = validatedFile.error.errors
.map((error) => error.message)
.join(", ");
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
// Get filename from formData since Blob doesn't have name property
const filename = (formData.get("file") as File).name;
const fileBuffer = await file.arrayBuffer();
try {
const data = await put(`${filename}`, fileBuffer, {
access: "public",
});
return NextResponse.json(data);
} catch (_error) {
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
} catch (_error) {
return NextResponse.json(
{ error: "Failed to process request" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,46 @@
import type { NextRequest } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const limit = Number.parseInt(searchParams.get("limit") || "10", 10);
const startingAfter = searchParams.get("starting_after");
const endingBefore = searchParams.get("ending_before");
if (startingAfter && endingBefore) {
return new ChatSDKError(
"bad_request:api",
"Only one of starting_after or ending_before can be provided."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:chat").toResponse();
}
const chats = await getChatsByUserId({
id: session.user.id,
limit,
startingAfter,
endingBefore,
});
return Response.json(chats);
}
export async function DELETE() {
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:chat").toResponse();
}
const result = await deleteAllChatsByUserId({ userId: session.user.id });
return Response.json(result, { status: 200 });
}

View file

@ -0,0 +1,37 @@
import { auth } from "@/app/(auth)/auth";
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const documentId = searchParams.get("documentId");
if (!documentId) {
return new ChatSDKError(
"bad_request:api",
"Parameter documentId is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:suggestions").toResponse();
}
const suggestions = await getSuggestionsByDocumentId({
documentId,
});
const [suggestion] = suggestions;
if (!suggestion) {
return Response.json([], { status: 200 });
}
if (suggestion.userId !== session.user.id) {
return new ChatSDKError("forbidden:api").toResponse();
}
return Response.json(suggestions, { status: 200 });
}

View file

@ -0,0 +1,75 @@
import { auth } from "@/app/(auth)/auth";
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get("chatId");
if (!chatId) {
return new ChatSDKError(
"bad_request:api",
"Parameter chatId is required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:vote").toResponse();
}
const chat = await getChatById({ id: chatId });
if (!chat) {
return new ChatSDKError("not_found:chat").toResponse();
}
if (chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:vote").toResponse();
}
const votes = await getVotesByChatId({ id: chatId });
return Response.json(votes, { status: 200 });
}
export async function PATCH(request: Request) {
const {
chatId,
messageId,
type,
}: { chatId: string; messageId: string; type: "up" | "down" } =
await request.json();
if (!chatId || !messageId || !type) {
return new ChatSDKError(
"bad_request:api",
"Parameters chatId, messageId, and type are required."
).toResponse();
}
const session = await auth();
if (!session?.user) {
return new ChatSDKError("unauthorized:vote").toResponse();
}
const chat = await getChatById({ id: chatId });
if (!chat) {
return new ChatSDKError("not_found:vote").toResponse();
}
if (chat.userId !== session.user.id) {
return new ChatSDKError("forbidden:vote").toResponse();
}
await voteMessage({
chatId,
messageId,
type,
});
return new Response("Message voted", { status: 200 });
}

View file

@ -0,0 +1,82 @@
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import { auth } from "@/app/(auth)/auth";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
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 auth();
if (!session) {
redirect("/api/auth/guest");
}
if (chat.visibility === "private") {
if (!session.user) {
return notFound();
}
if (session.user.id !== chat.userId) {
return notFound();
}
}
const messagesFromDb = await getMessagesByChatId({
id,
});
const uiMessages = convertToUIMessages(messagesFromDb);
const cookieStore = await cookies();
const chatModelFromCookie = cookieStore.get("chat-model");
if (!chatModelFromCookie) {
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialChatModel={DEFAULT_CHAT_MODEL}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
/>
<DataStreamHandler />
</>
);
}
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialChatModel={chatModelFromCookie.value}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
/>
<DataStreamHandler />
</>
);
}

View file

@ -0,0 +1,35 @@
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 { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
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"
/>
<DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh" />}>
<SidebarWrapper>{children}</SidebarWrapper>
</Suspense>
</DataStreamProvider>
</>
);
}
async function SidebarWrapper({ 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>
</SidebarProvider>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View file

@ -0,0 +1,52 @@
import { cookies } from "next/headers";
import { Suspense } from "react";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
import { generateUUID } from "@/lib/utils";
export default function Page() {
return (
<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 />
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,318 @@
@import "tailwindcss";
@import "katex/dist/katex.min.css";
/* include utility classes in streamdown */
@source '../node_modules/streamdown/dist/index.js';
/* custom variant for setting dark mode programmatically */
@custom-variant dark (&:is(.dark, .dark *));
/* include plugins */
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
/* define design tokens (light mode) */
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(240 10% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
/* border radius unit */
--radius: 0.5rem;
--sidebar: hsl(0 0% 98%);
}
/* define design tokens (dark mode) */
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--ring: hsl(240 4.9% 83.9%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
/* define theme */
@theme {
--font-sans: var(--font-geist);
--font-mono: var(--font-geist-mono);
--breakpoint-toast-mobile: 600px;
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background);
--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);
}
/*
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);
}
}
@utility text-balance {
text-wrap: balance;
}
@utility -webkit-overflow-scrolling-touch {
-webkit-overflow-scrolling: touch;
}
@utility touch-pan-y {
touch-action: pan-y;
}
@utility overscroll-behavior-contain {
overscroll-behavior: contain;
}
@layer utilities {
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
overflow-x: hidden;
position: relative;
}
html {
overflow-x: hidden;
}
}
.skeleton {
* {
pointer-events: none !important;
}
*[class^="text-"] {
color: transparent;
@apply rounded-md bg-foreground/20 select-none animate-pulse;
}
.skeleton-bg {
@apply bg-foreground/10;
}
.skeleton-div {
@apply bg-foreground/20 animate-pulse;
}
}
.ProseMirror {
outline: none;
}
.cm-editor,
.cm-gutters {
@apply bg-background! dark:bg-zinc-800! outline-hidden! selection:bg-zinc-900!;
}
.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
.ͼo.cm-selectionBackground,
.ͼo.cm-content::selection {
@apply bg-zinc-200! dark:bg-zinc-900!;
}
.cm-activeLine,
.cm-activeLineGutter {
@apply bg-transparent!;
}
.cm-activeLine {
@apply rounded-r-sm!;
}
.cm-lineNumbers {
@apply min-w-7;
}
.cm-foldGutter {
@apply min-w-3;
}
.cm-lineNumbers .cm-activeLineGutter {
@apply rounded-l-sm!;
}
.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);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -0,0 +1,87 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
import { SessionProvider } from "next-auth/react";
export const metadata: Metadata = {
metadataBase: new URL("https://chat.vercel.ai"),
title: "Next.js Chatbot Template",
description: "Next.js chatbot template using the AI SDK.",
};
export const viewport = {
maximumScale: 1, // Disable auto-zoom on mobile Safari
};
const geist = Geist({
subsets: ["latin"],
display: "swap",
variable: "--font-geist",
});
const geistMono = Geist_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-geist-mono",
});
const LIGHT_THEME_COLOR = "hsl(0 0% 100%)";
const DARK_THEME_COLOR = "hsl(240deg 10% 3.92%)";
const THEME_COLOR_SCRIPT = `\
(function() {
var html = document.documentElement;
var meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('name', 'theme-color');
document.head.appendChild(meta);
}
function updateThemeColor() {
var isDark = html.classList.contains('dark');
meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}');
}
var observer = new MutationObserver(updateThemeColor);
observer.observe(html, { attributes: true, attributeFilter: ['class'] });
updateThemeColor();
})();`;
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
className={`${geist.variable} ${geistMono.variable}`}
// `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
>
<head>
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required"
dangerouslySetInnerHTML={{
__html: THEME_COLOR_SCRIPT,
}}
/>
</head>
<body className="antialiased">
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange
enableSystem
>
<Toaster position="top-center" />
<SessionProvider>{children}</SessionProvider>
</ThemeProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,8 @@
"use server";
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
export async function getSuggestions({ documentId }: { documentId: string }) {
const suggestions = await getSuggestionsByDocumentId({ documentId });
return suggestions ?? [];
}

View file

@ -0,0 +1,280 @@
import { toast } from "sonner";
import { CodeEditor } from "@/components/code-editor";
import {
Console,
type ConsoleOutput,
type ConsoleOutputContent,
} from "@/components/console";
import { Artifact } from "@/components/create-artifact";
import {
CopyIcon,
LogsIcon,
MessageIcon,
PlayIcon,
RedoIcon,
UndoIcon,
} from "@/components/icons";
import { generateUUID } from "@/lib/utils";
const OUTPUT_HANDLERS = {
matplotlib: `
import io
import base64
from matplotlib import pyplot as plt
# Clear any existing plots
plt.clf()
plt.close('all')
# Switch to agg backend
plt.switch_backend('agg')
def setup_matplotlib_output():
def custom_show():
if plt.gcf().get_size_inches().prod() * plt.gcf().dpi ** 2 > 25_000_000:
print("Warning: Plot size too large, reducing quality")
plt.gcf().set_dpi(100)
png_buf = io.BytesIO()
plt.savefig(png_buf, format='png')
png_buf.seek(0)
png_base64 = base64.b64encode(png_buf.read()).decode('utf-8')
print(f'data:image/png;base64,{png_base64}')
png_buf.close()
plt.clf()
plt.close('all')
plt.show = custom_show
`,
basic: `
# Basic output capture setup
`,
};
function detectRequiredHandlers(code: string): string[] {
const handlers: string[] = ["basic"];
if (code.includes("matplotlib") || code.includes("plt.")) {
handlers.push("matplotlib");
}
return handlers;
}
type Metadata = {
outputs: ConsoleOutput[];
};
export const codeArtifact = new Artifact<"code", Metadata>({
kind: "code",
description:
"Useful for code generation; Code execution is only available for python code.",
initialize: ({ setMetadata }) => {
setMetadata({
outputs: [],
});
},
onStreamPart: ({ streamPart, setArtifact }) => {
if (streamPart.type === "data-codeDelta") {
setArtifact((draftArtifact) => ({
...draftArtifact,
content: streamPart.data,
isVisible:
draftArtifact.status === "streaming" &&
draftArtifact.content.length > 300 &&
draftArtifact.content.length < 310
? true
: draftArtifact.isVisible,
status: "streaming",
}));
}
},
content: ({ metadata, setMetadata, ...props }) => {
return (
<>
<div className="px-1">
<CodeEditor {...props} />
</div>
{metadata?.outputs && (
<Console
consoleOutputs={metadata.outputs}
setConsoleOutputs={() => {
setMetadata({
...metadata,
outputs: [],
});
}}
/>
)}
</>
);
},
actions: [
{
icon: <PlayIcon size={18} />,
label: "Run",
description: "Execute code",
onClick: async ({ content, setMetadata }) => {
const runId = generateUUID();
const outputContent: ConsoleOutputContent[] = [];
setMetadata((metadata) => ({
...metadata,
outputs: [
...metadata.outputs,
{
id: runId,
contents: [],
status: "in_progress",
},
],
}));
try {
// @ts-expect-error - loadPyodide is not defined
const currentPyodideInstance = await globalThis.loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.23.4/full/",
});
currentPyodideInstance.setStdout({
batched: (output: string) => {
outputContent.push({
type: output.startsWith("data:image/png;base64")
? "image"
: "text",
value: output,
});
},
});
await currentPyodideInstance.loadPackagesFromImports(content, {
messageCallback: (message: string) => {
setMetadata((metadata) => ({
...metadata,
outputs: [
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [{ type: "text", value: message }],
status: "loading_packages",
},
],
}));
},
});
const requiredHandlers = detectRequiredHandlers(content);
for (const handler of requiredHandlers) {
if (OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS]) {
await currentPyodideInstance.runPythonAsync(
OUTPUT_HANDLERS[handler as keyof typeof OUTPUT_HANDLERS]
);
if (handler === "matplotlib") {
await currentPyodideInstance.runPythonAsync(
"setup_matplotlib_output()"
);
}
}
}
await currentPyodideInstance.runPythonAsync(content);
setMetadata((metadata) => ({
...metadata,
outputs: [
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: outputContent,
status: "completed",
},
],
}));
} catch (error: any) {
setMetadata((metadata) => ({
...metadata,
outputs: [
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [{ type: "text", value: error.message }],
status: "failed",
},
],
}));
}
},
},
{
icon: <UndoIcon size={18} />,
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <RedoIcon size={18} />,
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
return true;
}
return false;
},
},
{
icon: <CopyIcon size={18} />,
description: "Copy code to clipboard",
onClick: ({ content }) => {
navigator.clipboard.writeText(content);
toast.success("Copied to clipboard!");
},
},
],
toolbar: [
{
icon: <MessageIcon />,
description: "Add comments",
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Add comments to the code snippet for understanding",
},
],
});
},
},
{
icon: <LogsIcon />,
description: "Add logs",
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Add logs to the code snippet for debugging",
},
],
});
},
},
],
});

View file

@ -0,0 +1,75 @@
import { streamObject } from "ai";
import { z } from "zod";
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
import { getArtifactModel } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const codeDocumentHandler = createDocumentHandler<"code">({
kind: "code",
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
system: codePrompt,
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;
}
}
}
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
system: updateDocumentPrompt(document.content, "code"),
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;
}
}
}
return draftContent;
},
});

View file

@ -0,0 +1,76 @@
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/icons";
import { ImageEditor } from "@/components/image-editor";
export const imageArtifact = new Artifact({
kind: "image",
description: "Useful for image generation",
onStreamPart: ({ streamPart, setArtifact }) => {
if (streamPart.type === "data-imageDelta") {
setArtifact((draftArtifact) => ({
...draftArtifact,
content: streamPart.data,
isVisible: true,
status: "streaming",
}));
}
},
content: ImageEditor,
actions: [
{
icon: <UndoIcon size={18} />,
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <RedoIcon size={18} />,
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
return true;
}
return false;
},
},
{
icon: <CopyIcon size={18} />,
description: "Copy image to clipboard",
onClick: ({ content }) => {
const img = new Image();
img.src = `data:image/png;base64,${content}`;
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx?.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
navigator.clipboard.write([
new ClipboardItem({ "image/png": blob }),
]);
}
}, "image/png");
};
toast.success("Copied image to clipboard!");
},
},
],
toolbar: [],
});

View file

@ -0,0 +1,115 @@
import { parse, unparse } from "papaparse";
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import {
CopyIcon,
LineChartIcon,
RedoIcon,
SparklesIcon,
UndoIcon,
} from "@/components/icons";
import { SpreadsheetEditor } from "@/components/sheet-editor";
type Metadata = any;
export const sheetArtifact = new Artifact<"sheet", Metadata>({
kind: "sheet",
description: "Useful for working with spreadsheets",
initialize: () => null,
onStreamPart: ({ setArtifact, streamPart }) => {
if (streamPart.type === "data-sheetDelta") {
setArtifact((draftArtifact) => ({
...draftArtifact,
content: streamPart.data,
isVisible: true,
status: "streaming",
}));
}
},
content: ({ content, currentVersionIndex, onSaveContent, status }) => {
return (
<SpreadsheetEditor
content={content}
currentVersionIndex={currentVersionIndex}
isCurrentVersion={true}
saveContent={onSaveContent}
status={status}
/>
);
},
actions: [
{
icon: <UndoIcon size={18} />,
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <RedoIcon size={18} />,
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
return true;
}
return false;
},
},
{
icon: <CopyIcon />,
description: "Copy as .csv",
onClick: ({ content }) => {
const parsed = parse<string[]>(content, { skipEmptyLines: true });
const nonEmptyRows = parsed.data.filter((row) =>
row.some((cell) => cell.trim() !== "")
);
const cleanedCsv = unparse(nonEmptyRows);
navigator.clipboard.writeText(cleanedCsv);
toast.success("Copied csv to clipboard!");
},
},
],
toolbar: [
{
description: "Format and clean data",
icon: <SparklesIcon />,
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{ type: "text", text: "Can you please format and clean the data?" },
],
});
},
},
{
description: "Analyze and visualize data",
icon: <LineChartIcon />,
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Can you please analyze and visualize the data by creating a new code artifact in python?",
},
],
});
},
},
],
});

View file

@ -0,0 +1,81 @@
import { streamObject } from "ai";
import { z } from "zod";
import { sheetPrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
import { getArtifactModel } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const sheetDocumentHandler = createDocumentHandler<"sheet">({
kind: "sheet",
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
system: sheetPrompt,
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;
}
}
}
dataStream.write({
type: "data-sheetDelta",
data: draftContent,
transient: true,
});
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = "";
const { fullStream } = streamObject({
model: getArtifactModel(),
system: updateDocumentPrompt(document.content, "sheet"),
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;
}
}
}
return draftContent;
},
});

View file

@ -0,0 +1,179 @@
import { toast } from "sonner";
import { Artifact } from "@/components/create-artifact";
import { DiffView } from "@/components/diffview";
import { DocumentSkeleton } from "@/components/document-skeleton";
import {
ClockRewind,
CopyIcon,
MessageIcon,
PenIcon,
RedoIcon,
UndoIcon,
} from "@/components/icons";
import { Editor } from "@/components/text-editor";
import type { Suggestion } from "@/lib/db/schema";
import { getSuggestions } from "../actions";
type TextArtifactMetadata = {
suggestions: Suggestion[];
};
export const textArtifact = new Artifact<"text", TextArtifactMetadata>({
kind: "text",
description: "Useful for text content, like drafting essays and emails.",
initialize: async ({ documentId, setMetadata }) => {
const suggestions = await getSuggestions({ documentId });
setMetadata({
suggestions,
});
},
onStreamPart: ({ streamPart, setMetadata, setArtifact }) => {
if (streamPart.type === "data-suggestion") {
setMetadata((metadata) => {
return {
suggestions: [...metadata.suggestions, streamPart.data],
};
});
}
if (streamPart.type === "data-textDelta") {
setArtifact((draftArtifact) => {
return {
...draftArtifact,
content: draftArtifact.content + streamPart.data,
isVisible:
draftArtifact.status === "streaming" &&
draftArtifact.content.length > 400 &&
draftArtifact.content.length < 450
? true
: draftArtifact.isVisible,
status: "streaming",
};
});
}
},
content: ({
mode,
status,
content,
isCurrentVersion,
currentVersionIndex,
onSaveContent,
getDocumentContentById,
isLoading,
metadata,
}) => {
if (isLoading) {
return <DocumentSkeleton artifactKind="text" />;
}
if (mode === "diff") {
const oldContent = getDocumentContentById(currentVersionIndex - 1);
const newContent = getDocumentContentById(currentVersionIndex);
return <DiffView newContent={newContent} oldContent={oldContent} />;
}
return (
<div className="flex flex-row px-4 py-8 md:p-20">
<Editor
content={content}
currentVersionIndex={currentVersionIndex}
isCurrentVersion={isCurrentVersion}
onSaveContent={onSaveContent}
status={status}
suggestions={metadata ? metadata.suggestions : []}
/>
{metadata?.suggestions && metadata.suggestions.length > 0 ? (
<div className="h-dvh w-12 shrink-0 md:hidden" />
) : null}
</div>
);
},
actions: [
{
icon: <ClockRewind size={18} />,
description: "View changes",
onClick: ({ handleVersionChange }) => {
handleVersionChange("toggle");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <UndoIcon size={18} />,
description: "View Previous version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("prev");
},
isDisabled: ({ currentVersionIndex }) => {
if (currentVersionIndex === 0) {
return true;
}
return false;
},
},
{
icon: <RedoIcon size={18} />,
description: "View Next version",
onClick: ({ handleVersionChange }) => {
handleVersionChange("next");
},
isDisabled: ({ isCurrentVersion }) => {
if (isCurrentVersion) {
return true;
}
return false;
},
},
{
icon: <CopyIcon size={18} />,
description: "Copy to clipboard",
onClick: ({ content }) => {
navigator.clipboard.writeText(content);
toast.success("Copied to clipboard!");
},
},
],
toolbar: [
{
icon: <PenIcon />,
description: "Add final polish",
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.",
},
],
});
},
},
{
icon: <MessageIcon />,
description: "Request suggestions",
onClick: ({ sendMessage }) => {
sendMessage({
role: "user",
parts: [
{
type: "text",
text: "Please add suggestions you have that could improve the writing.",
},
],
});
},
},
],
});

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB