mirror of
https://github.com/katanemo/plano.git
synced 2026-06-17 15:25:17 +02:00
demo(vercel-ai-sdk): add Next.js app routes, auth, and assets
This commit is contained in:
parent
e69964028e
commit
b7af8ab536
34 changed files with 2702 additions and 0 deletions
84
demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts
Normal file
84
demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts
Normal 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" };
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
// biome-ignore lint/performance/noBarrelFile: "Required"
|
||||
export { GET, POST } from "@/app/(auth)/auth";
|
||||
|
|
@ -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 });
|
||||
}
|
||||
13
demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts
Normal file
13
demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts
Normal 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;
|
||||
95
demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts
Normal file
95
demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
77
demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx
Normal file
77
demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx
Normal file
77
demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts
Normal file
51
demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
317
demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts
Normal file
317
demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts
Normal 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 });
|
||||
}
|
||||
39
demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts
Normal file
39
demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts
Normal 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>;
|
||||
126
demos/use_cases/vercel-ai-sdk/app/(chat)/api/document/route.ts
Normal file
126
demos/use_cases/vercel-ai-sdk/app/(chat)/api/document/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
75
demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts
Normal file
75
demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts
Normal 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 });
|
||||
}
|
||||
82
demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx
Normal file
82
demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx
Normal file
35
demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
demos/use_cases/vercel-ai-sdk/app/(chat)/opengraph-image.png
Normal file
BIN
demos/use_cases/vercel-ai-sdk/app/(chat)/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
52
demos/use_cases/vercel-ai-sdk/app/(chat)/page.tsx
Normal file
52
demos/use_cases/vercel-ai-sdk/app/(chat)/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
demos/use_cases/vercel-ai-sdk/app/(chat)/twitter-image.png
Normal file
BIN
demos/use_cases/vercel-ai-sdk/app/(chat)/twitter-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
demos/use_cases/vercel-ai-sdk/app/favicon.ico
Normal file
BIN
demos/use_cases/vercel-ai-sdk/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
318
demos/use_cases/vercel-ai-sdk/app/globals.css
Normal file
318
demos/use_cases/vercel-ai-sdk/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
87
demos/use_cases/vercel-ai-sdk/app/layout.tsx
Normal file
87
demos/use_cases/vercel-ai-sdk/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
demos/use_cases/vercel-ai-sdk/artifacts/actions.ts
Normal file
8
demos/use_cases/vercel-ai-sdk/artifacts/actions.ts
Normal 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 ?? [];
|
||||
}
|
||||
280
demos/use_cases/vercel-ai-sdk/artifacts/code/client.tsx
Normal file
280
demos/use_cases/vercel-ai-sdk/artifacts/code/client.tsx
Normal 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
75
demos/use_cases/vercel-ai-sdk/artifacts/code/server.ts
Normal file
75
demos/use_cases/vercel-ai-sdk/artifacts/code/server.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
76
demos/use_cases/vercel-ai-sdk/artifacts/image/client.tsx
Normal file
76
demos/use_cases/vercel-ai-sdk/artifacts/image/client.tsx
Normal 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: [],
|
||||
});
|
||||
115
demos/use_cases/vercel-ai-sdk/artifacts/sheet/client.tsx
Normal file
115
demos/use_cases/vercel-ai-sdk/artifacts/sheet/client.tsx
Normal 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?",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
81
demos/use_cases/vercel-ai-sdk/artifacts/sheet/server.ts
Normal file
81
demos/use_cases/vercel-ai-sdk/artifacts/sheet/server.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
179
demos/use_cases/vercel-ai-sdk/artifacts/text/client.tsx
Normal file
179
demos/use_cases/vercel-ai-sdk/artifacts/text/client.tsx
Normal 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.",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
73
demos/use_cases/vercel-ai-sdk/artifacts/text/server.ts
Normal file
73
demos/use_cases/vercel-ai-sdk/artifacts/text/server.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
BIN
demos/use_cases/vercel-ai-sdk/public/images/demo-thumbnail.png
Normal file
BIN
demos/use_cases/vercel-ai-sdk/public/images/demo-thumbnail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Loading…
Add table
Add a link
Reference in a new issue