Compare commits
10 commits
06b61bd829
...
2becdb4a56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2becdb4a56 | ||
|
|
107a43a803 | ||
|
|
d25b71c0a1 | ||
|
|
146b3cb8cf | ||
|
|
691c3cb05f | ||
|
|
f9652b452a | ||
|
|
3651670fb9 | ||
|
|
66762d023d | ||
|
|
15f626b401 | ||
|
|
27f2f9a9df |
178 changed files with 5544 additions and 8413 deletions
19
.env.example
19
.env.example
|
|
@ -1,24 +1,15 @@
|
|||
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
|
||||
BETTER_AUTH_SECRET=****
|
||||
BETTER_AUTH_URL=****
|
||||
# generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
|
||||
AUTH_SECRET=****
|
||||
|
||||
# The following keys below are automatically created and
|
||||
# added to your environment when you deploy on Vercel
|
||||
|
||||
# Instructions to create an AI Gateway API key here: https://vercel.com/ai-gateway
|
||||
# API key required for non-Vercel deployments
|
||||
# For Vercel deployments, OIDC tokens are used automatically
|
||||
# required for non-vercel deployments, vercel uses OIDC automatically
|
||||
# https://vercel.com/ai-gateway
|
||||
AI_GATEWAY_API_KEY=****
|
||||
|
||||
|
||||
# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/vercel-blob
|
||||
# https://vercel.com/docs/vercel-blob
|
||||
BLOB_READ_WRITE_TOKEN=****
|
||||
|
||||
# Instructions to create a PostgreSQL database here: https://vercel.com/docs/postgres
|
||||
# https://vercel.com/docs/postgres
|
||||
POSTGRES_URL=****
|
||||
|
||||
|
||||
# Instructions to create a Redis store here:
|
||||
# https://vercel.com/docs/redis
|
||||
REDIS_URL=****
|
||||
|
|
|
|||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
version: 10.32.1
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
|
|
|||
23
.gitignore
vendored
23
.gitignore
vendored
|
|
@ -1,46 +1,27 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
.env
|
||||
.vercel
|
||||
.env*.local
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/*
|
||||
|
||||
next-env.d.ts
|
||||
next-env.d.ts
|
||||
tsconfig.tsbuildinfo
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<a href="https://chat.vercel.ai/">
|
||||
<a href="https://chatbot.ai-sdk.dev/demo">
|
||||
<img alt="Chatbot" src="app/(chat)/opengraph-image.png">
|
||||
<h1 align="center">Chatbot</h1>
|
||||
</a>
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://chatbot.dev"><strong>Read Docs</strong></a> ·
|
||||
<a href="https://chatbot.ai-sdk.dev/docs"><strong>Read Docs</strong></a> ·
|
||||
<a href="#features"><strong>Features</strong></a> ·
|
||||
<a href="#model-providers"><strong>Model Providers</strong></a> ·
|
||||
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
## Model Providers
|
||||
|
||||
This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. The default model is [OpenAI](https://openai.com) GPT-4.1 Mini, with support for Anthropic, Google, and xAI models.
|
||||
This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. Models are configured in `lib/ai/models.ts` with per-model provider routing. Included models: Mistral, Moonshot, DeepSeek, OpenAI, and xAI.
|
||||
|
||||
### AI Gateway Authentication
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
import { createUser, getUser } from "@/lib/db/queries";
|
||||
|
||||
import { signIn } from "./auth";
|
||||
|
||||
const authFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
|
|
@ -22,11 +25,10 @@ export const login = async (
|
|||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
await auth.api.signInEmail({
|
||||
body: {
|
||||
email: validatedData.email,
|
||||
password: validatedData.password,
|
||||
},
|
||||
await signIn("credentials", {
|
||||
email: validatedData.email,
|
||||
password: validatedData.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
return { status: "success" };
|
||||
|
|
@ -59,17 +61,17 @@ export const register = async (
|
|||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: validatedData.email,
|
||||
password: validatedData.password,
|
||||
name: validatedData.email,
|
||||
},
|
||||
});
|
||||
const [user] = await getUser(validatedData.email);
|
||||
|
||||
if (!result) {
|
||||
return { status: "failed" };
|
||||
if (user) {
|
||||
return { status: "user_exists" } as RegisterActionState;
|
||||
}
|
||||
await createUser(validatedData.email, validatedData.password);
|
||||
await signIn("credentials", {
|
||||
email: validatedData.email,
|
||||
password: validatedData.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
|
|
@ -77,11 +79,6 @@ export const register = async (
|
|||
return { status: "invalid_data" };
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (message.includes("already exists") || message.includes("UNIQUE")) {
|
||||
return { status: "user_exists" };
|
||||
}
|
||||
|
||||
return { status: "failed" };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
1
app/(auth)/api/auth/[...nextauth]/route.ts
Normal file
1
app/(auth)/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { GET, POST } from "@/app/(auth)/auth";
|
||||
26
app/(auth)/api/auth/guest/route.ts
Normal file
26
app/(auth)/api/auth/guest/route.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { signIn } from "@/app/(auth)/auth";
|
||||
import { isDevelopmentEnvironment } from "@/lib/constants";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawRedirect = searchParams.get("redirectUrl") || "/";
|
||||
const redirectUrl =
|
||||
rawRedirect.startsWith("/") && !rawRedirect.startsWith("//")
|
||||
? rawRedirect
|
||||
: "/";
|
||||
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
secureCookie: !isDevelopmentEnvironment,
|
||||
});
|
||||
|
||||
if (token) {
|
||||
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
return NextResponse.redirect(new URL(`${base}/`, request.url));
|
||||
}
|
||||
|
||||
return signIn("guest", { redirect: true, redirectTo: redirectUrl });
|
||||
}
|
||||
14
app/(auth)/auth.config.ts
Normal file
14
app/(auth)/auth.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { NextAuthConfig } from "next-auth";
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
|
||||
export const authConfig = {
|
||||
basePath: "/api/auth",
|
||||
trustHost: true,
|
||||
pages: {
|
||||
signIn: `${base}/login`,
|
||||
newUser: `${base}/`,
|
||||
},
|
||||
providers: [],
|
||||
callbacks: {},
|
||||
} satisfies NextAuthConfig;
|
||||
99
app/(auth)/auth.ts
Normal file
99
app/(auth)/auth.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { compare } from "bcrypt-ts";
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
import type { DefaultJWT } from "next-auth/jwt";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { DUMMY_PASSWORD } from "@/lib/constants";
|
||||
import { createGuestUser, getUser } from "@/lib/db/queries";
|
||||
import { authConfig } from "./auth.config";
|
||||
|
||||
export type UserType = "guest" | "regular";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session extends DefaultSession {
|
||||
user: {
|
||||
id: string;
|
||||
type: UserType;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
id?: string;
|
||||
email?: string | null;
|
||||
type: UserType;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT extends DefaultJWT {
|
||||
id: string;
|
||||
type: UserType;
|
||||
}
|
||||
}
|
||||
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
signIn,
|
||||
signOut,
|
||||
} = NextAuth({
|
||||
...authConfig,
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const email = String(credentials.email ?? "");
|
||||
const password = String(credentials.password ?? "");
|
||||
const users = await getUser(email);
|
||||
|
||||
if (users.length === 0) {
|
||||
await compare(password, DUMMY_PASSWORD);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [user] = users;
|
||||
|
||||
if (!user.password) {
|
||||
await compare(password, DUMMY_PASSWORD);
|
||||
return null;
|
||||
}
|
||||
|
||||
const passwordsMatch = await compare(password, user.password);
|
||||
|
||||
if (!passwordsMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...user, type: "regular" };
|
||||
},
|
||||
}),
|
||||
Credentials({
|
||||
id: "guest",
|
||||
credentials: {},
|
||||
async authorize() {
|
||||
const [guestUser] = await createGuestUser();
|
||||
return { ...guestUser, type: "guest" };
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id as string;
|
||||
token.type = user.type;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id;
|
||||
session.user.type = token.type;
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
43
app/(auth)/layout.tsx
Normal file
43
app/(auth)/layout.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { SparklesIcon, VercelIcon } from "@/components/chat/icons";
|
||||
import { Preview } from "@/components/chat/preview";
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-dvh w-screen bg-sidebar">
|
||||
<div className="flex w-full flex-col bg-background p-8 xl:w-[600px] xl:shrink-0 xl:rounded-r-2xl xl:border-r xl:border-border/40 md:p-16">
|
||||
<Link
|
||||
className="flex w-fit items-center gap-1.5 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
|
||||
href="/"
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5" />
|
||||
Back
|
||||
</Link>
|
||||
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center gap-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-border/50">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 flex-col overflow-hidden pl-12 xl:flex">
|
||||
<div className="flex items-center gap-1.5 pt-8 text-[13px] text-muted-foreground/50">
|
||||
Powered by
|
||||
<VercelIcon size={14} />
|
||||
<span className="font-medium text-muted-foreground">AI Gateway</span>
|
||||
</div>
|
||||
<div className="flex-1 pt-4">
|
||||
<Preview />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,36 +2,30 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
import { SubmitButton } from "@/components/submit-button";
|
||||
import { toast } from "@/components/toast";
|
||||
import { useSession } from "@/lib/client";
|
||||
import { AuthForm } from "@/components/chat/auth-form";
|
||||
import { SubmitButton } from "@/components/chat/submit-button";
|
||||
import { toast } from "@/components/chat/toast";
|
||||
import { type LoginActionState, login } from "../actions";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSuccessful, setIsSuccessful] = useState(false);
|
||||
|
||||
const [state, formAction] = useActionState<LoginActionState, FormData>(
|
||||
login,
|
||||
{
|
||||
status: "idle",
|
||||
}
|
||||
{ status: "idle" }
|
||||
);
|
||||
|
||||
const { refetch } = useSession();
|
||||
const { update: updateSession } = useSession();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
|
||||
useEffect(() => {
|
||||
if (state.status === "failed") {
|
||||
toast({
|
||||
type: "error",
|
||||
description: "Invalid credentials!",
|
||||
});
|
||||
toast({ type: "error", description: "Invalid credentials!" });
|
||||
} else if (state.status === "invalid_data") {
|
||||
toast({
|
||||
type: "error",
|
||||
|
|
@ -39,8 +33,8 @@ export default function Page() {
|
|||
});
|
||||
} else if (state.status === "success") {
|
||||
setIsSuccessful(true);
|
||||
refetch();
|
||||
router.push("/");
|
||||
updateSession();
|
||||
router.refresh();
|
||||
}
|
||||
}, [state.status]);
|
||||
|
||||
|
|
@ -50,28 +44,23 @@ export default function Page() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
|
||||
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
|
||||
<h3 className="font-semibold text-xl dark:text-zinc-50">Sign In</h3>
|
||||
<p className="text-gray-500 text-sm dark:text-zinc-400">
|
||||
Use your email and password to sign in
|
||||
</p>
|
||||
</div>
|
||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
|
||||
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
|
||||
{"Don't have an account? "}
|
||||
<Link
|
||||
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
|
||||
href="/register"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
{" for free."}
|
||||
</p>
|
||||
</AuthForm>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sign in to your account to continue
|
||||
</p>
|
||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
|
||||
<p className="text-center text-[13px] text-muted-foreground">
|
||||
{"No account? "}
|
||||
<Link
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
href="/register"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</AuthForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,29 +2,26 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
import { SubmitButton } from "@/components/submit-button";
|
||||
import { toast } from "@/components/toast";
|
||||
import { useSession } from "@/lib/client";
|
||||
import { AuthForm } from "@/components/chat/auth-form";
|
||||
import { SubmitButton } from "@/components/chat/submit-button";
|
||||
import { toast } from "@/components/chat/toast";
|
||||
import { type RegisterActionState, register } from "../actions";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSuccessful, setIsSuccessful] = useState(false);
|
||||
|
||||
const [state, formAction] = useActionState<RegisterActionState, FormData>(
|
||||
register,
|
||||
{
|
||||
status: "idle",
|
||||
}
|
||||
{ status: "idle" }
|
||||
);
|
||||
|
||||
const { refetch } = useSession();
|
||||
const { update: updateSession } = useSession();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
|
||||
useEffect(() => {
|
||||
if (state.status === "user_exists") {
|
||||
toast({ type: "error", description: "Account already exists!" });
|
||||
|
|
@ -36,11 +33,10 @@ export default function Page() {
|
|||
description: "Failed validating your submission!",
|
||||
});
|
||||
} else if (state.status === "success") {
|
||||
toast({ type: "success", description: "Account created successfully!" });
|
||||
|
||||
toast({ type: "success", description: "Account created!" });
|
||||
setIsSuccessful(true);
|
||||
refetch();
|
||||
router.push("/");
|
||||
updateSession();
|
||||
router.refresh();
|
||||
}
|
||||
}, [state.status]);
|
||||
|
||||
|
|
@ -50,28 +46,21 @@ export default function Page() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
|
||||
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
|
||||
<h3 className="font-semibold text-xl dark:text-zinc-50">Sign Up</h3>
|
||||
<p className="text-gray-500 text-sm dark:text-zinc-400">
|
||||
Create an account with your email and password
|
||||
</p>
|
||||
</div>
|
||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||
<SubmitButton isSuccessful={isSuccessful}>Sign Up</SubmitButton>
|
||||
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
|
||||
{"Already have an account? "}
|
||||
<Link
|
||||
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
|
||||
href="/login"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
{" instead."}
|
||||
</p>
|
||||
</AuthForm>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Create account</h1>
|
||||
<p className="text-sm text-muted-foreground">Get started for free</p>
|
||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||
<SubmitButton isSuccessful={isSuccessful}>Sign up</SubmitButton>
|
||||
<p className="text-center text-[13px] text-muted-foreground">
|
||||
{"Have an account? "}
|
||||
<Link
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
href="/login"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</AuthForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
import { generateText, type UIMessage } from "ai";
|
||||
import { cookies } from "next/headers";
|
||||
import type { VisibilityType } from "@/components/visibility-selector";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import type { VisibilityType } from "@/components/chat/visibility-selector";
|
||||
import { titleModel } from "@/lib/ai/models";
|
||||
import { titlePrompt } from "@/lib/ai/prompts";
|
||||
import { getTitleModel } from "@/lib/ai/providers";
|
||||
import {
|
||||
deleteMessagesByChatIdAfterTimestamp,
|
||||
getChatById,
|
||||
getMessageById,
|
||||
updateChatVisibilityById,
|
||||
} from "@/lib/db/queries";
|
||||
|
|
@ -26,6 +29,9 @@ export async function generateTitleFromUserMessage({
|
|||
model: getTitleModel(),
|
||||
system: titlePrompt,
|
||||
prompt: getTextFromMessage(message),
|
||||
providerOptions: {
|
||||
gateway: { order: titleModel.gatewayOrder },
|
||||
},
|
||||
});
|
||||
return text
|
||||
.replace(/^[#*"\s]+/, "")
|
||||
|
|
@ -34,7 +40,20 @@ export async function generateTitleFromUserMessage({
|
|||
}
|
||||
|
||||
export async function deleteTrailingMessages({ id }: { id: string }) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const [message] = await getMessageById({ id });
|
||||
if (!message) {
|
||||
throw new Error("Message not found");
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id: message.chatId });
|
||||
if (!chat || chat.userId !== session.user.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await deleteMessagesByChatIdAfterTimestamp({
|
||||
chatId: message.chatId,
|
||||
|
|
@ -49,5 +68,15 @@ export async function updateChatVisibility({
|
|||
chatId: string;
|
||||
visibility: VisibilityType;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id: chatId });
|
||||
if (!chat || chat.userId !== session.user.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await updateChatVisibilityById({ chatId, visibility });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
|
|
@ -10,15 +10,21 @@ import {
|
|||
import { checkBotId } from "botid/server";
|
||||
import { after } from "next/server";
|
||||
import { createResumableStreamContext } from "resumable-stream";
|
||||
import { auth, type UserType } from "@/app/(auth)/auth";
|
||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||
import { allowedModelIds } from "@/lib/ai/models";
|
||||
import {
|
||||
allowedModelIds,
|
||||
chatModels,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
getCapabilities,
|
||||
} from "@/lib/ai/models";
|
||||
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
|
||||
import { getLanguageModel } from "@/lib/ai/providers";
|
||||
import { createDocument } from "@/lib/ai/tools/create-document";
|
||||
import { editDocument } from "@/lib/ai/tools/edit-document";
|
||||
import { getWeather } from "@/lib/ai/tools/get-weather";
|
||||
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
|
||||
import { updateDocument } from "@/lib/ai/tools/update-document";
|
||||
import { getSession, getUserType, type UserType } from "@/lib/auth";
|
||||
import { isProductionEnvironment } from "@/lib/constants";
|
||||
import {
|
||||
createStreamId,
|
||||
|
|
@ -67,20 +73,20 @@ export async function POST(request: Request) {
|
|||
|
||||
const [, session] = await Promise.all([
|
||||
checkBotId().catch(() => null),
|
||||
getSession(),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:chat").toResponse();
|
||||
}
|
||||
|
||||
if (!allowedModelIds.has(selectedChatModel)) {
|
||||
return new ChatbotError("bad_request:api").toResponse();
|
||||
}
|
||||
const chatModel = allowedModelIds.has(selectedChatModel)
|
||||
? selectedChatModel
|
||||
: DEFAULT_CHAT_MODEL;
|
||||
|
||||
await checkIpRateLimit(ipAddress(request));
|
||||
|
||||
const userType: UserType = getUserType(session.user);
|
||||
const userType: UserType = session.user.type;
|
||||
|
||||
const messageCount = await getMessageCountByUserId({
|
||||
id: session.user.id,
|
||||
|
|
@ -101,9 +107,7 @@ export async function POST(request: Request) {
|
|||
if (chat.userId !== session.user.id) {
|
||||
return new ChatbotError("forbidden:chat").toResponse();
|
||||
}
|
||||
if (!isToolApprovalFlow) {
|
||||
messagesFromDb = await getMessagesByChatId({ id });
|
||||
}
|
||||
messagesFromDb = await getMessagesByChatId({ id });
|
||||
} else if (message?.role === "user") {
|
||||
await saveChat({
|
||||
id,
|
||||
|
|
@ -114,9 +118,43 @@ export async function POST(request: Request) {
|
|||
titlePromise = generateTitleFromUserMessage({ message });
|
||||
}
|
||||
|
||||
const uiMessages = isToolApprovalFlow
|
||||
? (messages as ChatMessage[])
|
||||
: [...convertToUIMessages(messagesFromDb), message as ChatMessage];
|
||||
let uiMessages: ChatMessage[];
|
||||
|
||||
if (isToolApprovalFlow && messages) {
|
||||
const dbMessages = convertToUIMessages(messagesFromDb);
|
||||
const approvalStates = new Map(
|
||||
messages.flatMap(
|
||||
(m) =>
|
||||
m.parts
|
||||
?.filter(
|
||||
(p: Record<string, unknown>) =>
|
||||
p.state === "approval-responded" ||
|
||||
p.state === "output-denied"
|
||||
)
|
||||
.map((p: Record<string, unknown>) => [
|
||||
String(p.toolCallId ?? ""),
|
||||
p,
|
||||
]) ?? []
|
||||
)
|
||||
);
|
||||
uiMessages = dbMessages.map((msg) => ({
|
||||
...msg,
|
||||
parts: msg.parts.map((part) => {
|
||||
if (
|
||||
"toolCallId" in part &&
|
||||
approvalStates.has(String(part.toolCallId))
|
||||
) {
|
||||
return { ...part, ...approvalStates.get(String(part.toolCallId)) };
|
||||
}
|
||||
return part;
|
||||
}),
|
||||
})) as ChatMessage[];
|
||||
} else {
|
||||
uiMessages = [
|
||||
...convertToUIMessages(messagesFromDb),
|
||||
message as ChatMessage,
|
||||
];
|
||||
}
|
||||
|
||||
const { longitude, latitude, city, country } = geolocation(request);
|
||||
|
||||
|
|
@ -142,10 +180,11 @@ export async function POST(request: Request) {
|
|||
});
|
||||
}
|
||||
|
||||
const isReasoningModel =
|
||||
selectedChatModel.endsWith("-thinking") ||
|
||||
(selectedChatModel.includes("reasoning") &&
|
||||
!selectedChatModel.includes("non-reasoning"));
|
||||
const modelConfig = chatModels.find((m) => m.id === chatModel);
|
||||
const modelCapabilities = await getCapabilities();
|
||||
const capabilities = modelCapabilities[chatModel];
|
||||
const isReasoningModel = capabilities?.reasoning === true;
|
||||
const supportsTools = capabilities?.tools === true;
|
||||
|
||||
const modelMessages = await convertToModelMessages(uiMessages);
|
||||
|
||||
|
|
@ -153,30 +192,46 @@ export async function POST(request: Request) {
|
|||
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
|
||||
execute: async ({ writer: dataStream }) => {
|
||||
const result = streamText({
|
||||
model: getLanguageModel(selectedChatModel),
|
||||
system: systemPrompt({ selectedChatModel, requestHints }),
|
||||
model: getLanguageModel(chatModel),
|
||||
system: systemPrompt({ requestHints, supportsTools }),
|
||||
messages: modelMessages,
|
||||
stopWhen: stepCountIs(5),
|
||||
experimental_activeTools: isReasoningModel
|
||||
? []
|
||||
: [
|
||||
"getWeather",
|
||||
"createDocument",
|
||||
"updateDocument",
|
||||
"requestSuggestions",
|
||||
],
|
||||
providerOptions: isReasoningModel
|
||||
? {
|
||||
anthropic: {
|
||||
thinking: { type: "enabled", budgetTokens: 10_000 },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
experimental_activeTools:
|
||||
isReasoningModel && !supportsTools
|
||||
? []
|
||||
: [
|
||||
"getWeather",
|
||||
"createDocument",
|
||||
"editDocument",
|
||||
"updateDocument",
|
||||
"requestSuggestions",
|
||||
],
|
||||
providerOptions: {
|
||||
...(modelConfig?.gatewayOrder && {
|
||||
gateway: { order: modelConfig.gatewayOrder },
|
||||
}),
|
||||
...(modelConfig?.reasoningEffort && {
|
||||
openai: { reasoningEffort: modelConfig.reasoningEffort },
|
||||
}),
|
||||
},
|
||||
tools: {
|
||||
getWeather,
|
||||
createDocument: createDocument({ session, dataStream }),
|
||||
updateDocument: updateDocument({ session, dataStream }),
|
||||
requestSuggestions: requestSuggestions({ session, dataStream }),
|
||||
createDocument: createDocument({
|
||||
session,
|
||||
dataStream,
|
||||
modelId: chatModel,
|
||||
}),
|
||||
editDocument: editDocument({ dataStream, session }),
|
||||
updateDocument: updateDocument({
|
||||
session,
|
||||
dataStream,
|
||||
modelId: chatModel,
|
||||
}),
|
||||
requestSuggestions: requestSuggestions({
|
||||
session,
|
||||
dataStream,
|
||||
modelId: chatModel,
|
||||
}),
|
||||
},
|
||||
experimental_telemetry: {
|
||||
isEnabled: isProductionEnvironment,
|
||||
|
|
@ -189,9 +244,13 @@ export async function POST(request: Request) {
|
|||
);
|
||||
|
||||
if (titlePromise) {
|
||||
const title = await titlePromise;
|
||||
dataStream.write({ type: "data-chat-title", data: title });
|
||||
updateChatTitleById({ chatId: id, title });
|
||||
try {
|
||||
const title = await titlePromise;
|
||||
dataStream.write({ type: "data-chat-title", data: title });
|
||||
updateChatTitleById({ chatId: id, title });
|
||||
} catch (_) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
},
|
||||
generateId: generateUUID,
|
||||
|
|
@ -262,7 +321,7 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore redis errors
|
||||
/* non-critical */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -295,7 +354,7 @@ export async function DELETE(request: Request) {
|
|||
return new ChatbotError("bad_request:api").toResponse();
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:chat").toResponse();
|
||||
|
|
|
|||
|
|
@ -20,18 +20,16 @@ const userMessageSchema = z.object({
|
|||
parts: z.array(partSchema),
|
||||
});
|
||||
|
||||
// For tool approval flows, we accept all messages (more permissive schema)
|
||||
const messageSchema = z.object({
|
||||
const toolApprovalMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
role: z.string(),
|
||||
parts: z.array(z.any()),
|
||||
role: z.enum(["user", "assistant"]),
|
||||
parts: z.array(z.record(z.unknown())),
|
||||
});
|
||||
|
||||
export const postRequestBodySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
// Either a single new message or all messages (for tool approvals)
|
||||
message: userMessageSchema.optional(),
|
||||
messages: z.array(messageSchema).optional(),
|
||||
messages: z.array(toolApprovalMessageSchema).optional(),
|
||||
selectedChatModel: z.string(),
|
||||
selectedVisibilityType: z.enum(["public", "private"]),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import type { ArtifactKind } from "@/components/artifact";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import type { ArtifactKind } from "@/components/chat/artifact";
|
||||
import {
|
||||
deleteDocumentsByIdAfterTimestamp,
|
||||
getDocumentsById,
|
||||
saveDocument,
|
||||
updateDocumentContent,
|
||||
} from "@/lib/db/queries";
|
||||
import { ChatbotError } from "@/lib/errors";
|
||||
|
||||
const documentSchema = z.object({
|
||||
content: z.string(),
|
||||
title: z.string(),
|
||||
kind: z.enum(["text", "code", "image", "sheet"]),
|
||||
isManualEdit: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
|
@ -18,7 +27,7 @@ export async function GET(request: Request) {
|
|||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:document").toResponse();
|
||||
|
|
@ -50,18 +59,29 @@ export async function POST(request: Request) {
|
|||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("not_found:document").toResponse();
|
||||
}
|
||||
|
||||
const {
|
||||
content,
|
||||
title,
|
||||
kind,
|
||||
}: { content: string; title: string; kind: ArtifactKind } =
|
||||
await request.json();
|
||||
let content: string;
|
||||
let title: string;
|
||||
let kind: ArtifactKind;
|
||||
let isManualEdit: boolean | undefined;
|
||||
|
||||
try {
|
||||
const parsed = documentSchema.parse(await request.json());
|
||||
content = parsed.content;
|
||||
title = parsed.title;
|
||||
kind = parsed.kind;
|
||||
isManualEdit = parsed.isManualEdit;
|
||||
} catch {
|
||||
return new ChatbotError(
|
||||
"bad_request:api",
|
||||
"Invalid request body."
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
const documents = await getDocumentsById({ id });
|
||||
|
||||
|
|
@ -73,6 +93,11 @@ export async function POST(request: Request) {
|
|||
}
|
||||
}
|
||||
|
||||
if (isManualEdit && documents.length > 0) {
|
||||
const result = await updateDocumentContent({ id, content });
|
||||
return Response.json(result, { status: 200 });
|
||||
}
|
||||
|
||||
const document = await saveDocument({
|
||||
id,
|
||||
content,
|
||||
|
|
@ -103,7 +128,7 @@ export async function DELETE(request: Request) {
|
|||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:document").toResponse();
|
||||
|
|
@ -117,9 +142,18 @@ export async function DELETE(request: Request) {
|
|||
return new ChatbotError("forbidden:document").toResponse();
|
||||
}
|
||||
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
|
||||
if (Number.isNaN(parsedTimestamp.getTime())) {
|
||||
return new ChatbotError(
|
||||
"bad_request:api",
|
||||
"Invalid timestamp."
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
|
||||
id,
|
||||
timestamp: new Date(timestamp),
|
||||
timestamp: parsedTimestamp,
|
||||
});
|
||||
|
||||
return Response.json(documentsDeleted, { status: 200 });
|
||||
|
|
|
|||
|
|
@ -2,23 +2,21 @@ import { put } from "@vercel/blob";
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
// Use Blob instead of File since File is not available in Node.js environment
|
||||
const FileSchema = z.object({
|
||||
file: z
|
||||
.instanceof(Blob)
|
||||
.refine((file) => file.size <= 5 * 1024 * 1024, {
|
||||
message: "File size should be less than 5MB",
|
||||
})
|
||||
// Update the file type based on the kind of files you want to accept
|
||||
.refine((file) => ["image/jpeg", "image/png"].includes(file.type), {
|
||||
message: "File type should be JPEG or PNG",
|
||||
}),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -46,12 +44,12 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get filename from formData since Blob doesn't have name property
|
||||
const filename = (formData.get("file") as File).name;
|
||||
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
try {
|
||||
const data = await put(`${filename}`, fileBuffer, {
|
||||
const data = await put(`${safeName}`, fileBuffer, {
|
||||
access: "public",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import type { NextRequest } from "next/server";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
|
||||
import { ChatbotError } from "@/lib/errors";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "10", 10);
|
||||
const limit = Math.min(
|
||||
Math.max(Number.parseInt(searchParams.get("limit") || "10", 10), 1),
|
||||
50
|
||||
);
|
||||
const startingAfter = searchParams.get("starting_after");
|
||||
const endingBefore = searchParams.get("ending_before");
|
||||
|
||||
|
|
@ -17,7 +20,7 @@ export async function GET(request: NextRequest) {
|
|||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:chat").toResponse();
|
||||
|
|
@ -34,7 +37,7 @@ export async function GET(request: NextRequest) {
|
|||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:chat").toResponse();
|
||||
|
|
|
|||
43
app/(chat)/api/messages/route.ts
Normal file
43
app/(chat)/api/messages/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
|
||||
import { convertToUIMessages } from "@/lib/utils";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const chatId = searchParams.get("chatId");
|
||||
|
||||
if (!chatId) {
|
||||
return Response.json({ error: "chatId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [session, chat, messages] = await Promise.all([
|
||||
auth(),
|
||||
getChatById({ id: chatId }),
|
||||
getMessagesByChatId({ id: chatId }),
|
||||
]);
|
||||
|
||||
if (!chat) {
|
||||
return Response.json({
|
||||
messages: [],
|
||||
visibility: "private",
|
||||
userId: null,
|
||||
isReadonly: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
chat.visibility === "private" &&
|
||||
(!session?.user || session.user.id !== chat.userId)
|
||||
) {
|
||||
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const isReadonly = !session?.user || session.user.id !== chat.userId;
|
||||
|
||||
return Response.json({
|
||||
messages: convertToUIMessages(messages),
|
||||
visibility: chat.visibility,
|
||||
userId: chat.userId,
|
||||
isReadonly,
|
||||
});
|
||||
}
|
||||
20
app/(chat)/api/models/route.ts
Normal file
20
app/(chat)/api/models/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { getAllGatewayModels, getCapabilities, isDemo } from "@/lib/ai/models";
|
||||
|
||||
export async function GET() {
|
||||
const headers = {
|
||||
"Cache-Control": "public, max-age=86400, s-maxage=86400",
|
||||
};
|
||||
|
||||
const curatedCapabilities = await getCapabilities();
|
||||
|
||||
if (isDemo) {
|
||||
const models = await getAllGatewayModels();
|
||||
const capabilities = Object.fromEntries(
|
||||
models.map((m) => [m.id, curatedCapabilities[m.id] ?? m.capabilities])
|
||||
);
|
||||
|
||||
return Response.json({ capabilities, models }, { headers });
|
||||
}
|
||||
|
||||
return Response.json(curatedCapabilities, { headers });
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { getSession } from "@/lib/auth";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
|
||||
import { ChatbotError } from "@/lib/errors";
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ export async function GET(request: Request) {
|
|||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:suggestions").toResponse();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { getSession } from "@/lib/auth";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
|
||||
import { ChatbotError } from "@/lib/errors";
|
||||
|
||||
const voteSchema = z.object({
|
||||
chatId: z.string(),
|
||||
messageId: z.string(),
|
||||
type: z.enum(["up", "down"]),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const chatId = searchParams.get("chatId");
|
||||
|
|
@ -13,7 +20,7 @@ export async function GET(request: Request) {
|
|||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:vote").toResponse();
|
||||
|
|
@ -35,21 +42,23 @@ export async function GET(request: Request) {
|
|||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const {
|
||||
chatId,
|
||||
messageId,
|
||||
type,
|
||||
}: { chatId: string; messageId: string; type: "up" | "down" } =
|
||||
await request.json();
|
||||
let chatId: string;
|
||||
let messageId: string;
|
||||
let type: "up" | "down";
|
||||
|
||||
if (!chatId || !messageId || !type) {
|
||||
try {
|
||||
const parsed = voteSchema.parse(await request.json());
|
||||
chatId = parsed.chatId;
|
||||
messageId = parsed.messageId;
|
||||
type = parsed.type;
|
||||
} catch {
|
||||
return new ChatbotError(
|
||||
"bad_request:api",
|
||||
"Parameters chatId, messageId, and type are required."
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:vote").toResponse();
|
||||
|
|
|
|||
|
|
@ -1,81 +1,3 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { Chat } from "@/components/chat";
|
||||
import { DataStreamHandler } from "@/components/data-stream-handler";
|
||||
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
|
||||
import { convertToUIMessages } from "@/lib/utils";
|
||||
|
||||
export default function Page(props: { params: Promise<{ id: string }> }) {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<ChatPage params={props.params} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const chat = await getChatById({ id });
|
||||
|
||||
if (!chat) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (chat.visibility === "private") {
|
||||
if (!session.user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (session.user.id !== chat.userId) {
|
||||
return notFound();
|
||||
}
|
||||
}
|
||||
|
||||
const messagesFromDb = await getMessagesByChatId({
|
||||
id,
|
||||
});
|
||||
|
||||
const uiMessages = convertToUIMessages(messagesFromDb);
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const chatModelFromCookie = cookieStore.get("chat-model");
|
||||
|
||||
if (!chatModelFromCookie) {
|
||||
return (
|
||||
<>
|
||||
<Chat
|
||||
autoResume={true}
|
||||
id={chat.id}
|
||||
initialChatModel={DEFAULT_CHAT_MODEL}
|
||||
initialMessages={uiMessages}
|
||||
initialVisibilityType={chat.visibility}
|
||||
isReadonly={session?.user?.id !== chat.userId}
|
||||
/>
|
||||
<DataStreamHandler />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chat
|
||||
autoResume={true}
|
||||
id={chat.id}
|
||||
initialChatModel={chatModelFromCookie.value}
|
||||
initialMessages={uiMessages}
|
||||
initialVisibilityType={chat.visibility}
|
||||
isReadonly={session?.user?.id !== chat.userId}
|
||||
/>
|
||||
<DataStreamHandler />
|
||||
</>
|
||||
);
|
||||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,53 @@
|
|||
import { cookies } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import { Suspense } from "react";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { DataStreamProvider } from "@/components/data-stream-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import { AppSidebar } from "@/components/chat/app-sidebar";
|
||||
import { DataStreamProvider } from "@/components/chat/data-stream-provider";
|
||||
import { ChatShell } from "@/components/chat/shell";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { ActiveChatProvider } from "@/hooks/use-active-chat";
|
||||
import { auth } from "../(auth)/auth";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
|
||||
strategy="beforeInteractive"
|
||||
strategy="lazyOnload"
|
||||
/>
|
||||
<DataStreamProvider>
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<SidebarWrapper>{children}</SidebarWrapper>
|
||||
<Suspense fallback={<div className="flex h-dvh bg-sidebar" />}>
|
||||
<SidebarShell>{children}</SidebarShell>
|
||||
</Suspense>
|
||||
</DataStreamProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||
const [session, cookieStore] = await Promise.all([getSession(), cookies()]);
|
||||
async function SidebarShell({ children }: { children: React.ReactNode }) {
|
||||
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
|
||||
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={!isCollapsed}>
|
||||
<AppSidebar user={session?.user} />
|
||||
<SidebarInset>{children}</SidebarInset>
|
||||
<SidebarInset>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
theme="system"
|
||||
toastOptions={{
|
||||
className:
|
||||
"!bg-card !text-foreground !border-border/50 !shadow-[var(--shadow-float)]",
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<ActiveChatProvider>
|
||||
<ChatShell />
|
||||
</ActiveChatProvider>
|
||||
</Suspense>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,3 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { Suspense } from "react";
|
||||
import { Chat } from "@/components/chat";
|
||||
import { DataStreamHandler } from "@/components/data-stream-handler";
|
||||
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<NewChatPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function NewChatPage() {
|
||||
const cookieStore = await cookies();
|
||||
const modelIdFromCookie = cookieStore.get("chat-model");
|
||||
const id = generateUUID();
|
||||
|
||||
if (!modelIdFromCookie) {
|
||||
return (
|
||||
<>
|
||||
<Chat
|
||||
autoResume={false}
|
||||
id={id}
|
||||
initialChatModel={DEFAULT_CHAT_MODEL}
|
||||
initialMessages={[]}
|
||||
initialVisibilityType="private"
|
||||
isReadonly={false}
|
||||
key={id}
|
||||
/>
|
||||
<DataStreamHandler />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chat
|
||||
autoResume={false}
|
||||
id={id}
|
||||
initialChatModel={modelIdFromCookie.value}
|
||||
initialMessages={[]}
|
||||
initialVisibilityType="private"
|
||||
isReadonly={false}
|
||||
key={id}
|
||||
/>
|
||||
<DataStreamHandler />
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
542
app/globals.css
542
app/globals.css
|
|
@ -1,138 +1,42 @@
|
|||
@import "tailwindcss";
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
/* include utility classes in streamdown */
|
||||
@source "../node_modules/streamdown/dist/index.js";
|
||||
|
||||
/* custom variant for setting dark mode programmatically */
|
||||
@custom-variant dark (&:is(.dark, .dark *));
|
||||
|
||||
/* include plugins */
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/* define design tokens (light mode) */
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--chart-1: hsl(12 76% 61%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
--chart-5: hsl(27 87% 67%);
|
||||
--sidebar-background: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
/* border radius unit */
|
||||
--radius: 0.5rem;
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
/* define design tokens (dark mode) */
|
||||
.dark {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
}
|
||||
|
||||
/* define theme */
|
||||
@theme {
|
||||
--font-sans: var(--font-geist);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
--breakpoint-toast-mobile: 600px;
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
|
|
@ -142,24 +46,156 @@
|
|||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.985 0 0);
|
||||
--foreground: oklch(0.12 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.12 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.12 0 0);
|
||||
--primary: oklch(0.12 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.965 0 0);
|
||||
--secondary-foreground: oklch(0.38 0 0);
|
||||
--muted: oklch(0.94 0 0);
|
||||
--muted-foreground: oklch(0.58 0 0);
|
||||
--accent: oklch(0.965 0 0);
|
||||
--accent-foreground: oklch(0.12 0 0);
|
||||
--destructive: oklch(0.55 0.15 25);
|
||||
--border: oklch(0.9 0 0);
|
||||
--input: oklch(0.9 0 0);
|
||||
--ring: oklch(0.5 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.97 0 0);
|
||||
--sidebar-foreground: oklch(0.38 0 0);
|
||||
--sidebar-primary: oklch(0.12 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.12 0 0 / 0.06);
|
||||
--sidebar-accent-foreground: oklch(0.12 0 0);
|
||||
--sidebar-border: oklch(0.88 0 0);
|
||||
--sidebar-ring: oklch(0.5 0 0);
|
||||
|
||||
--shadow-card: 0 1px 3px oklch(0 0 0 / 0.05), 0 1px 1px oklch(0 0 0 / 0.03);
|
||||
--shadow-float:
|
||||
0 8px 24px -6px oklch(0 0 0 / 0.1), 0 2px 8px -2px oklch(0 0 0 / 0.04);
|
||||
--shadow-composer: 0 1px 2px oklch(0 0 0 / 0.04);
|
||||
--shadow-composer-focus:
|
||||
0 0 0 1px oklch(0 0 0 / 0.06), 0 2px 8px -2px oklch(0 0 0 / 0.06);
|
||||
--shadow-inset: inset 0 1px 1px oklch(0 0 0 / 0.03);
|
||||
--shadow-glow: 0 0 20px oklch(0 0 0 / 0.08);
|
||||
|
||||
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.195 0 0);
|
||||
--foreground: oklch(0.94 0 0);
|
||||
--card: oklch(0.225 0 0);
|
||||
--card-foreground: oklch(0.94 0 0);
|
||||
--popover: oklch(0.225 0 0);
|
||||
--popover-foreground: oklch(0.94 0 0);
|
||||
--primary: oklch(0.94 0 0);
|
||||
--primary-foreground: oklch(0.195 0 0);
|
||||
--secondary: oklch(0.26 0 0);
|
||||
--secondary-foreground: oklch(0.75 0 0);
|
||||
--muted: oklch(0.165 0 0);
|
||||
--muted-foreground: oklch(0.6 0 0);
|
||||
--accent: oklch(0.26 0 0);
|
||||
--accent-foreground: oklch(0.94 0 0);
|
||||
--destructive: oklch(0.7 0.15 25);
|
||||
--border: oklch(0.27 0 0);
|
||||
--input: oklch(0.27 0 0);
|
||||
--ring: oklch(0.45 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.175 0 0);
|
||||
--sidebar-foreground: oklch(0.78 0 0);
|
||||
--sidebar-primary: oklch(0.94 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.195 0 0);
|
||||
--sidebar-accent: oklch(0.94 0 0 / 0.06);
|
||||
--sidebar-accent-foreground: oklch(0.94 0 0);
|
||||
--sidebar-border: oklch(0.25 0 0);
|
||||
--sidebar-ring: oklch(0.45 0 0);
|
||||
|
||||
--shadow-card:
|
||||
inset 0 1px 0 oklch(1 0 0 / 0.04), 0 1px 2px oklch(0 0 0 / 0.2),
|
||||
0 0.5px 1px oklch(0 0 0 / 0.15);
|
||||
--shadow-float:
|
||||
0 0 0 1px oklch(1 0 0 / 0.06), 0 16px 48px -6px oklch(0 0 0 / 0.35),
|
||||
0 6px 12px -2px oklch(0 0 0 / 0.2);
|
||||
--shadow-composer:
|
||||
0 1px 3px oklch(0 0 0 / 0.2), inset 0 1px 0 oklch(1 0 0 / 0.03);
|
||||
--shadow-composer-focus:
|
||||
0 0 0 1px oklch(1 0 0 / 0.1), 0 4px 16px -4px oklch(0 0 0 / 0.3),
|
||||
inset 0 1px 0 oklch(1 0 0 / 0.04);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border ring-0;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "ss01", "ss02", "cv01";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
}
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@utility text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
|
@ -176,6 +212,14 @@
|
|||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
|
|
@ -192,22 +236,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
* {
|
||||
pointer-events: none !important;
|
||||
|
|
@ -227,92 +255,246 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes message-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thinking-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 oklch(0.55 0.12 250 / 0%);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px oklch(0.55 0.12 250 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes subtle-lift {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
}
|
||||
|
||||
@utility fade-up {
|
||||
animation: fade-up 0.25s var(--ease-spring) both;
|
||||
}
|
||||
|
||||
@utility fade-in {
|
||||
animation: fade-in 0.2s ease both;
|
||||
}
|
||||
|
||||
@utility shimmer {
|
||||
animation: shimmer 2s linear infinite;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
oklch(1 0 0 / 0.04),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@utility dot-pulse {
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@utility message-fade-in {
|
||||
animation: message-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@utility thinking-dot {
|
||||
animation: thinking-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@utility composer-glow {
|
||||
animation: glow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-editor,
|
||||
.cm-editor {
|
||||
@apply bg-transparent! outline-hidden! text-[13px]! leading-[1.6]!;
|
||||
font-family:
|
||||
"SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", ui-monospace,
|
||||
monospace !important;
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
@apply bg-background! dark:bg-zinc-800! outline-hidden! selection:bg-zinc-900!;
|
||||
@apply bg-transparent! border-r-0! outline-hidden!;
|
||||
}
|
||||
|
||||
.cm-gutter.cm-lineNumbers {
|
||||
@apply min-w-[3rem] text-muted-foreground/40 text-[11px]!;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply overflow-auto!;
|
||||
}
|
||||
|
||||
.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
||||
.ͼo.cm-selectionBackground,
|
||||
.ͼo.cm-content::selection {
|
||||
@apply bg-zinc-200! dark:bg-zinc-900!;
|
||||
background: oklch(0.55 0.12 250 / 0.15) !important;
|
||||
}
|
||||
|
||||
.dark
|
||||
.ͼo.cm-focused
|
||||
> .cm-scroller
|
||||
> .cm-selectionLayer
|
||||
.cm-selectionBackground,
|
||||
.dark .ͼo.cm-selectionBackground,
|
||||
.dark .ͼo.cm-content::selection {
|
||||
background: oklch(0.55 0.12 250 / 0.2) !important;
|
||||
}
|
||||
|
||||
.cm-activeLine {
|
||||
@apply bg-muted/50! rounded-sm!;
|
||||
}
|
||||
|
||||
.cm-activeLine,
|
||||
.cm-activeLineGutter {
|
||||
@apply bg-transparent!;
|
||||
}
|
||||
|
||||
.cm-activeLine {
|
||||
@apply rounded-r-sm!;
|
||||
.cm-activeLineGutter .cm-gutterElement {
|
||||
color: var(--foreground) !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cm-lineNumbers {
|
||||
@apply min-w-7;
|
||||
.cm-gutter.cm-lineNumbers .cm-gutterElement {
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
.cm-foldGutter {
|
||||
@apply min-w-3;
|
||||
}
|
||||
|
||||
.cm-lineNumbers .cm-activeLineGutter {
|
||||
@apply rounded-l-sm!;
|
||||
.cm-cursor {
|
||||
border-left-color: oklch(0.55 0.12 250) !important;
|
||||
border-left-width: 2px !important;
|
||||
}
|
||||
|
||||
.cm-matchingBracket {
|
||||
background: oklch(0.55 0.12 250 / 0.12) !important;
|
||||
outline: 1px solid oklch(0.55 0.12 250 / 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.suggestion-highlight {
|
||||
@apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
|
||||
}
|
||||
|
||||
/* minimal scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: --alpha(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* firefox scrollbar styling */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
@apply cursor-pointer rounded-sm bg-blue-200 transition-colors hover:bg-blue-300 dark:bg-blue-500/40 dark:text-blue-50 dark:hover:bg-blue-400/50;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0 0 0 / 0.12) transparent;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: oklch(1 0 0 / 0.1) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: oklch(0 0 0 / 0.12);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(1 0 0 / 0.2);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
[data-testid="artifact"] {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { Analytics } from "@vercel/analytics/next";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
import "./globals.css";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://chat.vercel.ai"),
|
||||
|
|
@ -14,7 +13,7 @@ export const metadata: Metadata = {
|
|||
};
|
||||
|
||||
export const viewport = {
|
||||
maximumScale: 1, // Disable auto-zoom on mobile Safari
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
const geist = Geist({
|
||||
|
|
@ -57,10 +56,6 @@ export default function RootLayout({
|
|||
return (
|
||||
<html
|
||||
className={`${geist.variable} ${geistMono.variable}`}
|
||||
// `next-themes` injects an extra classname to the body element to avoid
|
||||
// visual flicker before hydration. Hence the `suppressHydrationWarning`
|
||||
// prop is necessary to avoid the React hydration mismatch warning.
|
||||
// https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
|
|
@ -79,10 +74,12 @@ export default function RootLayout({
|
|||
disableTransitionOnChange
|
||||
enableSystem
|
||||
>
|
||||
<Toaster position="top-center" />
|
||||
{children}
|
||||
<SessionProvider
|
||||
basePath={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth`}
|
||||
>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { toast } from "sonner";
|
||||
import { CodeEditor } from "@/components/code-editor";
|
||||
import { CodeEditor } from "@/components/chat/code-editor";
|
||||
import {
|
||||
Console,
|
||||
type ConsoleOutput,
|
||||
type ConsoleOutputContent,
|
||||
} from "@/components/console";
|
||||
import { Artifact } from "@/components/create-artifact";
|
||||
} from "@/components/chat/console";
|
||||
import { Artifact } from "@/components/chat/create-artifact";
|
||||
import {
|
||||
CopyIcon,
|
||||
LogsIcon,
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
PlayIcon,
|
||||
RedoIcon,
|
||||
UndoIcon,
|
||||
} from "@/components/icons";
|
||||
} from "@/components/chat/icons";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const OUTPUT_HANDLERS = {
|
||||
|
|
@ -93,7 +93,7 @@ export const codeArtifact = new Artifact<"code", Metadata>({
|
|||
content: ({ metadata, setMetadata, ...props }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="px-1">
|
||||
<div className="relative min-h-[200px]">
|
||||
<CodeEditor {...props} />
|
||||
</div>
|
||||
|
||||
|
|
@ -193,14 +193,20 @@ export const codeArtifact = new Artifact<"code", Metadata>({
|
|||
},
|
||||
],
|
||||
}));
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
setMetadata((metadata) => ({
|
||||
...metadata,
|
||||
outputs: [
|
||||
...metadata.outputs.filter((output) => output.id !== runId),
|
||||
{
|
||||
id: runId,
|
||||
contents: [{ type: "text", value: error.message }],
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
value:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
status: "failed",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,75 +1,59 @@
|
|||
import { streamObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { streamText } from "ai";
|
||||
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||
import { getArtifactModel } from "@/lib/ai/providers";
|
||||
import { getLanguageModel } from "@/lib/ai/providers";
|
||||
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||
|
||||
function stripFences(code: string): string {
|
||||
return code
|
||||
.replace(/^```[\w]*\n?/, "")
|
||||
.replace(/\n?```\s*$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export const codeDocumentHandler = createDocumentHandler<"code">({
|
||||
kind: "code",
|
||||
onCreateDocument: async ({ title, dataStream }) => {
|
||||
onCreateDocument: async ({ title, dataStream, modelId }) => {
|
||||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamObject({
|
||||
model: getArtifactModel(),
|
||||
system: codePrompt,
|
||||
const { fullStream } = streamText({
|
||||
model: getLanguageModel(modelId),
|
||||
system: `${codePrompt}\n\nOutput ONLY the code. No explanations, no markdown fences, no wrapping.`,
|
||||
prompt: title,
|
||||
schema: z.object({
|
||||
code: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
for await (const delta of fullStream) {
|
||||
const { type } = delta;
|
||||
|
||||
if (type === "object") {
|
||||
const { object } = delta;
|
||||
const { code } = object;
|
||||
|
||||
if (code) {
|
||||
dataStream.write({
|
||||
type: "data-codeDelta",
|
||||
data: code ?? "",
|
||||
transient: true,
|
||||
});
|
||||
|
||||
draftContent = code;
|
||||
}
|
||||
if (delta.type === "text-delta") {
|
||||
draftContent += delta.text;
|
||||
dataStream.write({
|
||||
type: "data-codeDelta",
|
||||
data: stripFences(draftContent),
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return draftContent;
|
||||
return stripFences(draftContent);
|
||||
},
|
||||
onUpdateDocument: async ({ document, description, dataStream }) => {
|
||||
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
|
||||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamObject({
|
||||
model: getArtifactModel(),
|
||||
system: updateDocumentPrompt(document.content, "code"),
|
||||
const { fullStream } = streamText({
|
||||
model: getLanguageModel(modelId),
|
||||
system: `${updateDocumentPrompt(document.content, "code")}\n\nOutput ONLY the complete updated code. No explanations, no markdown fences, no wrapping.`,
|
||||
prompt: description,
|
||||
schema: z.object({
|
||||
code: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
for await (const delta of fullStream) {
|
||||
const { type } = delta;
|
||||
|
||||
if (type === "object") {
|
||||
const { object } = delta;
|
||||
const { code } = object;
|
||||
|
||||
if (code) {
|
||||
dataStream.write({
|
||||
type: "data-codeDelta",
|
||||
data: code ?? "",
|
||||
transient: true,
|
||||
});
|
||||
|
||||
draftContent = code;
|
||||
}
|
||||
if (delta.type === "text-delta") {
|
||||
draftContent += delta.text;
|
||||
dataStream.write({
|
||||
type: "data-codeDelta",
|
||||
data: stripFences(draftContent),
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return draftContent;
|
||||
return stripFences(draftContent);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { toast } from "sonner";
|
||||
import { Artifact } from "@/components/create-artifact";
|
||||
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/icons";
|
||||
import { ImageEditor } from "@/components/image-editor";
|
||||
import { Artifact } from "@/components/chat/create-artifact";
|
||||
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/chat/icons";
|
||||
import { ImageEditor } from "@/components/chat/image-editor";
|
||||
|
||||
export const imageArtifact = new Artifact({
|
||||
kind: "image",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { parse, unparse } from "papaparse";
|
||||
import { toast } from "sonner";
|
||||
import { Artifact } from "@/components/create-artifact";
|
||||
import { Artifact } from "@/components/chat/create-artifact";
|
||||
import {
|
||||
CopyIcon,
|
||||
LineChartIcon,
|
||||
RedoIcon,
|
||||
SparklesIcon,
|
||||
UndoIcon,
|
||||
} from "@/components/icons";
|
||||
import { SpreadsheetEditor } from "@/components/sheet-editor";
|
||||
} from "@/components/chat/icons";
|
||||
import { SpreadsheetEditor } from "@/components/chat/sheet-editor";
|
||||
|
||||
type Metadata = any;
|
||||
type Metadata = Record<string, never>;
|
||||
|
||||
export const sheetArtifact = new Artifact<"sheet", Metadata>({
|
||||
kind: "sheet",
|
||||
|
|
|
|||
|
|
@ -1,78 +1,49 @@
|
|||
import { streamObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { streamText } from "ai";
|
||||
import { sheetPrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||
import { getArtifactModel } from "@/lib/ai/providers";
|
||||
import { getLanguageModel } from "@/lib/ai/providers";
|
||||
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||
|
||||
export const sheetDocumentHandler = createDocumentHandler<"sheet">({
|
||||
kind: "sheet",
|
||||
onCreateDocument: async ({ title, dataStream }) => {
|
||||
onCreateDocument: async ({ title, dataStream, modelId }) => {
|
||||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamObject({
|
||||
model: getArtifactModel(),
|
||||
system: sheetPrompt,
|
||||
const { fullStream } = streamText({
|
||||
model: getLanguageModel(modelId),
|
||||
system: `${sheetPrompt}\n\nOutput ONLY the raw CSV data. No explanations, no markdown fences.`,
|
||||
prompt: title,
|
||||
schema: z.object({
|
||||
csv: z.string().describe("CSV data"),
|
||||
}),
|
||||
});
|
||||
|
||||
for await (const delta of fullStream) {
|
||||
const { type } = delta;
|
||||
|
||||
if (type === "object") {
|
||||
const { object } = delta;
|
||||
const { csv } = object;
|
||||
|
||||
if (csv) {
|
||||
dataStream.write({
|
||||
type: "data-sheetDelta",
|
||||
data: csv,
|
||||
transient: true,
|
||||
});
|
||||
|
||||
draftContent = csv;
|
||||
}
|
||||
if (delta.type === "text-delta") {
|
||||
draftContent += delta.text;
|
||||
dataStream.write({
|
||||
type: "data-sheetDelta",
|
||||
data: draftContent,
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dataStream.write({
|
||||
type: "data-sheetDelta",
|
||||
data: draftContent,
|
||||
transient: true,
|
||||
});
|
||||
|
||||
return draftContent;
|
||||
},
|
||||
onUpdateDocument: async ({ document, description, dataStream }) => {
|
||||
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
|
||||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamObject({
|
||||
model: getArtifactModel(),
|
||||
system: updateDocumentPrompt(document.content, "sheet"),
|
||||
const { fullStream } = streamText({
|
||||
model: getLanguageModel(modelId),
|
||||
system: `${updateDocumentPrompt(document.content, "sheet")}\n\nOutput ONLY the raw CSV data. No explanations, no markdown fences.`,
|
||||
prompt: description,
|
||||
schema: z.object({
|
||||
csv: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
for await (const delta of fullStream) {
|
||||
const { type } = delta;
|
||||
|
||||
if (type === "object") {
|
||||
const { object } = delta;
|
||||
const { csv } = object;
|
||||
|
||||
if (csv) {
|
||||
dataStream.write({
|
||||
type: "data-sheetDelta",
|
||||
data: csv,
|
||||
transient: true,
|
||||
});
|
||||
|
||||
draftContent = csv;
|
||||
}
|
||||
if (delta.type === "text-delta") {
|
||||
draftContent += delta.text;
|
||||
dataStream.write({
|
||||
type: "data-sheetDelta",
|
||||
data: draftContent,
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { toast } from "sonner";
|
||||
import { Artifact } from "@/components/create-artifact";
|
||||
import { DiffView } from "@/components/diffview";
|
||||
import { DocumentSkeleton } from "@/components/document-skeleton";
|
||||
import { Artifact } from "@/components/chat/create-artifact";
|
||||
import { DiffView } from "@/components/chat/diffview";
|
||||
import { DocumentSkeleton } from "@/components/chat/document-skeleton";
|
||||
import {
|
||||
ClockRewind,
|
||||
CopyIcon,
|
||||
|
|
@ -9,8 +9,8 @@ import {
|
|||
PenIcon,
|
||||
RedoIcon,
|
||||
UndoIcon,
|
||||
} from "@/components/icons";
|
||||
import { Editor } from "@/components/text-editor";
|
||||
} from "@/components/chat/icons";
|
||||
import { Editor } from "@/components/chat/text-editor";
|
||||
import type { Suggestion } from "@/lib/db/schema";
|
||||
import { getSuggestions } from "../actions";
|
||||
|
||||
|
|
@ -69,21 +69,28 @@ export const textArtifact = new Artifact<"text", TextArtifactMetadata>({
|
|||
}
|
||||
|
||||
if (mode === "diff") {
|
||||
const oldContent = getDocumentContentById(currentVersionIndex - 1);
|
||||
const newContent = getDocumentContentById(currentVersionIndex);
|
||||
const selectedContent = getDocumentContentById(currentVersionIndex);
|
||||
const prevContent =
|
||||
currentVersionIndex > 0
|
||||
? getDocumentContentById(currentVersionIndex - 1)
|
||||
: selectedContent;
|
||||
|
||||
return <DiffView newContent={newContent} oldContent={oldContent} />;
|
||||
return (
|
||||
<div className="flex flex-row px-4 py-8 md:px-16 md:py-12 lg:px-20">
|
||||
<DiffView newContent={selectedContent} oldContent={prevContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row px-4 py-8 md:p-20">
|
||||
<div className="flex flex-row px-4 py-8 md:px-16 md:py-12 lg:px-20">
|
||||
<Editor
|
||||
content={content}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
isCurrentVersion={isCurrentVersion}
|
||||
onSaveContent={onSaveContent}
|
||||
status={status}
|
||||
suggestions={metadata ? metadata.suggestions : []}
|
||||
suggestions={isCurrentVersion && metadata ? metadata.suggestions : []}
|
||||
/>
|
||||
|
||||
{metadata?.suggestions && metadata.suggestions.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { smoothStream, streamText } from "ai";
|
||||
import { updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||
import { getArtifactModel } from "@/lib/ai/providers";
|
||||
import { getLanguageModel } from "@/lib/ai/providers";
|
||||
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||
|
||||
export const textDocumentHandler = createDocumentHandler<"text">({
|
||||
kind: "text",
|
||||
onCreateDocument: async ({ title, dataStream }) => {
|
||||
onCreateDocument: async ({ title, dataStream, modelId }) => {
|
||||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamText({
|
||||
model: getArtifactModel(),
|
||||
model: getLanguageModel(modelId),
|
||||
system:
|
||||
"Write about the given topic. Markdown is supported. Use headings wherever appropriate.",
|
||||
experimental_transform: smoothStream({ chunking: "word" }),
|
||||
|
|
@ -17,16 +17,11 @@ export const textDocumentHandler = createDocumentHandler<"text">({
|
|||
});
|
||||
|
||||
for await (const delta of fullStream) {
|
||||
const { type } = delta;
|
||||
|
||||
if (type === "text-delta") {
|
||||
const { text } = delta;
|
||||
|
||||
draftContent += text;
|
||||
|
||||
if (delta.type === "text-delta") {
|
||||
draftContent += delta.text;
|
||||
dataStream.write({
|
||||
type: "data-textDelta",
|
||||
data: text,
|
||||
data: delta.text,
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -34,35 +29,22 @@ export const textDocumentHandler = createDocumentHandler<"text">({
|
|||
|
||||
return draftContent;
|
||||
},
|
||||
onUpdateDocument: async ({ document, description, dataStream }) => {
|
||||
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
|
||||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamText({
|
||||
model: getArtifactModel(),
|
||||
model: getLanguageModel(modelId),
|
||||
system: updateDocumentPrompt(document.content, "text"),
|
||||
experimental_transform: smoothStream({ chunking: "word" }),
|
||||
prompt: description,
|
||||
providerOptions: {
|
||||
openai: {
|
||||
prediction: {
|
||||
type: "content",
|
||||
content: document.content,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for await (const delta of fullStream) {
|
||||
const { type } = delta;
|
||||
|
||||
if (type === "text-delta") {
|
||||
const { text } = delta;
|
||||
|
||||
draftContent += text;
|
||||
|
||||
if (delta.type === "text-delta") {
|
||||
draftContent += delta.text;
|
||||
dataStream.write({
|
||||
type: "data-textDelta",
|
||||
data: text,
|
||||
data: delta.text,
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"style": "radix-maia",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
|
|
|
|||
|
|
@ -55,9 +55,7 @@ export const MessageContent = ({
|
|||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
"flex min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { ComponentProps, ReactNode } from "react";
|
|||
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
|
|
@ -12,54 +11,49 @@ import {
|
|||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
export type ModelSelectorProps = React.ComponentProps<typeof PopoverPrimitive.Root>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
<Popover {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof PopoverTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
<PopoverTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof PopoverContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
title: _title,
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className={cn(
|
||||
"outline! border-none! p-0 outline-border! outline-solid!",
|
||||
"w-[280px] p-0 rounded-xl border border-border/60 bg-card/95 backdrop-blur-xl shadow-[var(--shadow-float)]",
|
||||
className
|
||||
)}
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
{...props}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
</PopoverContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
|
@ -68,13 +62,13 @@ export const ModelSelectorInput = ({
|
|||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
<CommandInput className={cn("h-auto py-2.5 text-[13px]", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
export const ModelSelectorList = ({ className, ...props }: ModelSelectorListProps) => (
|
||||
<CommandList className={cn("max-h-[280px]", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
|
@ -91,8 +85,8 @@ export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
|||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
export const ModelSelectorItem = ({ className, ...props }: ModelSelectorItemProps) => (
|
||||
<CommandItem className={cn("w-full text-[13px] rounded-lg", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
|
@ -182,10 +176,10 @@ export const ModelSelectorLogo = ({
|
|||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3 dark:invert", className)}
|
||||
height={12}
|
||||
className={cn("size-4 dark:invert", className)}
|
||||
height={16}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
width={16}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -197,7 +191,7 @@ export const ModelSelectorLogoGroup = ({
|
|||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:p-px [&>img]:ring-1 [&>img]:ring-border/30",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -13,7 +12,7 @@ import { cjk } from "@streamdown/cjk";
|
|||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
|
|
@ -137,7 +136,7 @@ export const Reasoning = memo(
|
|||
return (
|
||||
<ReasoningContext.Provider value={contextValue}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
className={cn("not-prose", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
|
|
@ -157,7 +156,7 @@ export type ReasoningTriggerProps = ComponentProps<
|
|||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
return <Shimmer className="font-medium" duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
|
|
@ -177,14 +176,13 @@ export const ReasoningTrigger = memo(
|
|||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-[13px] leading-[1.65] transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
|
|
@ -199,29 +197,43 @@ export const ReasoningTrigger = memo(
|
|||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
export type ReasoningContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins} {...props}>
|
||||
{children}
|
||||
</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
({ className, children, ...props }: ReasoningContentProps) => {
|
||||
const { isStreaming, isOpen } = useReasoning();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (isStreaming && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [children, isStreaming]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 animate-in fade-in-0 duration-200 text-muted-foreground/60 [overflow-anchor:none]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="max-h-[200px] overflow-y-auto rounded-lg border border-border/20 bg-muted/30 px-3 py-2 text-[11px] leading-relaxed"
|
||||
ref={scrollRef}
|
||||
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins} {...props}>
|
||||
{children}
|
||||
</Streamdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
|
|
|
|||
|
|
@ -161,9 +161,7 @@ export const ToolOutput = ({
|
|||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
errorText && "bg-destructive/10 text-destructive"
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { unstable_serialize } from "swr/infinite";
|
||||
import { PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import {
|
||||
getChatHistoryPaginationKey,
|
||||
SidebarHistory,
|
||||
} from "@/components/sidebar-history";
|
||||
import { SidebarUserNav } from "@/components/sidebar-user-nav";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import type { AuthUser } from "@/lib/auth";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
export function AppSidebar({ user }: { user: AuthUser | undefined }) {
|
||||
const router = useRouter();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
const deletePromise = fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
toast.promise(deletePromise, {
|
||||
loading: "Deleting all chats...",
|
||||
success: () => {
|
||||
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||
setShowDeleteAllDialog(false);
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
return "All chats deleted successfully";
|
||||
},
|
||||
error: "Failed to delete all chats",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar className="group-data-[side=left]:border-r-0">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Link
|
||||
className="flex flex-row items-center gap-3"
|
||||
href="/"
|
||||
onClick={() => {
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
<span className="cursor-pointer rounded-md px-2 font-semibold text-lg hover:bg-muted">
|
||||
Chatbot
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex flex-row gap-1">
|
||||
{user && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => setShowDeleteAllDialog(true)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" className="hidden md:block">
|
||||
Delete All Chats
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpenMobile(false);
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" className="hidden md:block">
|
||||
New Chat
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarHistory user={user} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setShowDeleteAllDialog}
|
||||
open={showDeleteAllDialog}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete all chats?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete all
|
||||
your chats and remove them from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteAll}>
|
||||
Delete All
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { type Dispatch, memo, type SetStateAction, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { artifactDefinitions, type UIArtifact } from "./artifact";
|
||||
import type { ArtifactActionContext } from "./create-artifact";
|
||||
import { Button } from "./ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type ArtifactActionsProps = {
|
||||
artifact: UIArtifact;
|
||||
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
|
||||
currentVersionIndex: number;
|
||||
isCurrentVersion: boolean;
|
||||
mode: "edit" | "diff";
|
||||
metadata: any;
|
||||
setMetadata: Dispatch<SetStateAction<any>>;
|
||||
};
|
||||
|
||||
function PureArtifactActions({
|
||||
artifact,
|
||||
handleVersionChange,
|
||||
currentVersionIndex,
|
||||
isCurrentVersion,
|
||||
mode,
|
||||
metadata,
|
||||
setMetadata,
|
||||
}: ArtifactActionsProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const artifactDefinition = artifactDefinitions.find(
|
||||
(definition) => definition.kind === artifact.kind
|
||||
);
|
||||
|
||||
if (!artifactDefinition) {
|
||||
throw new Error("Artifact definition not found!");
|
||||
}
|
||||
|
||||
const actionContext: ArtifactActionContext = {
|
||||
content: artifact.content,
|
||||
handleVersionChange,
|
||||
currentVersionIndex,
|
||||
isCurrentVersion,
|
||||
mode,
|
||||
metadata,
|
||||
setMetadata,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
{artifactDefinition.actions.map((action) => (
|
||||
<Tooltip key={action.description}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn("h-fit dark:hover:bg-zinc-700", {
|
||||
"p-2": !action.label,
|
||||
"px-2 py-1.5": action.label,
|
||||
})}
|
||||
disabled={
|
||||
isLoading || artifact.status === "streaming"
|
||||
? true
|
||||
: action.isDisabled
|
||||
? action.isDisabled(actionContext)
|
||||
: false
|
||||
}
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await Promise.resolve(action.onClick(actionContext));
|
||||
} catch (_error) {
|
||||
toast.error("Failed to execute action");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{action.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ArtifactActions = memo(
|
||||
PureArtifactActions,
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.artifact.status !== nextProps.artifact.status) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.artifact.content !== nextProps.artifact.content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
|
@ -1,535 +0,0 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { formatDistance } from "date-fns";
|
||||
import equal from "fast-deep-equal";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useDebounceCallback, useWindowSize } from "usehooks-ts";
|
||||
import { codeArtifact } from "@/artifacts/code/client";
|
||||
import { imageArtifact } from "@/artifacts/image/client";
|
||||
import { sheetArtifact } from "@/artifacts/sheet/client";
|
||||
import { textArtifact } from "@/artifacts/text/client";
|
||||
import { useArtifact } from "@/hooks/use-artifact";
|
||||
import type { Document, Vote } from "@/lib/db/schema";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { ArtifactActions } from "./artifact-actions";
|
||||
import { ArtifactCloseButton } from "./artifact-close-button";
|
||||
import { ArtifactMessages } from "./artifact-messages";
|
||||
import { MultimodalInput } from "./multimodal-input";
|
||||
import { Toolbar } from "./toolbar";
|
||||
import { useSidebar } from "./ui/sidebar";
|
||||
import { VersionFooter } from "./version-footer";
|
||||
import type { VisibilityType } from "./visibility-selector";
|
||||
|
||||
export const artifactDefinitions = [
|
||||
textArtifact,
|
||||
codeArtifact,
|
||||
imageArtifact,
|
||||
sheetArtifact,
|
||||
];
|
||||
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
|
||||
|
||||
export type UIArtifact = {
|
||||
title: string;
|
||||
documentId: string;
|
||||
kind: ArtifactKind;
|
||||
content: string;
|
||||
isVisible: boolean;
|
||||
status: "streaming" | "idle";
|
||||
boundingBox: {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
function PureArtifact({
|
||||
addToolApprovalResponse,
|
||||
chatId,
|
||||
input,
|
||||
setInput,
|
||||
status,
|
||||
stop,
|
||||
attachments,
|
||||
setAttachments,
|
||||
sendMessage,
|
||||
messages,
|
||||
setMessages,
|
||||
regenerate,
|
||||
votes,
|
||||
isReadonly,
|
||||
selectedVisibilityType,
|
||||
selectedModelId,
|
||||
}: {
|
||||
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
||||
chatId: string;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
stop: UseChatHelpers<ChatMessage>["stop"];
|
||||
attachments: Attachment[];
|
||||
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
||||
messages: ChatMessage[];
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
votes: Vote[] | undefined;
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
selectedVisibilityType: VisibilityType;
|
||||
selectedModelId: string;
|
||||
}) {
|
||||
const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
|
||||
|
||||
const {
|
||||
data: documents,
|
||||
isLoading: isDocumentsFetching,
|
||||
mutate: mutateDocuments,
|
||||
} = useSWR<Document[]>(
|
||||
artifact.documentId !== "init" && artifact.status !== "streaming"
|
||||
? `/api/document?id=${artifact.documentId}`
|
||||
: null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<"edit" | "diff">("edit");
|
||||
const [document, setDocument] = useState<Document | null>(null);
|
||||
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
|
||||
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
if (documents && documents.length > 0) {
|
||||
const mostRecentDocument = documents.at(-1);
|
||||
|
||||
if (mostRecentDocument) {
|
||||
setDocument(mostRecentDocument);
|
||||
setCurrentVersionIndex(documents.length - 1);
|
||||
setArtifact((currentArtifact) => ({
|
||||
...currentArtifact,
|
||||
content: mostRecentDocument.content ?? "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [documents, setArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
mutateDocuments();
|
||||
}, [mutateDocuments]);
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const [isContentDirty, setIsContentDirty] = useState(false);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(updatedContent: string) => {
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutate<Document[]>(
|
||||
`/api/document?id=${artifact.documentId}`,
|
||||
async (currentDocuments) => {
|
||||
if (!currentDocuments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentDocument = currentDocuments.at(-1);
|
||||
|
||||
if (!currentDocument || !currentDocument.content) {
|
||||
setIsContentDirty(false);
|
||||
return currentDocuments;
|
||||
}
|
||||
|
||||
if (currentDocument.content !== updatedContent) {
|
||||
await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: artifact.title,
|
||||
content: updatedContent,
|
||||
kind: artifact.kind,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
setIsContentDirty(false);
|
||||
|
||||
const newDocument = {
|
||||
...currentDocument,
|
||||
content: updatedContent,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
return [...currentDocuments, newDocument];
|
||||
}
|
||||
return currentDocuments;
|
||||
},
|
||||
{ revalidate: false }
|
||||
);
|
||||
},
|
||||
[artifact, mutate]
|
||||
);
|
||||
|
||||
const debouncedHandleContentChange = useDebounceCallback(
|
||||
handleContentChange,
|
||||
2000
|
||||
);
|
||||
|
||||
const saveContent = useCallback(
|
||||
(updatedContent: string, debounce: boolean) => {
|
||||
if (document && updatedContent !== document.content) {
|
||||
setIsContentDirty(true);
|
||||
|
||||
if (debounce) {
|
||||
debouncedHandleContentChange(updatedContent);
|
||||
} else {
|
||||
handleContentChange(updatedContent);
|
||||
}
|
||||
}
|
||||
},
|
||||
[document, debouncedHandleContentChange, handleContentChange]
|
||||
);
|
||||
|
||||
function getDocumentContentById(index: number) {
|
||||
if (!documents) {
|
||||
return "";
|
||||
}
|
||||
if (!documents[index]) {
|
||||
return "";
|
||||
}
|
||||
return documents[index].content ?? "";
|
||||
}
|
||||
|
||||
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
|
||||
if (!documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "latest") {
|
||||
setCurrentVersionIndex(documents.length - 1);
|
||||
setMode("edit");
|
||||
}
|
||||
|
||||
if (type === "toggle") {
|
||||
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
|
||||
}
|
||||
|
||||
if (type === "prev") {
|
||||
if (currentVersionIndex > 0) {
|
||||
setCurrentVersionIndex((index) => index - 1);
|
||||
}
|
||||
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
|
||||
setCurrentVersionIndex((index) => index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const [isToolbarVisible, setIsToolbarVisible] = useState(false);
|
||||
|
||||
/*
|
||||
* NOTE: if there are no documents, or if
|
||||
* the documents are being fetched, then
|
||||
* we mark it as the current version.
|
||||
*/
|
||||
|
||||
const isCurrentVersion =
|
||||
documents && documents.length > 0
|
||||
? currentVersionIndex === documents.length - 1
|
||||
: true;
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const isMobile = windowWidth ? windowWidth < 768 : false;
|
||||
|
||||
const artifactDefinition = artifactDefinitions.find(
|
||||
(definition) => definition.kind === artifact.kind
|
||||
);
|
||||
|
||||
if (!artifactDefinition) {
|
||||
throw new Error("Artifact definition not found!");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
|
||||
artifactDefinition.initialize({
|
||||
documentId: artifact.documentId,
|
||||
setMetadata,
|
||||
});
|
||||
}
|
||||
}, [artifact.documentId, artifactDefinition, setMetadata]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{artifact.isVisible && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
|
||||
data-testid="artifact"
|
||||
exit={{ opacity: 0, transition: { delay: 0.4 } }}
|
||||
initial={{ opacity: 1 }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
animate={{ width: windowWidth, right: 0 }}
|
||||
className="fixed h-dvh bg-background"
|
||||
exit={{
|
||||
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||
right: 0,
|
||||
}}
|
||||
initial={{
|
||||
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
delay: 0.1,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
},
|
||||
}}
|
||||
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0 },
|
||||
}}
|
||||
initial={{ opacity: 0, x: 10, scale: 1 }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{!isCurrentVersion && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute top-0 left-0 z-50 h-dvh w-[400px] bg-zinc-900/50"
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex h-full flex-col items-center justify-between">
|
||||
<ArtifactMessages
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
artifactStatus={artifact.status}
|
||||
chatId={chatId}
|
||||
isReadonly={isReadonly}
|
||||
messages={messages}
|
||||
regenerate={regenerate}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
votes={votes}
|
||||
/>
|
||||
|
||||
<div className="relative flex w-full flex-row items-end gap-2 px-4 pb-4">
|
||||
<MultimodalInput
|
||||
attachments={attachments}
|
||||
chatId={chatId}
|
||||
className="bg-background dark:bg-muted"
|
||||
input={input}
|
||||
messages={messages}
|
||||
selectedModelId={selectedModelId}
|
||||
selectedVisibilityType={selectedVisibilityType}
|
||||
sendMessage={sendMessage}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
animate={
|
||||
isMobile
|
||||
? {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: windowHeight,
|
||||
width: windowWidth ? windowWidth : "calc(100dvw)",
|
||||
borderRadius: 0,
|
||||
transition: {
|
||||
delay: 0,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.8,
|
||||
},
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
x: 400,
|
||||
y: 0,
|
||||
height: windowHeight,
|
||||
width: windowWidth
|
||||
? windowWidth - 400
|
||||
: "calc(100dvw-400px)",
|
||||
borderRadius: 0,
|
||||
transition: {
|
||||
delay: 0,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.8,
|
||||
},
|
||||
}
|
||||
}
|
||||
className="fixed flex h-dvh flex-col overflow-y-scroll border-zinc-200 bg-background md:border-l dark:border-zinc-700 dark:bg-muted"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.5,
|
||||
transition: {
|
||||
delay: 0.1,
|
||||
type: "spring",
|
||||
stiffness: 600,
|
||||
damping: 30,
|
||||
},
|
||||
}}
|
||||
initial={
|
||||
isMobile
|
||||
? {
|
||||
opacity: 1,
|
||||
x: artifact.boundingBox.left,
|
||||
y: artifact.boundingBox.top,
|
||||
height: artifact.boundingBox.height,
|
||||
width: artifact.boundingBox.width,
|
||||
borderRadius: 50,
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
x: artifact.boundingBox.left,
|
||||
y: artifact.boundingBox.top,
|
||||
height: artifact.boundingBox.height,
|
||||
width: artifact.boundingBox.width,
|
||||
borderRadius: 50,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-start justify-between p-2">
|
||||
<div className="flex flex-row items-start gap-4">
|
||||
<ArtifactCloseButton />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="font-medium">{artifact.title}</div>
|
||||
|
||||
{isContentDirty ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Saving changes...
|
||||
</div>
|
||||
) : document ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{`Updated ${formatDistance(
|
||||
new Date(document.createdAt),
|
||||
new Date(),
|
||||
{
|
||||
addSuffix: true,
|
||||
}
|
||||
)}`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 h-3 w-32 animate-pulse rounded-md bg-muted-foreground/20" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtifactActions
|
||||
artifact={artifact}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
handleVersionChange={handleVersionChange}
|
||||
isCurrentVersion={isCurrentVersion}
|
||||
metadata={metadata}
|
||||
mode={mode}
|
||||
setMetadata={setMetadata}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-full max-w-full! items-center overflow-y-scroll bg-background dark:bg-muted">
|
||||
<artifactDefinition.content
|
||||
content={
|
||||
isCurrentVersion
|
||||
? artifact.content
|
||||
: getDocumentContentById(currentVersionIndex)
|
||||
}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
getDocumentContentById={getDocumentContentById}
|
||||
isCurrentVersion={isCurrentVersion}
|
||||
isInline={false}
|
||||
isLoading={isDocumentsFetching && !artifact.content}
|
||||
metadata={metadata}
|
||||
mode={mode}
|
||||
onSaveContent={saveContent}
|
||||
setMetadata={setMetadata}
|
||||
status={artifact.status}
|
||||
suggestions={[]}
|
||||
title={artifact.title}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCurrentVersion && (
|
||||
<Toolbar
|
||||
artifactKind={artifact.kind}
|
||||
isToolbarVisible={isToolbarVisible}
|
||||
sendMessage={sendMessage}
|
||||
setIsToolbarVisible={setIsToolbarVisible}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isCurrentVersion && (
|
||||
<VersionFooter
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
documents={documents}
|
||||
handleVersionChange={handleVersionChange}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
|
||||
if (prevProps.status !== nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.votes, nextProps.votes)) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.input !== nextProps.input) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.messages, nextProps.messages.length)) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import { SidebarToggle } from "@/components/sidebar-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusIcon, VercelIcon } from "./icons";
|
||||
import { useSidebar } from "./ui/sidebar";
|
||||
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
|
||||
|
||||
function PureChatHeader({
|
||||
chatId,
|
||||
selectedVisibilityType,
|
||||
isReadonly,
|
||||
}: {
|
||||
chatId: string;
|
||||
selectedVisibilityType: VisibilityType;
|
||||
isReadonly: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { open } = useSidebar();
|
||||
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 flex items-center gap-2 bg-background px-2 py-1.5 md:px-2">
|
||||
<SidebarToggle />
|
||||
|
||||
{(!open || windowWidth < 768) && (
|
||||
<Button
|
||||
className="order-2 ml-auto md:order-1 md:ml-0"
|
||||
onClick={() => {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="md:sr-only">New Chat</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isReadonly && (
|
||||
<VisibilitySelector
|
||||
chatId={chatId}
|
||||
className="order-1 md:order-2"
|
||||
selectedVisibilityType={selectedVisibilityType}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="order-3 hidden bg-zinc-900 px-2 text-zinc-50 hover:bg-zinc-800 md:ml-auto md:flex md:h-fit dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
<Link
|
||||
href={"https://vercel.com/templates/next.js/chatbot"}
|
||||
rel="noreferrer"
|
||||
target="_noblank"
|
||||
>
|
||||
<VercelIcon size={16} />
|
||||
Deploy with Vercel
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.chatId === nextProps.chatId &&
|
||||
prevProps.selectedVisibilityType === nextProps.selectedVisibilityType &&
|
||||
prevProps.isReadonly === nextProps.isReadonly
|
||||
);
|
||||
});
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { unstable_serialize } from "swr/infinite";
|
||||
import { ChatHeader } from "@/components/chat-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useArtifactSelector } from "@/hooks/use-artifact";
|
||||
import { useAutoResume } from "@/hooks/use-auto-resume";
|
||||
import { useChatVisibility } from "@/hooks/use-chat-visibility";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import { ChatbotError } from "@/lib/errors";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||||
import { Artifact } from "./artifact";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { Messages } from "./messages";
|
||||
import { MultimodalInput } from "./multimodal-input";
|
||||
import { getChatHistoryPaginationKey } from "./sidebar-history";
|
||||
import { toast } from "./toast";
|
||||
import type { VisibilityType } from "./visibility-selector";
|
||||
|
||||
export function Chat({
|
||||
id,
|
||||
initialMessages,
|
||||
initialChatModel,
|
||||
initialVisibilityType,
|
||||
isReadonly,
|
||||
autoResume,
|
||||
}: {
|
||||
id: string;
|
||||
initialMessages: ChatMessage[];
|
||||
initialChatModel: string;
|
||||
initialVisibilityType: VisibilityType;
|
||||
isReadonly: boolean;
|
||||
autoResume: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const { visibilityType } = useChatVisibility({
|
||||
chatId: id,
|
||||
initialVisibilityType,
|
||||
});
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
// Handle browser back/forward navigation
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
// When user navigates back/forward, refresh to sync with URL
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [router]);
|
||||
const { setDataStream } = useDataStream();
|
||||
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
|
||||
const [currentModelId, setCurrentModelId] = useState(initialChatModel);
|
||||
const currentModelIdRef = useRef(currentModelId);
|
||||
|
||||
useEffect(() => {
|
||||
currentModelIdRef.current = currentModelId;
|
||||
}, [currentModelId]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
regenerate,
|
||||
resumeStream,
|
||||
addToolApprovalResponse,
|
||||
} = useChat<ChatMessage>({
|
||||
id,
|
||||
messages: initialMessages,
|
||||
generateId: generateUUID,
|
||||
sendAutomaticallyWhen: ({ messages: currentMessages }) => {
|
||||
const lastMessage = currentMessages.at(-1);
|
||||
const shouldContinue =
|
||||
lastMessage?.parts?.some(
|
||||
(part) =>
|
||||
"state" in part &&
|
||||
part.state === "approval-responded" &&
|
||||
"approval" in part &&
|
||||
(part.approval as { approved?: boolean })?.approved === true
|
||||
) ?? false;
|
||||
return shouldContinue;
|
||||
},
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat`,
|
||||
fetch: fetchWithErrorHandlers,
|
||||
prepareSendMessagesRequest(request) {
|
||||
const lastMessage = request.messages.at(-1);
|
||||
const isToolApprovalContinuation =
|
||||
lastMessage?.role !== "user" ||
|
||||
request.messages.some((msg) =>
|
||||
msg.parts?.some((part) => {
|
||||
const state = (part as { state?: string }).state;
|
||||
return (
|
||||
state === "approval-responded" || state === "output-denied"
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
body: {
|
||||
id: request.id,
|
||||
...(isToolApprovalContinuation
|
||||
? { messages: request.messages }
|
||||
: { message: lastMessage }),
|
||||
selectedChatModel: currentModelIdRef.current,
|
||||
selectedVisibilityType: visibilityType,
|
||||
...request.body,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
onData: (dataPart) => {
|
||||
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
|
||||
},
|
||||
onFinish: () => {
|
||||
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message?.includes("AI Gateway requires a valid credit card")) {
|
||||
setShowCreditCardAlert(true);
|
||||
} else if (error instanceof ChatbotError) {
|
||||
toast({
|
||||
type: "error",
|
||||
description: error.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
type: "error",
|
||||
description: error.message || "Oops, an error occurred!",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.get("query");
|
||||
|
||||
const [hasAppendedQuery, setHasAppendedQuery] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (query && !hasAppendedQuery) {
|
||||
sendMessage({
|
||||
role: "user" as const,
|
||||
parts: [{ type: "text", text: query }],
|
||||
});
|
||||
|
||||
setHasAppendedQuery(true);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/chat/${id}`
|
||||
);
|
||||
}
|
||||
}, [query, sendMessage, hasAppendedQuery, id]);
|
||||
|
||||
const { data: votes } = useSWR<Vote[]>(
|
||||
!isReadonly && messages.length >= 2
|
||||
? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${id}`
|
||||
: null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
|
||||
|
||||
useAutoResume({
|
||||
autoResume,
|
||||
initialMessages,
|
||||
resumeStream,
|
||||
setMessages,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
|
||||
<ChatHeader
|
||||
chatId={id}
|
||||
isReadonly={isReadonly}
|
||||
selectedVisibilityType={initialVisibilityType}
|
||||
/>
|
||||
|
||||
<Messages
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
chatId={id}
|
||||
isArtifactVisible={isArtifactVisible}
|
||||
isReadonly={isReadonly}
|
||||
messages={messages}
|
||||
regenerate={regenerate}
|
||||
selectedModelId={initialChatModel}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
votes={votes}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4">
|
||||
{!isReadonly && (
|
||||
<MultimodalInput
|
||||
attachments={attachments}
|
||||
chatId={id}
|
||||
input={input}
|
||||
messages={messages}
|
||||
onModelChange={setCurrentModelId}
|
||||
selectedModelId={currentModelId}
|
||||
selectedVisibilityType={visibilityType}
|
||||
sendMessage={sendMessage}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Artifact
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
attachments={attachments}
|
||||
chatId={id}
|
||||
input={input}
|
||||
isReadonly={isReadonly}
|
||||
messages={messages}
|
||||
regenerate={regenerate}
|
||||
selectedModelId={currentModelId}
|
||||
selectedVisibilityType={visibilityType}
|
||||
sendMessage={sendMessage}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
votes={votes}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setShowCreditCardAlert}
|
||||
open={showCreditCardAlert}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Activate AI Gateway</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This application requires{" "}
|
||||
{process.env.NODE_ENV === "production" ? "the owner" : "you"} to
|
||||
activate Vercel AI Gateway.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
window.open(
|
||||
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
|
||||
"_blank"
|
||||
);
|
||||
window.location.href = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/`;
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
components/chat/app-sidebar.tsx
Normal file
165
components/chat/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
MessageSquareIcon,
|
||||
PanelLeftIcon,
|
||||
PenSquareIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { User } from "next-auth";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { unstable_serialize } from "swr/infinite";
|
||||
import {
|
||||
getChatHistoryPaginationKey,
|
||||
SidebarHistory,
|
||||
} from "@/components/chat/sidebar-history";
|
||||
import { SidebarUserNav } from "@/components/chat/sidebar-user-nav";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
export function AppSidebar({ user }: { user: User | undefined }) {
|
||||
const router = useRouter();
|
||||
const { setOpenMobile, toggleSidebar } = useSidebar();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
setShowDeleteAllDialog(false);
|
||||
router.replace("/");
|
||||
mutate(unstable_serialize(getChatHistoryPaginationKey), [], {
|
||||
revalidate: false,
|
||||
});
|
||||
|
||||
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
toast.success("All chats deleted");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="pb-0 pt-3">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className="flex flex-row items-center justify-between">
|
||||
<div className="group/logo relative flex items-center justify-center">
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="size-8 !px-0 items-center justify-center group-data-[collapsible=icon]:group-hover/logo:opacity-0"
|
||||
tooltip="Chatbot"
|
||||
>
|
||||
<Link href="/" onClick={() => setOpenMobile(false)}>
|
||||
<MessageSquareIcon className="size-4 text-sidebar-foreground/50" />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="pointer-events-none absolute inset-0 size-8 opacity-0 group-data-[collapsible=icon]:pointer-events-auto group-data-[collapsible=icon]:group-hover/logo:opacity-100"
|
||||
onClick={() => toggleSidebar()}
|
||||
>
|
||||
<PanelLeftIcon className="size-4" />
|
||||
</SidebarMenuButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="hidden md:block" side="right">
|
||||
Open sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarTrigger className="text-sidebar-foreground/60 transition-colors duration-150 hover:text-sidebar-foreground" />
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup className="pt-1">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="h-8 rounded-lg border border-sidebar-border text-[13px] text-sidebar-foreground/70 transition-colors duration-150 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
||||
onClick={() => {
|
||||
setOpenMobile(false);
|
||||
router.push("/");
|
||||
}}
|
||||
tooltip="New Chat"
|
||||
>
|
||||
<PenSquareIcon className="size-4" />
|
||||
<span className="font-medium">New chat</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{user && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="rounded-lg text-sidebar-foreground/40 transition-colors duration-150 hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setShowDeleteAllDialog(true)}
|
||||
tooltip="Delete All Chats"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
<span className="text-[13px]">Delete all</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarHistory user={user} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="border-t border-sidebar-border pt-2 pb-3">
|
||||
{user && <SidebarUserNav user={user} />}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setShowDeleteAllDialog}
|
||||
open={showDeleteAllDialog}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete all chats?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete all
|
||||
your chats and remove them from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteAll}>
|
||||
Delete All
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
components/chat/artifact-actions.tsx
Normal file
119
components/chat/artifact-actions.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { memo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { artifactDefinitions, type UIArtifact } from "./artifact";
|
||||
import type { ArtifactActionContext } from "./create-artifact";
|
||||
|
||||
type ArtifactActionsProps = {
|
||||
artifact: UIArtifact;
|
||||
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
|
||||
currentVersionIndex: number;
|
||||
isCurrentVersion: boolean;
|
||||
mode: "edit" | "diff";
|
||||
metadata: ArtifactActionContext["metadata"];
|
||||
setMetadata: ArtifactActionContext["setMetadata"];
|
||||
};
|
||||
|
||||
function PureArtifactActions({
|
||||
artifact,
|
||||
handleVersionChange,
|
||||
currentVersionIndex,
|
||||
isCurrentVersion,
|
||||
mode,
|
||||
metadata,
|
||||
setMetadata,
|
||||
}: ArtifactActionsProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const artifactDefinition = artifactDefinitions.find(
|
||||
(definition) => definition.kind === artifact.kind
|
||||
);
|
||||
|
||||
if (!artifactDefinition) {
|
||||
throw new Error("Artifact definition not found!");
|
||||
}
|
||||
|
||||
const actionContext: ArtifactActionContext = {
|
||||
content: artifact.content,
|
||||
handleVersionChange,
|
||||
currentVersionIndex,
|
||||
isCurrentVersion,
|
||||
mode,
|
||||
metadata,
|
||||
setMetadata,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
{artifactDefinition.actions.map((action) => {
|
||||
const disabled =
|
||||
isLoading || artifact.status === "streaming"
|
||||
? true
|
||||
: action.isDisabled
|
||||
? action.isDisabled(actionContext)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Tooltip key={action.description}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full p-3 text-muted-foreground transition-all duration-150",
|
||||
"hover:text-foreground",
|
||||
"active:scale-95",
|
||||
"disabled:pointer-events-none disabled:opacity-30",
|
||||
{
|
||||
"text-foreground":
|
||||
mode === "diff" && action.description === "View changes",
|
||||
}
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await Promise.resolve(action.onClick(actionContext));
|
||||
} catch (_error) {
|
||||
toast.error("Failed to execute action");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
{action.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ArtifactActions = memo(
|
||||
PureArtifactActions,
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.artifact.status !== nextProps.artifact.status) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.artifact.content !== nextProps.artifact.content) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.mode !== nextProps.mode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
import { memo } from "react";
|
||||
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
|
||||
import { CrossIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
function PureArtifactCloseButton() {
|
||||
const { setArtifact } = useArtifact();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="h-fit p-2 dark:hover:bg-zinc-700"
|
||||
<button
|
||||
className="group flex size-8 items-center justify-center rounded-lg border border-transparent text-muted-foreground transition-all duration-150 hover:border-border hover:bg-muted hover:text-foreground active:scale-95"
|
||||
data-testid="artifact-close-button"
|
||||
onClick={() => {
|
||||
setArtifact((currentArtifact) =>
|
||||
|
|
@ -20,10 +19,10 @@ function PureArtifactCloseButton() {
|
|||
: { ...initialArtifactData, status: "idle" }
|
||||
);
|
||||
}}
|
||||
variant="outline"
|
||||
type="button"
|
||||
>
|
||||
<CrossIcon size={18} />
|
||||
</Button>
|
||||
<CrossIcon size={16} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
482
components/chat/artifact.tsx
Normal file
482
components/chat/artifact.tsx
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { formatDistance } from "date-fns";
|
||||
import equal from "fast-deep-equal";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import { codeArtifact } from "@/artifacts/code/client";
|
||||
import { imageArtifact } from "@/artifacts/image/client";
|
||||
import { sheetArtifact } from "@/artifacts/sheet/client";
|
||||
import { textArtifact } from "@/artifacts/text/client";
|
||||
import { useArtifact } from "@/hooks/use-artifact";
|
||||
import type { Document, Vote } from "@/lib/db/schema";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { useSidebar } from "../ui/sidebar";
|
||||
import { ArtifactActions } from "./artifact-actions";
|
||||
import { ArtifactCloseButton } from "./artifact-close-button";
|
||||
import { LoaderIcon } from "./icons";
|
||||
import { Toolbar } from "./toolbar";
|
||||
import { VersionFooter } from "./version-footer";
|
||||
import type { VisibilityType } from "./visibility-selector";
|
||||
|
||||
export const artifactDefinitions = [
|
||||
textArtifact,
|
||||
codeArtifact,
|
||||
imageArtifact,
|
||||
sheetArtifact,
|
||||
];
|
||||
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
|
||||
|
||||
export type UIArtifact = {
|
||||
title: string;
|
||||
documentId: string;
|
||||
kind: ArtifactKind;
|
||||
content: string;
|
||||
isVisible: boolean;
|
||||
status: "streaming" | "idle";
|
||||
boundingBox: {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
function PureArtifact({
|
||||
addToolApprovalResponse: _addToolApprovalResponse,
|
||||
chatId: _chatId,
|
||||
input: _input,
|
||||
setInput: _setInput,
|
||||
status,
|
||||
stop,
|
||||
attachments: _attachments,
|
||||
setAttachments: _setAttachments,
|
||||
sendMessage,
|
||||
messages: _messages,
|
||||
setMessages,
|
||||
regenerate: _regenerate,
|
||||
votes: _votes,
|
||||
isReadonly: _isReadonly,
|
||||
selectedVisibilityType: _selectedVisibilityType,
|
||||
selectedModelId: _selectedModelId,
|
||||
}: {
|
||||
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
||||
chatId: string;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
stop: UseChatHelpers<ChatMessage>["stop"];
|
||||
attachments: Attachment[];
|
||||
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
||||
messages: ChatMessage[];
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
votes: Vote[] | undefined;
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
selectedVisibilityType: VisibilityType;
|
||||
selectedModelId: string;
|
||||
}) {
|
||||
const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
|
||||
|
||||
const {
|
||||
data: documents,
|
||||
isLoading: isDocumentsFetching,
|
||||
mutate: mutateDocuments,
|
||||
} = useSWR<Document[]>(
|
||||
artifact.documentId !== "init" && artifact.status !== "streaming"
|
||||
? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`
|
||||
: null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<"edit" | "diff">("edit");
|
||||
const [document, setDocument] = useState<Document | null>(null);
|
||||
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
|
||||
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const artifactContentRef = useRef<HTMLDivElement>(null);
|
||||
const userScrolledArtifact = useRef(false);
|
||||
const [isContentDirty, setIsContentDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact.status !== "streaming") {
|
||||
userScrolledArtifact.current = false;
|
||||
return;
|
||||
}
|
||||
if (userScrolledArtifact.current) {
|
||||
return;
|
||||
}
|
||||
const el = artifactContentRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.scrollTo({ top: el.scrollHeight });
|
||||
}, [artifact.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (documents && documents.length > 0) {
|
||||
const mostRecentDocument = documents.at(-1);
|
||||
|
||||
if (mostRecentDocument) {
|
||||
setDocument(mostRecentDocument);
|
||||
setCurrentVersionIndex(documents.length - 1);
|
||||
if (artifact.status === "streaming" || !isContentDirty) {
|
||||
setArtifact((currentArtifact) => ({
|
||||
...currentArtifact,
|
||||
content: mostRecentDocument.content ?? "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [documents, setArtifact, artifact.status, isContentDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
mutateDocuments();
|
||||
}, [mutateDocuments]);
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(updatedContent: string) => {
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutate<Document[]>(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`,
|
||||
async (currentDocuments) => {
|
||||
if (!currentDocuments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentDocument = currentDocuments.at(-1);
|
||||
|
||||
if (!currentDocument?.content) {
|
||||
setIsContentDirty(false);
|
||||
return currentDocuments;
|
||||
}
|
||||
|
||||
if (currentDocument.content === updatedContent) {
|
||||
setIsContentDirty(false);
|
||||
return currentDocuments;
|
||||
}
|
||||
|
||||
await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: artifact.title,
|
||||
content: updatedContent,
|
||||
kind: artifact.kind,
|
||||
isManualEdit: true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
setIsContentDirty(false);
|
||||
|
||||
return currentDocuments.map((doc, i) =>
|
||||
i === currentDocuments.length - 1
|
||||
? { ...doc, content: updatedContent }
|
||||
: doc
|
||||
);
|
||||
},
|
||||
{ revalidate: false }
|
||||
);
|
||||
},
|
||||
[artifact, mutate]
|
||||
);
|
||||
|
||||
const latestContentRef = useRef<string>("");
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const saveContent = useCallback(
|
||||
(updatedContent: string, debounce: boolean) => {
|
||||
latestContentRef.current = updatedContent;
|
||||
setIsContentDirty(true);
|
||||
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (debounce) {
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
handleContentChange(latestContentRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}, 2000);
|
||||
} else {
|
||||
handleContentChange(updatedContent);
|
||||
}
|
||||
},
|
||||
[handleContentChange]
|
||||
);
|
||||
|
||||
function getDocumentContentById(index: number) {
|
||||
if (!documents) {
|
||||
return "";
|
||||
}
|
||||
if (!documents[index]) {
|
||||
return "";
|
||||
}
|
||||
return documents[index].content ?? "";
|
||||
}
|
||||
|
||||
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
|
||||
if (!documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "latest") {
|
||||
setCurrentVersionIndex(documents.length - 1);
|
||||
setMode("edit");
|
||||
}
|
||||
|
||||
if (type === "toggle") {
|
||||
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
|
||||
}
|
||||
|
||||
if (type === "prev") {
|
||||
if (currentVersionIndex > 0) {
|
||||
setCurrentVersionIndex((index) => index - 1);
|
||||
}
|
||||
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
|
||||
setCurrentVersionIndex((index) => index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const [isToolbarVisible, setIsToolbarVisible] = useState(true);
|
||||
|
||||
const isCurrentVersion =
|
||||
documents && documents.length > 0
|
||||
? currentVersionIndex === documents.length - 1
|
||||
: true;
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const isMobile = windowWidth ? windowWidth < 768 : false;
|
||||
|
||||
const artifactDefinition = artifactDefinitions.find(
|
||||
(definition) => definition.kind === artifact.kind
|
||||
);
|
||||
|
||||
if (!artifactDefinition) {
|
||||
throw new Error("Artifact definition not found!");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
|
||||
artifactDefinition.initialize({
|
||||
documentId: artifact.documentId,
|
||||
setMetadata,
|
||||
});
|
||||
}
|
||||
}, [artifact.documentId, artifactDefinition, setMetadata]);
|
||||
|
||||
if (!artifact.isVisible && !isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="h-dvh w-0 shrink-0 overflow-hidden transition-[width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]"
|
||||
data-testid="artifact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artifact.isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const consoleError =
|
||||
metadata?.outputs
|
||||
?.filter((o: { status: string }) => o.status === "failed")
|
||||
.flatMap((o: { contents: { type: string; value: string }[] }) =>
|
||||
o.contents.filter((c) => c.type === "text").map((c) => c.value)
|
||||
)
|
||||
.join("\n") || undefined;
|
||||
|
||||
const artifactPanel = (
|
||||
<>
|
||||
{sidebarState !== "collapsed" && (
|
||||
<div className="flex h-[calc(3.5rem+1px)] shrink-0 items-center justify-between border-b border-border/50 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ArtifactCloseButton />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="text-sm font-semibold leading-tight tracking-tight">
|
||||
{artifact.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isContentDirty ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="size-1.5 animate-pulse rounded-full bg-amber-500" />
|
||||
Saving...
|
||||
</div>
|
||||
) : document ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{`Updated ${formatDistance(new Date(document.createdAt), new Date(), { addSuffix: true })}`}
|
||||
</div>
|
||||
) : artifact.status === "streaming" ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="animate-spin">
|
||||
<LoaderIcon size={12} />
|
||||
</div>
|
||||
Generating...
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-muted-foreground/10" />
|
||||
)}
|
||||
{documents && documents.length > 1 && (
|
||||
<div className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
v{currentVersionIndex + 1}/{documents.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="relative flex-1 overflow-y-auto bg-background"
|
||||
data-slot="artifact-content"
|
||||
onScroll={() => {
|
||||
const el = artifactContentRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const atBottom =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
userScrolledArtifact.current = !atBottom;
|
||||
}}
|
||||
ref={artifactContentRef}
|
||||
>
|
||||
<artifactDefinition.content
|
||||
content={
|
||||
isCurrentVersion
|
||||
? artifact.content
|
||||
: getDocumentContentById(currentVersionIndex)
|
||||
}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
getDocumentContentById={getDocumentContentById}
|
||||
isCurrentVersion={isCurrentVersion}
|
||||
isInline={false}
|
||||
isLoading={isDocumentsFetching && !artifact.content}
|
||||
metadata={metadata}
|
||||
mode={mode}
|
||||
onSaveContent={saveContent}
|
||||
setMetadata={setMetadata}
|
||||
status={artifact.status}
|
||||
suggestions={[]}
|
||||
title={artifact.title}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{isCurrentVersion && (
|
||||
<Toolbar
|
||||
artifactActions={
|
||||
<ArtifactActions
|
||||
artifact={artifact}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
handleVersionChange={handleVersionChange}
|
||||
isCurrentVersion={isCurrentVersion}
|
||||
metadata={metadata}
|
||||
mode={mode}
|
||||
setMetadata={setMetadata}
|
||||
/>
|
||||
}
|
||||
artifactKind={artifact.kind}
|
||||
consoleError={consoleError}
|
||||
documentId={artifact.documentId}
|
||||
isToolbarVisible={isToolbarVisible}
|
||||
onClose={() => {
|
||||
setArtifact((prev) => ({ ...prev, isVisible: false }));
|
||||
}}
|
||||
sendMessage={sendMessage}
|
||||
setIsToolbarVisible={setIsToolbarVisible}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{!isCurrentVersion && (
|
||||
<VersionFooter
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
documents={documents}
|
||||
handleVersionChange={handleVersionChange}
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: windowHeight,
|
||||
width: "100dvw",
|
||||
borderRadius: 0,
|
||||
}}
|
||||
className="fixed inset-0 z-50 flex h-dvh flex-col overflow-hidden bg-sidebar"
|
||||
data-testid="artifact"
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{
|
||||
opacity: 1,
|
||||
x: artifact.boundingBox.left,
|
||||
y: artifact.boundingBox.top,
|
||||
height: artifact.boundingBox.height,
|
||||
width: artifact.boundingBox.width,
|
||||
borderRadius: 50,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
{artifactPanel}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-dvh w-[60%] shrink-0 flex-col overflow-hidden border-l border-border/50 bg-sidebar transition-[width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]"
|
||||
data-testid="artifact"
|
||||
>
|
||||
{artifactPanel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
|
||||
if (prevProps.status !== nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.votes, nextProps.votes)) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.input !== nextProps.input) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.messages.length !== nextProps.messages.length) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import Form from "next/form";
|
||||
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
export function AuthForm({
|
||||
action,
|
||||
|
|
@ -15,40 +15,33 @@ export function AuthForm({
|
|||
defaultEmail?: string;
|
||||
}) {
|
||||
return (
|
||||
<Form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
|
||||
<Form action={action} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email Address
|
||||
<Label className="font-normal text-muted-foreground" htmlFor="email">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
className="bg-muted text-md md:text-sm"
|
||||
className="h-10 rounded-lg border-border/50 bg-muted/50 text-sm transition-colors focus:border-foreground/20 focus:bg-muted"
|
||||
defaultValue={defaultEmail}
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="user@acme.com"
|
||||
placeholder="you@someo.ne"
|
||||
required
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||
htmlFor="password"
|
||||
>
|
||||
<Label className="font-normal text-muted-foreground" htmlFor="password">
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
className="bg-muted text-md md:text-sm"
|
||||
className="h-10 rounded-lg border-border/50 bg-muted/50 text-sm transition-colors focus:border-foreground/20 focus:bg-muted"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
76
components/chat/chat-header.tsx
Normal file
76
components/chat/chat-header.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { VercelIcon } from "./icons";
|
||||
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
|
||||
|
||||
function PureChatHeader({
|
||||
chatId,
|
||||
selectedVisibilityType,
|
||||
isReadonly,
|
||||
}: {
|
||||
chatId: string;
|
||||
selectedVisibilityType: VisibilityType;
|
||||
isReadonly: boolean;
|
||||
}) {
|
||||
const { state, toggleSidebar, isMobile } = useSidebar();
|
||||
|
||||
if (state === "collapsed" && !isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 flex h-14 items-center gap-2 bg-sidebar px-3">
|
||||
<Button
|
||||
className="md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<PanelLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
className="flex size-8 items-center justify-center rounded-lg md:hidden"
|
||||
href="https://vercel.com/templates/next.js/chatbot"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<VercelIcon size={14} />
|
||||
</Link>
|
||||
|
||||
{!isReadonly && (
|
||||
<VisibilitySelector
|
||||
chatId={chatId}
|
||||
selectedVisibilityType={selectedVisibilityType}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="hidden rounded-lg bg-foreground px-4 text-background hover:bg-foreground/90 md:ml-auto md:flex"
|
||||
>
|
||||
<Link
|
||||
href="https://vercel.com/templates/next.js/chatbot"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<VercelIcon size={16} />
|
||||
Deploy with Vercel
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.chatId === nextProps.chatId &&
|
||||
prevProps.selectedVisibilityType === nextProps.selectedVisibilityType &&
|
||||
prevProps.isReadonly === nextProps.isReadonly
|
||||
);
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ type EditorProps = {
|
|||
function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
const userScrolledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && !editorRef.current) {
|
||||
|
|
@ -40,8 +41,6 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
|
|||
editorRef.current = null;
|
||||
}
|
||||
};
|
||||
// NOTE: we only want to run this effect once
|
||||
// eslint-disable-next-line
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -59,17 +58,44 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
|
|||
}
|
||||
});
|
||||
|
||||
const scrollListener = EditorView.domEventHandlers({
|
||||
scroll() {
|
||||
if (status !== "streaming") {
|
||||
return;
|
||||
}
|
||||
const dom = editorRef.current?.scrollDOM;
|
||||
if (!dom) {
|
||||
return;
|
||||
}
|
||||
const atBottom =
|
||||
dom.scrollHeight - dom.scrollTop - dom.clientHeight < 40;
|
||||
userScrolledRef.current = !atBottom;
|
||||
},
|
||||
});
|
||||
|
||||
const currentSelection = editorRef.current.state.selection;
|
||||
|
||||
const newState = EditorState.create({
|
||||
doc: editorRef.current.state.doc,
|
||||
extensions: [basicSetup, python(), oneDark, updateListener],
|
||||
extensions: [
|
||||
basicSetup,
|
||||
python(),
|
||||
oneDark,
|
||||
updateListener,
|
||||
scrollListener,
|
||||
],
|
||||
selection: currentSelection,
|
||||
});
|
||||
|
||||
editorRef.current.setState(newState);
|
||||
}
|
||||
}, [onSaveContent]);
|
||||
}, [onSaveContent, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "streaming") {
|
||||
userScrolledRef.current = false;
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && content) {
|
||||
|
|
@ -86,36 +112,43 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
|
|||
});
|
||||
|
||||
editorRef.current.dispatch(transaction);
|
||||
|
||||
if (status === "streaming" && !userScrolledRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
const dom = editorRef.current?.scrollDOM;
|
||||
if (dom) {
|
||||
dom.scrollTo({ top: dom.scrollHeight });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [content, status]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="not-prose relative w-full pb-[calc(80dvh)] text-sm"
|
||||
className="not-prose relative w-full min-h-[300px] pb-[calc(50dvh)]"
|
||||
ref={containerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
|
||||
if (prevProps.suggestions !== nextProps.suggestions) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
|
||||
return false;
|
||||
}
|
||||
export const CodeEditor = memo(PureCodeEditor, (prevProps, nextProps) => {
|
||||
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prevProps.content !== nextProps.content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
if (prevProps.status !== nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export const CodeEditor = memo(PureCodeEditor, areEqual);
|
||||
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
|
@ -8,9 +8,9 @@ import {
|
|||
} from "react";
|
||||
import { useArtifactSelector } from "@/hooks/use-artifact";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
import { CrossSmallIcon, TerminalWindowIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
import { Spinner } from "./ui/spinner";
|
||||
|
||||
export type ConsoleOutputContent = {
|
||||
type: "text" | "image";
|
||||
|
|
@ -31,7 +31,6 @@ type ConsoleProps = {
|
|||
export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||
const [height, setHeight] = useState<number>(300);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const consoleEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
|
||||
|
||||
|
|
@ -67,9 +66,13 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
|||
};
|
||||
}, [resize, stopResizing]);
|
||||
|
||||
const consoleContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
if (consoleOutputs.length > 0) {
|
||||
consoleContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
}, [consoleOutputs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isArtifactVisible) {
|
||||
|
|
@ -101,38 +104,35 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
|||
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-scroll border-zinc-200 border-t bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900",
|
||||
{
|
||||
"select-none": isResizing,
|
||||
}
|
||||
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-auto border-t border-border/50 bg-background",
|
||||
{ "select-none": isResizing }
|
||||
)}
|
||||
ref={consoleContainerRef}
|
||||
style={{ height }}
|
||||
>
|
||||
<div className="sticky top-0 z-50 flex h-fit w-full flex-row items-center justify-between border-zinc-200 border-b bg-muted px-2 py-1 dark:border-zinc-700">
|
||||
<div className="flex flex-row items-center gap-3 pl-2 text-sm text-zinc-800 dark:text-zinc-50">
|
||||
<div className="text-muted-foreground">
|
||||
<TerminalWindowIcon />
|
||||
</div>
|
||||
<div>Console</div>
|
||||
<div className="sticky top-0 z-50 flex h-10 w-full items-center justify-between border-b border-border/50 bg-background px-3">
|
||||
<div className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
|
||||
<TerminalWindowIcon />
|
||||
<span>Console</span>
|
||||
</div>
|
||||
<Button
|
||||
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
className="size-7 text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={() => setConsoleOutputs([])}
|
||||
size="icon"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<CrossSmallIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{consoleOutputs.map((consoleOutput, index) => (
|
||||
<div className="bg-background">
|
||||
{[...consoleOutputs].reverse().map((consoleOutput, index) => (
|
||||
<div
|
||||
className="flex flex-row border-zinc-200 border-b bg-zinc-50 px-4 py-2 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-900"
|
||||
className="flex border-b border-border/30 px-4 py-2.5 font-mono text-[12px] leading-relaxed"
|
||||
key={consoleOutput.id}
|
||||
>
|
||||
<div
|
||||
className={cn("w-12 shrink-0", {
|
||||
className={cn("w-10 shrink-0 tabular-nums", {
|
||||
"text-muted-foreground": [
|
||||
"in_progress",
|
||||
"loading_packages",
|
||||
|
|
@ -141,16 +141,14 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
|||
"text-red-400": consoleOutput.status === "failed",
|
||||
})}
|
||||
>
|
||||
[{index + 1}]
|
||||
[{consoleOutputs.length - index}]
|
||||
</div>
|
||||
{["in_progress", "loading_packages"].includes(
|
||||
consoleOutput.status
|
||||
) ? (
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="mt-0.5 mb-auto size-fit self-center">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner className="size-3.5" />
|
||||
<span className="text-muted-foreground">
|
||||
{consoleOutput.status === "in_progress"
|
||||
? "Initializing..."
|
||||
: consoleOutput.status === "loading_packages"
|
||||
|
|
@ -158,23 +156,25 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
|||
content.type === "text" ? content.value : null
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-2 overflow-x-scroll text-zinc-900 dark:text-zinc-50">
|
||||
<div className="no-scrollbar flex w-full min-w-0 flex-col gap-2 overflow-x-auto text-foreground">
|
||||
{consoleOutput.contents.map((content) =>
|
||||
content.type === "image" ? (
|
||||
<picture key={`${consoleOutput.id}-${content.value}`}>
|
||||
<picture
|
||||
key={`${consoleOutput.id}-img-${content.value.slice(0, 32)}`}
|
||||
>
|
||||
<img
|
||||
alt="output"
|
||||
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
|
||||
className="max-w-full rounded-md"
|
||||
src={content.value}
|
||||
/>
|
||||
</picture>
|
||||
) : (
|
||||
<div
|
||||
className="w-full whitespace-pre-line break-words"
|
||||
key={`${consoleOutput.id}-${content.value}`}
|
||||
key={`${consoleOutput.id}-txt-${content.value.slice(0, 32)}`}
|
||||
>
|
||||
{content.value}
|
||||
</div>
|
||||
|
|
@ -184,7 +184,6 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={consoleEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -23,7 +23,6 @@ export function DataStreamHandler() {
|
|||
setDataStream([]);
|
||||
|
||||
for (const delta of newDeltas) {
|
||||
// Handle chat title updates
|
||||
if (delta.type === "data-chat-title") {
|
||||
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||
continue;
|
||||
|
|
@ -11,8 +11,8 @@ import { EditorState } from "prosemirror-state";
|
|||
import { EditorView } from "prosemirror-view";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { DiffType, diffEditor } from "@/lib/editor/diff";
|
||||
|
||||
const diffSchema = new Schema({
|
||||
|
|
@ -27,11 +27,11 @@ const diffSchema = new Schema({
|
|||
switch (mark.attrs.type) {
|
||||
case DiffType.Inserted:
|
||||
className =
|
||||
"bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300";
|
||||
"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 rounded-sm px-0.5 -mx-0.5";
|
||||
break;
|
||||
case DiffType.Deleted:
|
||||
className =
|
||||
"bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300";
|
||||
"bg-red-500/15 line-through text-red-600 dark:text-red-400 rounded-sm px-0.5 -mx-0.5 opacity-70";
|
||||
break;
|
||||
default:
|
||||
className = "";
|
||||
|
|
@ -60,10 +60,10 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
|
|||
const parser = DOMParser.fromSchema(diffSchema);
|
||||
|
||||
const oldHtmlContent = renderToString(
|
||||
<Streamdown>{oldContent}</Streamdown>
|
||||
<MessageResponse>{oldContent}</MessageResponse>
|
||||
);
|
||||
const newHtmlContent = renderToString(
|
||||
<Streamdown>{newContent}</Streamdown>
|
||||
<MessageResponse>{newContent}</MessageResponse>
|
||||
);
|
||||
|
||||
const oldContainer = document.createElement("div");
|
||||
|
|
@ -86,6 +86,15 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
|
|||
state,
|
||||
editable: () => false,
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const firstDiff = editorRef.current?.querySelector(
|
||||
"[class*='bg-emerald'], [class*='bg-red']"
|
||||
);
|
||||
if (firstDiff) {
|
||||
firstDiff.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
@ -96,5 +105,10 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
|
|||
};
|
||||
}, [oldContent, newContent]);
|
||||
|
||||
return <div className="diff-editor" ref={editorRef} />;
|
||||
return (
|
||||
<div
|
||||
className="diff-editor prose dark:prose-invert prose-neutral relative max-w-none"
|
||||
ref={editorRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -15,21 +15,33 @@ import type { Document } from "@/lib/db/schema";
|
|||
import { cn, fetcher } from "@/lib/utils";
|
||||
import type { ArtifactKind, UIArtifact } from "./artifact";
|
||||
import { CodeEditor } from "./code-editor";
|
||||
import { DocumentToolCall, DocumentToolResult } from "./document";
|
||||
import { InlineDocumentSkeleton } from "./document-skeleton";
|
||||
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from "./icons";
|
||||
import {
|
||||
CodeIcon,
|
||||
FileIcon,
|
||||
FullscreenIcon,
|
||||
ImageIcon,
|
||||
LoaderIcon,
|
||||
} from "./icons";
|
||||
import { ImageEditor } from "./image-editor";
|
||||
import { SpreadsheetEditor } from "./sheet-editor";
|
||||
import { Editor } from "./text-editor";
|
||||
|
||||
type DocumentToolOutput = {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: ArtifactKind;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type DocumentPreviewProps = {
|
||||
isReadonly: boolean;
|
||||
result?: any;
|
||||
args?: any;
|
||||
result?: Partial<DocumentToolOutput>;
|
||||
args?: Partial<DocumentToolOutput> & { isUpdate?: boolean };
|
||||
};
|
||||
|
||||
export function DocumentPreview({
|
||||
isReadonly,
|
||||
isReadonly: _isReadonly,
|
||||
result,
|
||||
args,
|
||||
}: DocumentPreviewProps) {
|
||||
|
|
@ -37,7 +49,12 @@ export function DocumentPreview({
|
|||
|
||||
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
|
||||
Document[]
|
||||
>(result ? `/api/document?id=${result.id}` : null, fetcher);
|
||||
>(
|
||||
result
|
||||
? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${result.id}`
|
||||
: null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const previewDocument = useMemo(() => documents?.[0], [documents]);
|
||||
const hitboxRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -58,30 +75,28 @@ export function DocumentPreview({
|
|||
}
|
||||
}, [artifact.documentId, setArtifact]);
|
||||
|
||||
if (artifact.isVisible) {
|
||||
if (result) {
|
||||
return (
|
||||
<DocumentToolResult
|
||||
isReadonly={isReadonly}
|
||||
result={{ id: result.id, title: result.title, kind: result.kind }}
|
||||
type="create"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (args) {
|
||||
return (
|
||||
<DocumentToolCall
|
||||
args={{ title: args.title, kind: args.kind }}
|
||||
isReadonly={isReadonly}
|
||||
type="create"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDocumentsFetching) {
|
||||
return <LoadingSkeleton artifactKind={result.kind ?? args.kind} />;
|
||||
const kind = result?.kind ?? args?.kind ?? artifact.kind;
|
||||
const title = result?.title ?? args?.title ?? artifact.title;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[450px]">
|
||||
{title ? (
|
||||
<DocumentHeader isStreaming={true} kind={kind} title={title} />
|
||||
) : (
|
||||
<div className="flex flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 border-border/50 px-4 py-3 dark:bg-muted">
|
||||
<div className="flex flex-row items-center gap-2.5">
|
||||
<div className="size-3.5 animate-pulse rounded bg-muted-foreground/15" />
|
||||
<div className="h-3.5 w-24 animate-pulse rounded bg-muted-foreground/15" />
|
||||
</div>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[257px] overflow-hidden rounded-b-2xl border border-t-0 border-border/50 bg-muted p-6">
|
||||
<InlineDocumentSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const document: Document | null = previewDocument
|
||||
|
|
@ -120,23 +135,19 @@ export function DocumentPreview({
|
|||
|
||||
const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
|
||||
<div className="w-full max-w-[450px]">
|
||||
<div className="flex h-[57px] flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 p-4 dark:border-zinc-700 dark:bg-muted">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="text-muted-foreground">
|
||||
<div className="size-4 animate-pulse rounded-md bg-muted-foreground/20" />
|
||||
</div>
|
||||
<div className="h-4 w-24 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
</div>
|
||||
<div>
|
||||
<FullscreenIcon />
|
||||
<div className="flex flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 border-border/50 px-4 py-3 dark:bg-muted">
|
||||
<div className="flex flex-row items-center gap-2.5">
|
||||
<div className="size-3.5 animate-pulse rounded bg-muted-foreground/15" />
|
||||
<div className="h-3.5 w-24 animate-pulse rounded bg-muted-foreground/15" />
|
||||
</div>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
{artifactKind === "image" ? (
|
||||
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted dark:border-zinc-700">
|
||||
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/20" />
|
||||
<div className="overflow-hidden rounded-b-2xl border border-t-0 border-border/50 bg-muted">
|
||||
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/10" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted p-8 pt-4 dark:border-zinc-700">
|
||||
<div className="h-[257px] overflow-hidden rounded-b-2xl border border-t-0 border-border/50 bg-muted p-6">
|
||||
<InlineDocumentSkeleton />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -149,7 +160,7 @@ const PureHitboxLayer = ({
|
|||
setArtifact,
|
||||
}: {
|
||||
hitboxRef: React.RefObject<HTMLDivElement>;
|
||||
result: any;
|
||||
result?: Partial<DocumentToolOutput>;
|
||||
setArtifact: (
|
||||
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)
|
||||
) => void;
|
||||
|
|
@ -158,23 +169,19 @@ const PureHitboxLayer = ({
|
|||
(event: MouseEvent<HTMLElement>) => {
|
||||
const boundingBox = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
setArtifact((artifact) =>
|
||||
artifact.status === "streaming"
|
||||
? { ...artifact, isVisible: true }
|
||||
: {
|
||||
...artifact,
|
||||
title: result.title,
|
||||
documentId: result.id,
|
||||
kind: result.kind,
|
||||
isVisible: true,
|
||||
boundingBox: {
|
||||
left: boundingBox.x,
|
||||
top: boundingBox.y,
|
||||
width: boundingBox.width,
|
||||
height: boundingBox.height,
|
||||
},
|
||||
}
|
||||
);
|
||||
setArtifact((artifact) => ({
|
||||
...artifact,
|
||||
...(result?.id && { documentId: result.id }),
|
||||
...(result?.title && { title: result.title }),
|
||||
...(result?.kind && { kind: result.kind }),
|
||||
isVisible: true,
|
||||
boundingBox: {
|
||||
left: boundingBox.x,
|
||||
top: boundingBox.y,
|
||||
width: boundingBox.width,
|
||||
height: boundingBox.height,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setArtifact, result]
|
||||
);
|
||||
|
|
@ -188,7 +195,7 @@ const PureHitboxLayer = ({
|
|||
role="presentation"
|
||||
>
|
||||
<div className="flex w-full items-center justify-end p-4">
|
||||
<div className="absolute top-[13px] right-[9px] rounded-md p-2 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
<div className="absolute top-[13px] right-[9px] rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
<FullscreenIcon />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -212,20 +219,22 @@ const PureDocumentHeader = ({
|
|||
kind: ArtifactKind;
|
||||
isStreaming: boolean;
|
||||
}) => (
|
||||
<div className="flex flex-row items-start justify-between gap-2 rounded-t-2xl border border-b-0 p-4 sm:items-center dark:border-zinc-700 dark:bg-muted">
|
||||
<div className="flex flex-row items-start gap-3 sm:items-center">
|
||||
<div className="flex flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 border-border/50 px-4 py-3 dark:bg-muted">
|
||||
<div className="flex flex-row items-center gap-2.5">
|
||||
<div className="text-muted-foreground">
|
||||
{isStreaming ? (
|
||||
<div className="animate-spin">
|
||||
<LoaderIcon />
|
||||
<LoaderIcon size={14} />
|
||||
</div>
|
||||
) : kind === "image" ? (
|
||||
<ImageIcon />
|
||||
<ImageIcon size={14} />
|
||||
) : kind === "code" ? (
|
||||
<CodeIcon size={14} />
|
||||
) : (
|
||||
<FileIcon />
|
||||
<FileIcon size={14} />
|
||||
)}
|
||||
</div>
|
||||
<div className="-translate-y-1 font-medium sm:translate-y-0">{title}</div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
</div>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
|
|
@ -246,9 +255,9 @@ const DocumentContent = ({ document }: { document: Document }) => {
|
|||
const { artifact } = useArtifact();
|
||||
|
||||
const containerClassName = cn(
|
||||
"h-[257px] overflow-y-scroll rounded-b-2xl border border-t-0 dark:border-zinc-700 dark:bg-muted",
|
||||
"h-[257px] overflow-hidden rounded-b-2xl border border-t-0 border-border/50 dark:bg-muted",
|
||||
{
|
||||
"p-4 sm:px-14 sm:py-16": document.kind === "text",
|
||||
"p-4 sm:px-10 sm:py-10": document.kind === "text",
|
||||
"p-0": document.kind === "code",
|
||||
}
|
||||
);
|
||||
|
|
@ -265,7 +274,7 @@ const DocumentContent = ({ document }: { document: Document }) => {
|
|||
const handleSaveContent = () => null;
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<div className={cn(containerClassName, "relative")}>
|
||||
{document.kind === "text" ? (
|
||||
<Editor {...commonProps} onSaveContent={handleSaveContent} />
|
||||
) : document.kind === "code" ? (
|
||||
|
|
@ -290,6 +299,10 @@ const DocumentContent = ({ document }: { document: Document }) => {
|
|||
title={document.title}
|
||||
/>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-muted to-transparent dark:from-muted" />
|
||||
{document.kind === "code" && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-muted to-transparent dark:from-muted" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
components/chat/document-skeleton.tsx
Normal file
38
components/chat/document-skeleton.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import type { ArtifactKind } from "./artifact";
|
||||
|
||||
export const DocumentSkeleton = ({
|
||||
artifactKind,
|
||||
}: {
|
||||
artifactKind: ArtifactKind;
|
||||
}) => {
|
||||
return artifactKind === "image" ? (
|
||||
<div className="flex h-[calc(100dvh-60px)] w-full flex-col items-center justify-center gap-4">
|
||||
<div className="size-96 animate-pulse rounded-lg bg-muted-foreground/10" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4 px-4 py-8 md:px-20 md:py-12">
|
||||
<div className="h-8 w-2/5 animate-pulse rounded-md bg-muted-foreground/10" />
|
||||
<div className="h-4 w-full animate-pulse rounded-md bg-muted-foreground/8" />
|
||||
<div className="h-4 w-full animate-pulse rounded-md bg-muted-foreground/8" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded-md bg-muted-foreground/8" />
|
||||
<div className="h-4 w-0 rounded-md" />
|
||||
<div className="h-6 w-1/3 animate-pulse rounded-md bg-muted-foreground/10" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded-md bg-muted-foreground/8" />
|
||||
<div className="h-4 w-2/3 animate-pulse rounded-md bg-muted-foreground/8" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InlineDocumentSkeleton = () => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="h-3.5 w-48 animate-pulse rounded bg-muted-foreground/10" />
|
||||
<div className="h-3.5 w-3/4 animate-pulse rounded bg-muted-foreground/8" />
|
||||
<div className="h-3.5 w-1/2 animate-pulse rounded bg-muted-foreground/8" />
|
||||
<div className="h-3.5 w-64 animate-pulse rounded bg-muted-foreground/8" />
|
||||
<div className="h-3.5 w-40 animate-pulse rounded bg-muted-foreground/8" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -37,7 +37,7 @@ function PureDocumentToolResult({
|
|||
|
||||
return (
|
||||
<button
|
||||
className="flex w-fit cursor-pointer flex-row items-start gap-3 rounded-xl border bg-background px-3 py-2"
|
||||
className="flex w-fit cursor-pointer flex-row items-center gap-2 rounded-xl border bg-background px-3 py-2"
|
||||
onClick={(event) => {
|
||||
if (isReadonly) {
|
||||
toast.error(
|
||||
|
|
@ -67,7 +67,7 @@ function PureDocumentToolResult({
|
|||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
<div className="text-muted-foreground">
|
||||
{type === "create" ? (
|
||||
<FileIcon />
|
||||
) : type === "update" ? (
|
||||
|
|
@ -88,9 +88,9 @@ export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
|
|||
type DocumentToolCallProps = {
|
||||
type: "create" | "update" | "request-suggestions";
|
||||
args:
|
||||
| { title: string; kind: ArtifactKind } // for create
|
||||
| { id: string; description: string } // for update
|
||||
| { documentId: string }; // for request-suggestions
|
||||
| { title: string; kind: ArtifactKind }
|
||||
| { id: string; description: string }
|
||||
| { documentId: string };
|
||||
isReadonly: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ function PureDocumentToolCall({
|
|||
type="button"
|
||||
>
|
||||
<div className="flex flex-row items-start gap-3">
|
||||
<div className="mt-1 text-zinc-500">
|
||||
<div className="mt-1 text-neutral-500">
|
||||
{type === "create" ? (
|
||||
<FileIcon />
|
||||
) : type === "update" ? (
|
||||
24
components/chat/greeting.tsx
Normal file
24
components/chat/greeting.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { motion } from "framer-motion";
|
||||
|
||||
export const Greeting = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center px-4" key="overview">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center font-semibold text-2xl tracking-tight text-foreground md:text-3xl"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.35, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
What can I help with?
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-3 text-center text-muted-foreground/80 text-sm"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.5, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
Ask a question, write code, or explore ideas.
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,7 +8,7 @@ import type { ChatMessage } from "@/lib/types";
|
|||
import {
|
||||
MessageAction as Action,
|
||||
MessageActions as Actions,
|
||||
} from "./ai-elements/message";
|
||||
} from "../ai-elements/message";
|
||||
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
|
||||
|
||||
export function PureMessageActions({
|
||||
|
|
@ -16,13 +16,13 @@ export function PureMessageActions({
|
|||
message,
|
||||
vote,
|
||||
isLoading,
|
||||
setMode,
|
||||
onEdit,
|
||||
}: {
|
||||
chatId: string;
|
||||
message: ChatMessage;
|
||||
vote: Vote | undefined;
|
||||
isLoading: boolean;
|
||||
setMode?: (mode: "view" | "edit") => void;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
|
|
@ -47,22 +47,25 @@ export function PureMessageActions({
|
|||
toast.success("Copied to clipboard!");
|
||||
};
|
||||
|
||||
// User messages get edit (on hover) and copy actions
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<Actions className="-mr-0.5 justify-end">
|
||||
<div className="relative">
|
||||
{setMode && (
|
||||
<Actions className="-mr-0.5 justify-end opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{onEdit && (
|
||||
<Action
|
||||
className="absolute top-0 -left-10 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
|
||||
className="size-7 text-muted-foreground/50 hover:text-foreground"
|
||||
data-testid="message-edit-button"
|
||||
onClick={() => setMode("edit")}
|
||||
onClick={onEdit}
|
||||
tooltip="Edit"
|
||||
>
|
||||
<PencilEditIcon />
|
||||
</Action>
|
||||
)}
|
||||
<Action onClick={handleCopy} tooltip="Copy">
|
||||
<Action
|
||||
className="size-7 text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={handleCopy}
|
||||
tooltip="Copy"
|
||||
>
|
||||
<CopyIcon />
|
||||
</Action>
|
||||
</div>
|
||||
|
|
@ -71,12 +74,17 @@ export function PureMessageActions({
|
|||
}
|
||||
|
||||
return (
|
||||
<Actions className="-ml-0.5">
|
||||
<Action onClick={handleCopy} tooltip="Copy">
|
||||
<Actions className="-ml-0.5 opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
|
||||
<Action
|
||||
className="text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={handleCopy}
|
||||
tooltip="Copy"
|
||||
>
|
||||
<CopyIcon />
|
||||
</Action>
|
||||
|
||||
<Action
|
||||
className="text-muted-foreground/50 hover:text-foreground"
|
||||
data-testid="message-upvote"
|
||||
disabled={vote?.isUpvoted}
|
||||
onClick={() => {
|
||||
|
|
@ -129,6 +137,7 @@ export function PureMessageActions({
|
|||
</Action>
|
||||
|
||||
<Action
|
||||
className="text-muted-foreground/50 hover:text-foreground"
|
||||
data-testid="message-downvote"
|
||||
disabled={vote && !vote.isUpvoted}
|
||||
onClick={() => {
|
||||
33
components/chat/message-editor.tsx
Normal file
33
components/chat/message-editor.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { deleteTrailingMessages } from "@/app/(chat)/actions";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
|
||||
export async function submitEditedMessage({
|
||||
message,
|
||||
text,
|
||||
setMessages,
|
||||
regenerate,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
text: string;
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
}) {
|
||||
await deleteTrailingMessages({ id: message.id });
|
||||
|
||||
setMessages((messages) => {
|
||||
const index = messages.findIndex((m) => m.id === message.id);
|
||||
if (index === -1) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return [
|
||||
...messages.slice(0, index),
|
||||
{ ...message, parts: [{ type: "text" as const, text }] },
|
||||
];
|
||||
});
|
||||
|
||||
regenerate();
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from "./ai-elements/reasoning";
|
||||
} from "../ai-elements/reasoning";
|
||||
|
||||
type MessageReasoningProps = {
|
||||
isLoading: boolean;
|
||||
387
components/chat/message.tsx
Normal file
387
components/chat/message.tsx
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
"use client";
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { cn, sanitizeText } from "@/lib/utils";
|
||||
import { MessageContent, MessageResponse } from "../ai-elements/message";
|
||||
import { Shimmer } from "../ai-elements/shimmer";
|
||||
import {
|
||||
Tool,
|
||||
ToolContent,
|
||||
ToolHeader,
|
||||
ToolInput,
|
||||
ToolOutput,
|
||||
} from "../ai-elements/tool";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { DocumentToolResult } from "./document";
|
||||
import { DocumentPreview } from "./document-preview";
|
||||
import { SparklesIcon } from "./icons";
|
||||
import { MessageActions } from "./message-actions";
|
||||
import { MessageReasoning } from "./message-reasoning";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import { Weather } from "./weather";
|
||||
|
||||
const PurePreviewMessage = ({
|
||||
addToolApprovalResponse,
|
||||
chatId,
|
||||
message,
|
||||
vote,
|
||||
isLoading,
|
||||
setMessages: _setMessages,
|
||||
regenerate: _regenerate,
|
||||
isReadonly,
|
||||
requiresScrollPadding: _requiresScrollPadding,
|
||||
onEdit,
|
||||
}: {
|
||||
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
||||
chatId: string;
|
||||
message: ChatMessage;
|
||||
vote: Vote | undefined;
|
||||
isLoading: boolean;
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
requiresScrollPadding: boolean;
|
||||
onEdit?: (message: ChatMessage) => void;
|
||||
}) => {
|
||||
const attachmentsFromMessage = message.parts.filter(
|
||||
(part) => part.type === "file"
|
||||
);
|
||||
|
||||
useDataStream();
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const isAssistant = message.role === "assistant";
|
||||
|
||||
const hasAnyContent = message.parts?.some(
|
||||
(part) =>
|
||||
(part.type === "text" && part.text?.trim().length > 0) ||
|
||||
(part.type === "reasoning" &&
|
||||
"text" in part &&
|
||||
part.text?.trim().length > 0) ||
|
||||
part.type.startsWith("tool-")
|
||||
);
|
||||
const isThinking = isAssistant && isLoading && !hasAnyContent;
|
||||
|
||||
const attachments = attachmentsFromMessage.length > 0 && (
|
||||
<div
|
||||
className="flex flex-row justify-end gap-2"
|
||||
data-testid={"message-attachments"}
|
||||
>
|
||||
{attachmentsFromMessage.map((attachment) => (
|
||||
<PreviewAttachment
|
||||
attachment={{
|
||||
name: attachment.filename ?? "file",
|
||||
contentType: attachment.mediaType,
|
||||
url: attachment.url,
|
||||
}}
|
||||
key={attachment.url}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const mergedReasoning = message.parts?.reduce(
|
||||
(acc, part) => {
|
||||
if (part.type === "reasoning" && part.text?.trim().length > 0) {
|
||||
return {
|
||||
text: acc.text ? `${acc.text}\n\n${part.text}` : part.text,
|
||||
isStreaming: "state" in part ? part.state === "streaming" : false,
|
||||
rendered: false,
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ text: "", isStreaming: false, rendered: false }
|
||||
) ?? { text: "", isStreaming: false, rendered: false };
|
||||
|
||||
const parts = message.parts?.map((part, index) => {
|
||||
const { type } = part;
|
||||
const key = `message-${message.id}-part-${index}`;
|
||||
|
||||
if (type === "reasoning") {
|
||||
if (!mergedReasoning.rendered && mergedReasoning.text) {
|
||||
mergedReasoning.rendered = true;
|
||||
return (
|
||||
<MessageReasoning
|
||||
isLoading={isLoading || mergedReasoning.isStreaming}
|
||||
key={key}
|
||||
reasoning={mergedReasoning.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
return (
|
||||
<MessageContent
|
||||
className={cn("text-[13px] leading-[1.65]", {
|
||||
"w-fit max-w-[min(80%,56ch)] overflow-hidden break-words rounded-2xl rounded-br-lg border border-border/30 bg-gradient-to-br from-secondary to-muted px-3.5 py-2 shadow-[var(--shadow-card)]":
|
||||
message.role === "user",
|
||||
})}
|
||||
data-testid="message-content"
|
||||
key={key}
|
||||
>
|
||||
<MessageResponse>{sanitizeText(part.text)}</MessageResponse>
|
||||
</MessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "tool-getWeather") {
|
||||
const { toolCallId, state } = part;
|
||||
const approvalId = (part as { approval?: { id: string } }).approval?.id;
|
||||
const isDenied =
|
||||
state === "output-denied" ||
|
||||
(state === "approval-responded" &&
|
||||
(part as { approval?: { approved?: boolean } }).approval?.approved ===
|
||||
false);
|
||||
const widthClass = "w-[min(100%,450px)]";
|
||||
|
||||
if (state === "output-available") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Weather weatherAtLocation={part.output} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDenied) {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state="output-denied" type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
<div className="px-4 py-3 text-muted-foreground text-sm">
|
||||
Weather lookup was denied.
|
||||
</div>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "approval-responded") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state={state} type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
<ToolInput input={part.input} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state={state} type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
{(state === "input-available" ||
|
||||
state === "approval-requested") && (
|
||||
<ToolInput input={part.input} />
|
||||
)}
|
||||
{state === "approval-requested" && approvalId && (
|
||||
<div className="flex items-center justify-end gap-2 border-t px-4 py-3">
|
||||
<button
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
reason: "User denied weather lookup",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm transition-colors hover:bg-primary/90"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: true,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "tool-createDocument") {
|
||||
const { toolCallId } = part;
|
||||
|
||||
if (part.output && "error" in part.output) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
|
||||
key={toolCallId}
|
||||
>
|
||||
Error creating document: {String(part.output.error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentPreview
|
||||
isReadonly={isReadonly}
|
||||
key={toolCallId}
|
||||
result={part.output}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "tool-updateDocument") {
|
||||
const { toolCallId } = part;
|
||||
|
||||
if (part.output && "error" in part.output) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
|
||||
key={toolCallId}
|
||||
>
|
||||
Error updating document: {String(part.output.error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={toolCallId}>
|
||||
<DocumentPreview
|
||||
args={{ ...part.output, isUpdate: true }}
|
||||
isReadonly={isReadonly}
|
||||
result={part.output}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "tool-requestSuggestions") {
|
||||
const { toolCallId, state } = part;
|
||||
|
||||
return (
|
||||
<Tool
|
||||
className="w-[min(100%,450px)]"
|
||||
defaultOpen={true}
|
||||
key={toolCallId}
|
||||
>
|
||||
<ToolHeader state={state} type="tool-requestSuggestions" />
|
||||
<ToolContent>
|
||||
{state === "input-available" && <ToolInput input={part.input} />}
|
||||
{state === "output-available" && (
|
||||
<ToolOutput
|
||||
errorText={undefined}
|
||||
output={
|
||||
"error" in part.output ? (
|
||||
<div className="rounded border p-2 text-red-500">
|
||||
Error: {String(part.output.error)}
|
||||
</div>
|
||||
) : (
|
||||
<DocumentToolResult
|
||||
isReadonly={isReadonly}
|
||||
result={part.output}
|
||||
type="request-suggestions"
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const actions = !isReadonly && (
|
||||
<MessageActions
|
||||
chatId={chatId}
|
||||
isLoading={isLoading}
|
||||
key={`action-${message.id}`}
|
||||
message={message}
|
||||
onEdit={onEdit ? () => onEdit(message) : undefined}
|
||||
vote={vote}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = isThinking ? (
|
||||
<div className="flex h-[calc(13px*1.65)] items-center text-[13px] leading-[1.65]">
|
||||
<Shimmer className="font-medium" duration={1}>
|
||||
Thinking...
|
||||
</Shimmer>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{attachments}
|
||||
{parts}
|
||||
{actions}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/message w-full",
|
||||
!isAssistant && "animate-[fade-up_0.25s_cubic-bezier(0.22,1,0.36,1)]"
|
||||
)}
|
||||
data-role={message.role}
|
||||
data-testid={`message-${message.role}`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
isUser ? "flex flex-col items-end gap-2" : "flex items-start gap-3"
|
||||
)}
|
||||
>
|
||||
{isAssistant && (
|
||||
<div className="flex h-[calc(13px*1.65)] shrink-0 items-center">
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-border/50">
|
||||
<SparklesIcon size={13} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAssistant ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">{content}</div>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PreviewMessage = PurePreviewMessage;
|
||||
|
||||
export const ThinkingMessage = () => {
|
||||
return (
|
||||
<div
|
||||
className="group/message w-full"
|
||||
data-role="assistant"
|
||||
data-testid="message-assistant-loading"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-[calc(13px*1.65)] shrink-0 items-center">
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-border/50">
|
||||
<SparklesIcon size={13} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(13px*1.65)] items-center text-[13px] leading-[1.65]">
|
||||
<Shimmer className="font-medium" duration={1}>
|
||||
Thinking...
|
||||
</Shimmer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useMessages } from "@/hooks/use-messages";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { Greeting } from "./greeting";
|
||||
import { PreviewMessage, ThinkingMessage } from "./message";
|
||||
|
|
@ -17,7 +19,9 @@ type MessagesProps = {
|
|||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
isArtifactVisible: boolean;
|
||||
isLoading?: boolean;
|
||||
selectedModelId: string;
|
||||
onEditMessage?: (message: ChatMessage) => void;
|
||||
};
|
||||
|
||||
function PureMessages({
|
||||
|
|
@ -29,7 +33,10 @@ function PureMessages({
|
|||
setMessages,
|
||||
regenerate,
|
||||
isReadonly,
|
||||
isArtifactVisible,
|
||||
isLoading,
|
||||
selectedModelId: _selectedModelId,
|
||||
onEditMessage,
|
||||
}: MessagesProps) {
|
||||
const {
|
||||
containerRef: messagesContainerRef,
|
||||
|
|
@ -37,21 +44,37 @@ function PureMessages({
|
|||
isAtBottom,
|
||||
scrollToBottom,
|
||||
hasSentMessage,
|
||||
reset,
|
||||
} = useMessages({
|
||||
status,
|
||||
});
|
||||
|
||||
useDataStream();
|
||||
|
||||
const prevChatIdRef = useRef(chatId);
|
||||
useEffect(() => {
|
||||
if (prevChatIdRef.current !== chatId) {
|
||||
prevChatIdRef.current = chatId;
|
||||
reset();
|
||||
}
|
||||
}, [chatId, reset]);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 bg-background">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||||
<Greeting />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="absolute inset-0 touch-pan-y overflow-y-auto bg-background"
|
||||
className={cn(
|
||||
"absolute inset-0 touch-pan-y overflow-y-auto",
|
||||
messages.length > 0 ? "bg-background" : "bg-transparent"
|
||||
)}
|
||||
ref={messagesContainerRef}
|
||||
style={isArtifactVisible ? { scrollbarWidth: "none" } : undefined}
|
||||
>
|
||||
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
|
||||
{messages.length === 0 && <Greeting />}
|
||||
|
||||
<div className="mx-auto flex min-h-full min-w-0 max-w-4xl flex-col gap-5 px-2 py-6 md:gap-7 md:px-4">
|
||||
{messages.map((message, index) => (
|
||||
<PreviewMessage
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
|
|
@ -62,6 +85,7 @@ function PureMessages({
|
|||
isReadonly={isReadonly}
|
||||
key={message.id}
|
||||
message={message}
|
||||
onEdit={onEditMessage}
|
||||
regenerate={regenerate}
|
||||
requiresScrollPadding={
|
||||
hasSentMessage && index === messages.length - 1
|
||||
|
|
@ -75,12 +99,9 @@ function PureMessages({
|
|||
/>
|
||||
))}
|
||||
|
||||
{status === "submitted" &&
|
||||
!messages.some((msg) =>
|
||||
msg.parts?.some(
|
||||
(part) => "state" in part && part.state === "approval-responded"
|
||||
)
|
||||
) && <ThinkingMessage />}
|
||||
{status === "submitted" && messages.at(-1)?.role !== "assistant" && (
|
||||
<ThinkingMessage />
|
||||
)}
|
||||
|
||||
<div
|
||||
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||
|
|
@ -91,15 +112,15 @@ function PureMessages({
|
|||
|
||||
<button
|
||||
aria-label="Scroll to bottom"
|
||||
className={`absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
|
||||
className={`absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center rounded-full border border-border/50 bg-card/90 px-3.5 shadow-[var(--shadow-float)] backdrop-blur-lg transition-all duration-200 h-7 text-[10px] ${
|
||||
isAtBottom
|
||||
? "pointer-events-none scale-0 opacity-0"
|
||||
? "pointer-events-none scale-90 opacity-0"
|
||||
: "pointer-events-auto scale-100 opacity-100"
|
||||
}`}
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
type="button"
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
<ArrowDownIcon className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -3,7 +3,15 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import type { UIMessage } from "ai";
|
||||
import equal from "fast-deep-equal";
|
||||
import { ArrowUpIcon, CheckIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
BrainIcon,
|
||||
EyeIcon,
|
||||
LockIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type Dispatch,
|
||||
|
|
@ -15,6 +23,7 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { useLocalStorage, useWindowSize } from "usehooks-ts";
|
||||
import {
|
||||
ModelSelector,
|
||||
|
|
@ -28,11 +37,11 @@ import {
|
|||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector";
|
||||
import {
|
||||
type ChatModel,
|
||||
chatModels,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
modelsByProvider,
|
||||
type ModelCapabilities,
|
||||
} from "@/lib/ai/models";
|
||||
import { signIn, useSession } from "@/lib/client";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
|
@ -41,15 +50,20 @@ import {
|
|||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
} from "./ai-elements/prompt-input";
|
||||
} from "../ai-elements/prompt-input";
|
||||
import { Button } from "../ui/button";
|
||||
import { PaperclipIcon, StopIcon } from "./icons";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import {
|
||||
type SlashCommand,
|
||||
SlashCommandMenu,
|
||||
slashCommands,
|
||||
} from "./slash-commands";
|
||||
import { SuggestedActions } from "./suggested-actions";
|
||||
import { Button } from "./ui/button";
|
||||
import type { VisibilityType } from "./visibility-selector";
|
||||
|
||||
function setCookie(name: string, value: string) {
|
||||
const maxAge = 60 * 60 * 24 * 365; // 1 year
|
||||
const maxAge = 60 * 60 * 24 * 365;
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: needed for client-side cookie setting
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`;
|
||||
}
|
||||
|
|
@ -69,6 +83,9 @@ function PureMultimodalInput({
|
|||
selectedVisibilityType,
|
||||
selectedModelId,
|
||||
onModelChange,
|
||||
editingMessage,
|
||||
onCancelEdit,
|
||||
isLoading,
|
||||
}: {
|
||||
chatId: string;
|
||||
input: string;
|
||||
|
|
@ -79,17 +96,21 @@ function PureMultimodalInput({
|
|||
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
||||
messages: UIMessage[];
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
sendMessage:
|
||||
| UseChatHelpers<ChatMessage>["sendMessage"]
|
||||
| (() => Promise<void>);
|
||||
className?: string;
|
||||
selectedVisibilityType: VisibilityType;
|
||||
selectedModelId: string;
|
||||
onModelChange?: (modelId: string) => void;
|
||||
editingMessage?: ChatMessage | null;
|
||||
onCancelEdit?: () => void;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { width } = useWindowSize();
|
||||
const { data: session, refetch: refetchSession } = useSession();
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
|
||||
const hasAutoFocused = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasAutoFocused.current && width) {
|
||||
|
|
@ -109,12 +130,9 @@ function PureMultimodalInput({
|
|||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
const domValue = textareaRef.current.value;
|
||||
// Prefer DOM value over localStorage to handle hydration
|
||||
const finalValue = domValue || localStorageInput || "";
|
||||
setInput(finalValue);
|
||||
}
|
||||
// Only run once after hydration
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localStorageInput, setInput]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -122,11 +140,80 @@ function PureMultimodalInput({
|
|||
}, [input, setLocalStorageInput]);
|
||||
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(event.target.value);
|
||||
const val = event.target.value;
|
||||
setInput(val);
|
||||
|
||||
if (val.startsWith("/") && !val.includes(" ")) {
|
||||
setSlashOpen(true);
|
||||
setSlashQuery(val.slice(1));
|
||||
setSlashIndex(0);
|
||||
} else {
|
||||
setSlashOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand) => {
|
||||
setSlashOpen(false);
|
||||
setInput("");
|
||||
switch (cmd.action) {
|
||||
case "new":
|
||||
router.push("/");
|
||||
break;
|
||||
case "clear":
|
||||
setMessages(() => []);
|
||||
break;
|
||||
case "rename":
|
||||
toast("Rename is available from the sidebar chat menu.");
|
||||
break;
|
||||
case "model": {
|
||||
const modelBtn = document.querySelector<HTMLButtonElement>(
|
||||
"[data-testid='model-selector']"
|
||||
);
|
||||
modelBtn?.click();
|
||||
break;
|
||||
}
|
||||
case "theme":
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||
break;
|
||||
case "delete":
|
||||
toast("Delete this chat?", {
|
||||
action: {
|
||||
label: "Delete",
|
||||
onClick: () => {
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
router.push("/");
|
||||
toast.success("Chat deleted");
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "purge":
|
||||
toast("Delete all chats?", {
|
||||
action: {
|
||||
label: "Delete all",
|
||||
onClick: () => {
|
||||
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
router.push("/");
|
||||
toast.success("All chats deleted");
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
|
||||
const [slashOpen, setSlashOpen] = useState(false);
|
||||
const [slashQuery, setSlashQuery] = useState("");
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
|
||||
const submitForm = useCallback(() => {
|
||||
window.history.pushState(
|
||||
|
|
@ -216,8 +303,8 @@ function PureMultimodalInput({
|
|||
...currentAttachments,
|
||||
...successfullyUploadedAttachments,
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading files!", error);
|
||||
} catch (_error) {
|
||||
toast.error("Failed to upload files");
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
}
|
||||
|
|
@ -240,7 +327,6 @@ function PureMultimodalInput({
|
|||
return;
|
||||
}
|
||||
|
||||
// Prevent default paste behavior for images
|
||||
event.preventDefault();
|
||||
|
||||
setUploadQueue((prev) => [...prev, "Pasted image"]);
|
||||
|
|
@ -263,8 +349,7 @@ function PureMultimodalInput({
|
|||
...curr,
|
||||
...(successfullyUploadedAttachments as Attachment[]),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading pasted images:", error);
|
||||
} catch (_error) {
|
||||
toast.error("Failed to upload pasted image(s)");
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
|
|
@ -273,7 +358,6 @@ function PureMultimodalInput({
|
|||
[setAttachments, uploadFile]
|
||||
);
|
||||
|
||||
// Add paste event listener to textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
|
|
@ -286,7 +370,25 @@ function PureMultimodalInput({
|
|||
|
||||
return (
|
||||
<div className={cn("relative flex w-full flex-col gap-4", className)}>
|
||||
{messages.length === 0 &&
|
||||
{editingMessage && onCancelEdit && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
<span>Editing message</span>
|
||||
<button
|
||||
className="rounded px-1.5 py-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onCancelEdit();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!editingMessage &&
|
||||
!isLoading &&
|
||||
messages.length === 0 &&
|
||||
attachments.length === 0 &&
|
||||
uploadQueue.length === 0 && (
|
||||
<SuggestedActions
|
||||
|
|
@ -305,24 +407,32 @@ function PureMultimodalInput({
|
|||
type="file"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{slashOpen && (
|
||||
<SlashCommandMenu
|
||||
onClose={() => setSlashOpen(false)}
|
||||
onSelect={handleSlashSelect}
|
||||
query={slashQuery}
|
||||
selectedIndex={slashIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PromptInput
|
||||
className="[&>div]:rounded-xl"
|
||||
onSubmit={async () => {
|
||||
className="[&>div]:rounded-2xl [&>div]:border [&>div]:border-border/30 [&>div]:bg-card/70 [&>div]:shadow-[var(--shadow-composer)] [&>div]:transition-shadow [&>div]:duration-300 [&>div]:focus-within:shadow-[var(--shadow-composer-focus)]"
|
||||
onSubmit={() => {
|
||||
if (input.startsWith("/")) {
|
||||
const query = input.slice(1).trim();
|
||||
const cmd = slashCommands.find((c) => c.name === query);
|
||||
if (cmd) {
|
||||
handleSlashSelect(cmd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!input.trim() && attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!session && !isSigningIn) {
|
||||
setIsSigningIn(true);
|
||||
const { error } = await signIn.anonymous();
|
||||
if (error) {
|
||||
toast.error("Failed to create session, please try again!");
|
||||
setIsSigningIn(false);
|
||||
return;
|
||||
}
|
||||
await refetchSession();
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
if (status === "ready") {
|
||||
if (status === "ready" || status === "error") {
|
||||
submitForm();
|
||||
} else {
|
||||
toast.error("Please wait for the model to finish its response!");
|
||||
|
|
@ -331,7 +441,7 @@ function PureMultimodalInput({
|
|||
>
|
||||
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
||||
<div
|
||||
className="flex flex-row items-end gap-2 overflow-x-scroll"
|
||||
className="flex w-full self-start flex-row gap-2 overflow-x-auto px-3 pt-3 no-scrollbar"
|
||||
data-testid="attachments-preview"
|
||||
>
|
||||
{attachments.map((attachment) => (
|
||||
|
|
@ -363,14 +473,49 @@ function PureMultimodalInput({
|
|||
</div>
|
||||
)}
|
||||
<PromptInputTextarea
|
||||
className="p-6 min-h-24"
|
||||
className="min-h-24 text-[13px] leading-relaxed px-4 pt-3.5 pb-1.5 placeholder:text-muted-foreground/35"
|
||||
data-testid="multimodal-input"
|
||||
onChange={handleInput}
|
||||
placeholder="Send a message..."
|
||||
onKeyDown={(e) => {
|
||||
if (slashOpen) {
|
||||
const filtered = slashCommands.filter((cmd) =>
|
||||
cmd.name.startsWith(slashQuery.toLowerCase())
|
||||
);
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSlashIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSlashIndex((i) => Math.max(i - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
if (filtered[slashIndex]) {
|
||||
handleSlashSelect(filtered[slashIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setSlashOpen(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape" && editingMessage && onCancelEdit) {
|
||||
e.preventDefault();
|
||||
onCancelEdit();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
editingMessage ? "Edit your message..." : "Ask anything..."
|
||||
}
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
/>
|
||||
<PromptInputFooter>
|
||||
<PromptInputFooter className="px-3 pb-3">
|
||||
<PromptInputTools>
|
||||
<AttachmentsButton
|
||||
fileInputRef={fileInputRef}
|
||||
|
|
@ -387,7 +532,12 @@ function PureMultimodalInput({
|
|||
<StopButton setMessages={setMessages} stop={stop} />
|
||||
) : (
|
||||
<PromptInputSubmit
|
||||
className="rounded-full"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-xl transition-all duration-200",
|
||||
input.trim()
|
||||
? "bg-foreground text-background hover:opacity-85 active:scale-95"
|
||||
: "bg-muted text-muted-foreground/25 cursor-not-allowed"
|
||||
)}
|
||||
data-testid="send-button"
|
||||
disabled={!input.trim() || uploadQueue.length > 0}
|
||||
status={status}
|
||||
|
|
@ -420,6 +570,15 @@ export const MultimodalInput = memo(
|
|||
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.editingMessage !== nextProps.editingMessage) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isLoading !== nextProps.isLoading) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.messages.length !== nextProps.messages.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -434,14 +593,26 @@ function PureAttachmentsButton({
|
|||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
selectedModelId: string;
|
||||
}) {
|
||||
const isReasoningModel =
|
||||
selectedModelId.includes("reasoning") || selectedModelId.includes("think");
|
||||
const { data: modelsResponse } = useSWR(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`,
|
||||
(url: string) => fetch(url).then((r) => r.json()),
|
||||
{ revalidateOnFocus: false, dedupingInterval: 3_600_000 }
|
||||
);
|
||||
|
||||
const caps: Record<string, ModelCapabilities> | undefined =
|
||||
modelsResponse?.capabilities ?? modelsResponse;
|
||||
const hasVision = caps?.[selectedModelId]?.vision ?? false;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="aspect-square h-8 rounded-lg p-1 transition-colors hover:bg-accent"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-lg border border-border/40 p-1 transition-colors",
|
||||
hasVision
|
||||
? "text-foreground hover:border-border hover:text-foreground"
|
||||
: "text-muted-foreground/30 cursor-not-allowed"
|
||||
)}
|
||||
data-testid="attachments-button"
|
||||
disabled={status !== "ready" || isReasoningModel}
|
||||
disabled={status !== "ready" || !hasVision}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
|
|
@ -463,26 +634,31 @@ function PureModelSelectorCompact({
|
|||
onModelChange?: (modelId: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: modelsData } = useSWR(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`,
|
||||
(url: string) => fetch(url).then((r) => r.json()),
|
||||
{ revalidateOnFocus: false, dedupingInterval: 3_600_000 }
|
||||
);
|
||||
|
||||
const capabilities: Record<string, ModelCapabilities> | undefined =
|
||||
modelsData?.capabilities ?? modelsData;
|
||||
const dynamicModels: ChatModel[] | undefined = modelsData?.models;
|
||||
const activeModels = dynamicModels ?? chatModels;
|
||||
|
||||
const selectedModel =
|
||||
chatModels.find((m) => m.id === selectedModelId) ??
|
||||
chatModels.find((m) => m.id === DEFAULT_CHAT_MODEL) ??
|
||||
chatModels[0];
|
||||
activeModels.find((m: ChatModel) => m.id === selectedModelId) ??
|
||||
activeModels.find((m: ChatModel) => m.id === DEFAULT_CHAT_MODEL) ??
|
||||
activeModels[0];
|
||||
const [provider] = selectedModel.id.split("/");
|
||||
|
||||
// Provider display names
|
||||
const providerNames: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
google: "Google",
|
||||
xai: "xAI",
|
||||
reasoning: "Reasoning",
|
||||
};
|
||||
|
||||
return (
|
||||
<ModelSelector onOpenChange={setOpen} open={open}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<Button className="h-8 w-[200px] justify-between px-2" variant="ghost">
|
||||
<Button
|
||||
className="h-7 max-w-[200px] justify-between gap-1.5 rounded-lg px-2 text-[12px] text-muted-foreground transition-colors hover:text-foreground"
|
||||
data-testid="model-selector"
|
||||
variant="ghost"
|
||||
>
|
||||
{provider && <ModelSelectorLogo provider={provider} />}
|
||||
<ModelSelectorName>{selectedModel.name}</ModelSelectorName>
|
||||
</Button>
|
||||
|
|
@ -490,35 +666,123 @@ function PureModelSelectorCompact({
|
|||
<ModelSelectorContent>
|
||||
<ModelSelectorInput placeholder="Search models..." />
|
||||
<ModelSelectorList>
|
||||
{Object.entries(modelsByProvider).map(
|
||||
([providerKey, providerModels]) => (
|
||||
{(() => {
|
||||
const curatedIds = new Set(chatModels.map((m) => m.id));
|
||||
const allModels = dynamicModels
|
||||
? [
|
||||
...chatModels,
|
||||
...dynamicModels.filter((m) => !curatedIds.has(m.id)),
|
||||
]
|
||||
: chatModels;
|
||||
|
||||
const grouped: Record<
|
||||
string,
|
||||
{ model: ChatModel; curated: boolean }[]
|
||||
> = {};
|
||||
for (const model of allModels) {
|
||||
const key = curatedIds.has(model.id)
|
||||
? "_available"
|
||||
: model.provider;
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
grouped[key].push({ model, curated: curatedIds.has(model.id) });
|
||||
}
|
||||
|
||||
const sortedKeys = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === "_available") {
|
||||
return -1;
|
||||
}
|
||||
if (b === "_available") {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const providerNames: Record<string, string> = {
|
||||
alibaba: "Alibaba",
|
||||
anthropic: "Anthropic",
|
||||
"arcee-ai": "Arcee AI",
|
||||
bytedance: "ByteDance",
|
||||
cohere: "Cohere",
|
||||
deepseek: "DeepSeek",
|
||||
google: "Google",
|
||||
inception: "Inception",
|
||||
kwaipilot: "Kwaipilot",
|
||||
meituan: "Meituan",
|
||||
meta: "Meta",
|
||||
minimax: "MiniMax",
|
||||
mistral: "Mistral",
|
||||
moonshotai: "Moonshot",
|
||||
morph: "Morph",
|
||||
nvidia: "Nvidia",
|
||||
openai: "OpenAI",
|
||||
perplexity: "Perplexity",
|
||||
"prime-intellect": "Prime Intellect",
|
||||
xiaomi: "Xiaomi",
|
||||
xai: "xAI",
|
||||
zai: "Zai",
|
||||
};
|
||||
|
||||
return sortedKeys.map((key) => (
|
||||
<ModelSelectorGroup
|
||||
heading={providerNames[providerKey] ?? providerKey}
|
||||
key={providerKey}
|
||||
heading={
|
||||
key === "_available"
|
||||
? "Available"
|
||||
: (providerNames[key] ?? key)
|
||||
}
|
||||
key={key}
|
||||
>
|
||||
{providerModels.map((model) => {
|
||||
{grouped[key].map(({ model, curated }) => {
|
||||
const logoProvider = model.id.split("/")[0];
|
||||
return (
|
||||
<ModelSelectorItem
|
||||
className={cn(
|
||||
"flex w-full",
|
||||
model.id === selectedModel.id &&
|
||||
"border-b border-dashed border-foreground/50",
|
||||
!curated && "opacity-40 cursor-default"
|
||||
)}
|
||||
key={model.id}
|
||||
onSelect={() => {
|
||||
if (!curated) {
|
||||
return;
|
||||
}
|
||||
onModelChange?.(model.id);
|
||||
setCookie("chat-model", model.id);
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelector<HTMLTextAreaElement>(
|
||||
"[data-testid='multimodal-input']"
|
||||
)
|
||||
?.focus();
|
||||
}, 50);
|
||||
}}
|
||||
value={model.id}
|
||||
>
|
||||
<ModelSelectorLogo provider={logoProvider} />
|
||||
<ModelSelectorName>{model.name}</ModelSelectorName>
|
||||
{model.id === selectedModel.id && (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2 text-foreground/70">
|
||||
{capabilities?.[model.id]?.tools && (
|
||||
<WrenchIcon className="size-3.5" />
|
||||
)}
|
||||
{capabilities?.[model.id]?.vision && (
|
||||
<EyeIcon className="size-3.5" />
|
||||
)}
|
||||
{capabilities?.[model.id]?.reasoning && (
|
||||
<BrainIcon className="size-3.5" />
|
||||
)}
|
||||
{!curated && (
|
||||
<LockIcon className="size-3 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</ModelSelectorItem>
|
||||
);
|
||||
})}
|
||||
</ModelSelectorGroup>
|
||||
)
|
||||
)}
|
||||
));
|
||||
})()}
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
|
|
@ -536,7 +800,7 @@ function PureStopButton({
|
|||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="size-7 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
className="h-7 w-7 rounded-xl bg-foreground p-1 text-background transition-all duration-200 hover:opacity-85 active:scale-95 disabled:bg-muted disabled:text-muted-foreground/25 disabled:cursor-not-allowed"
|
||||
data-testid="stop-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import Image from "next/image";
|
||||
import type { Attachment } from "@/lib/types";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
import { CrossSmallIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
import { Spinner } from "./ui/spinner";
|
||||
|
||||
export const PreviewAttachment = ({
|
||||
attachment,
|
||||
|
|
@ -17,16 +16,16 @@ export const PreviewAttachment = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="group relative size-16 overflow-hidden rounded-lg border bg-muted"
|
||||
className="group relative h-24 w-24 shrink-0 overflow-hidden rounded-xl border border-border/40 bg-muted"
|
||||
data-testid="input-attachment-preview"
|
||||
>
|
||||
{contentType?.startsWith("image") ? (
|
||||
<Image
|
||||
alt={name ?? "An image attachment"}
|
||||
alt={name ?? "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={64}
|
||||
height={96}
|
||||
src={url}
|
||||
width={64}
|
||||
width={96}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
|
||||
|
|
@ -36,27 +35,22 @@ export const PreviewAttachment = ({
|
|||
|
||||
{isUploading && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50"
|
||||
className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40 backdrop-blur-sm"
|
||||
data-testid="input-attachment-loader"
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onRemove && !isUploading && (
|
||||
<Button
|
||||
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
<button
|
||||
className="absolute top-1.5 right-1.5 flex size-5 items-center justify-center rounded-full bg-black/60 text-white opacity-0 backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100"
|
||||
onClick={onRemove}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
type="button"
|
||||
>
|
||||
<CrossSmallIcon size={8} />
|
||||
</Button>
|
||||
<CrossSmallIcon size={10} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-linear-to-t from-black/80 to-transparent px-1 py-0.5 text-[10px] text-white">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
59
components/chat/preview.tsx
Normal file
59
components/chat/preview.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { suggestions } from "@/lib/constants";
|
||||
import { SparklesIcon } from "./icons";
|
||||
|
||||
export function Preview() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleAction = (query?: string) => {
|
||||
const url = query ? `/?query=${encodeURIComponent(query)}` : "/";
|
||||
router.push(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-tl-2xl bg-background">
|
||||
<div className="flex h-14 shrink-0 items-center gap-3 border-b border-border/20 px-5">
|
||||
<div className="flex size-5 items-center justify-center rounded bg-muted/60 ring-1 ring-border/50">
|
||||
<SparklesIcon size={10} />
|
||||
</div>
|
||||
<span className="text-[13px] text-muted-foreground">Chatbot</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold tracking-tight">
|
||||
What can I help with?
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
Ask a question, write code, or explore ideas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-md grid-cols-2 gap-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
className="rounded-xl border border-border/30 bg-card/20 px-3 py-2.5 text-left text-[11px] leading-relaxed text-muted-foreground/70 transition-all duration-200 hover:border-border/60 hover:bg-card/40 hover:text-muted-foreground"
|
||||
key={suggestion}
|
||||
onClick={() => handleAction(suggestion)}
|
||||
type="button"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-5 pb-5">
|
||||
<button
|
||||
className="flex w-full items-center rounded-2xl border border-border/30 bg-card/30 px-4 py-3 text-left text-[13px] text-muted-foreground/40 transition-colors hover:border-border/50 hover:text-muted-foreground/60"
|
||||
onClick={() => handleAction()}
|
||||
type="button"
|
||||
>
|
||||
Ask anything...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,8 +50,9 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
|
|||
frozen: true,
|
||||
width: 50,
|
||||
renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1,
|
||||
cellClass: "border-t border-r dark:bg-zinc-950 dark:text-zinc-50",
|
||||
headerCellClass: "border-t border-r dark:bg-zinc-900 dark:text-zinc-50",
|
||||
cellClass: "border-t border-r dark:bg-neutral-950 dark:text-neutral-50",
|
||||
headerCellClass:
|
||||
"border-t border-r dark:bg-neutral-900 dark:text-neutral-50",
|
||||
};
|
||||
|
||||
const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({
|
||||
|
|
@ -59,10 +60,10 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
|
|||
name: String.fromCharCode(65 + i),
|
||||
renderEditCell: textEditor,
|
||||
width: 120,
|
||||
cellClass: cn("border-t dark:bg-zinc-950 dark:text-zinc-50", {
|
||||
cellClass: cn("border-t dark:bg-neutral-950 dark:text-neutral-50", {
|
||||
"border-l": i !== 0,
|
||||
}),
|
||||
headerCellClass: cn("border-t dark:bg-zinc-900 dark:text-zinc-50", {
|
||||
headerCellClass: cn("border-t dark:bg-neutral-900 dark:text-neutral-50", {
|
||||
"border-l": i !== 0,
|
||||
}),
|
||||
}));
|
||||
|
|
@ -72,7 +73,7 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
|
|||
|
||||
const initialRows = useMemo(() => {
|
||||
return parseData.map((row, rowIndex) => {
|
||||
const rowData: any = {
|
||||
const rowData: Record<string, string | number> = {
|
||||
id: rowIndex,
|
||||
rowNumber: rowIndex + 1,
|
||||
};
|
||||
|
|
@ -91,15 +92,15 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
|
|||
setLocalRows(initialRows);
|
||||
}, [initialRows]);
|
||||
|
||||
const generateCsv = (data: any[][]) => {
|
||||
const generateCsv = (data: string[][]) => {
|
||||
return unparse(data);
|
||||
};
|
||||
|
||||
const handleRowsChange = (newRows: any[]) => {
|
||||
const handleRowsChange = (newRows: Record<string, string | number>[]) => {
|
||||
setLocalRows(newRows);
|
||||
|
||||
const updatedData = newRows.map((row) => {
|
||||
return columns.slice(1).map((col) => row[col.key] || "");
|
||||
return columns.slice(1).map((col) => String(row[col.key] ?? ""));
|
||||
});
|
||||
|
||||
const newCsvContent = generateCsv(updatedData);
|
||||
205
components/chat/shell.tsx
Normal file
205
components/chat/shell.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useActiveChat } from "@/hooks/use-active-chat";
|
||||
import {
|
||||
initialArtifactData,
|
||||
useArtifact,
|
||||
useArtifactSelector,
|
||||
} from "@/hooks/use-artifact";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Artifact } from "./artifact";
|
||||
import { ChatHeader } from "./chat-header";
|
||||
import { DataStreamHandler } from "./data-stream-handler";
|
||||
import { submitEditedMessage } from "./message-editor";
|
||||
import { Messages } from "./messages";
|
||||
import { MultimodalInput } from "./multimodal-input";
|
||||
|
||||
export function ChatShell() {
|
||||
const {
|
||||
chatId,
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
regenerate,
|
||||
addToolApprovalResponse,
|
||||
input,
|
||||
setInput,
|
||||
visibilityType,
|
||||
isReadonly,
|
||||
isLoading,
|
||||
votes,
|
||||
currentModelId,
|
||||
setCurrentModelId,
|
||||
showCreditCardAlert,
|
||||
setShowCreditCardAlert,
|
||||
} = useActiveChat();
|
||||
|
||||
const [editingMessage, setEditingMessage] = useState<ChatMessage | null>(
|
||||
null
|
||||
);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
|
||||
const { setArtifact } = useArtifact();
|
||||
|
||||
const stopRef = useRef(stop);
|
||||
stopRef.current = stop;
|
||||
|
||||
const prevChatIdRef = useRef(chatId);
|
||||
useEffect(() => {
|
||||
if (prevChatIdRef.current !== chatId) {
|
||||
prevChatIdRef.current = chatId;
|
||||
stopRef.current();
|
||||
setArtifact(initialArtifactData);
|
||||
setEditingMessage(null);
|
||||
setAttachments([]);
|
||||
}
|
||||
}, [chatId, setArtifact]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-dvh w-full flex-row overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col bg-sidebar transition-[width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",
|
||||
isArtifactVisible ? "w-[40%]" : "w-full"
|
||||
)}
|
||||
>
|
||||
<ChatHeader
|
||||
chatId={chatId}
|
||||
isReadonly={isReadonly}
|
||||
selectedVisibilityType={visibilityType}
|
||||
/>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background md:rounded-tl-[12px] md:border-t md:border-l md:border-border/40">
|
||||
<Messages
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
chatId={chatId}
|
||||
isArtifactVisible={isArtifactVisible}
|
||||
isLoading={isLoading}
|
||||
isReadonly={isReadonly}
|
||||
messages={messages}
|
||||
onEditMessage={(msg) => {
|
||||
const text = msg.parts
|
||||
?.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
setInput(text ?? "");
|
||||
setEditingMessage(msg);
|
||||
}}
|
||||
regenerate={regenerate}
|
||||
selectedModelId={currentModelId}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
votes={votes}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4">
|
||||
{!isReadonly && (
|
||||
<MultimodalInput
|
||||
attachments={attachments}
|
||||
chatId={chatId}
|
||||
editingMessage={editingMessage}
|
||||
input={input}
|
||||
isLoading={isLoading}
|
||||
messages={messages}
|
||||
onCancelEdit={() => {
|
||||
setEditingMessage(null);
|
||||
setInput("");
|
||||
}}
|
||||
onModelChange={setCurrentModelId}
|
||||
selectedModelId={currentModelId}
|
||||
selectedVisibilityType={visibilityType}
|
||||
sendMessage={
|
||||
editingMessage
|
||||
? async () => {
|
||||
const msg = editingMessage;
|
||||
setEditingMessage(null);
|
||||
await submitEditedMessage({
|
||||
message: msg,
|
||||
text: input,
|
||||
setMessages,
|
||||
regenerate,
|
||||
});
|
||||
setInput("");
|
||||
}
|
||||
: sendMessage
|
||||
}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Artifact
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
attachments={attachments}
|
||||
chatId={chatId}
|
||||
input={input}
|
||||
isReadonly={isReadonly}
|
||||
messages={messages}
|
||||
regenerate={regenerate}
|
||||
selectedModelId={currentModelId}
|
||||
selectedVisibilityType={visibilityType}
|
||||
sendMessage={sendMessage}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
votes={votes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataStreamHandler />
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setShowCreditCardAlert}
|
||||
open={showCreditCardAlert}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Activate AI Gateway</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This application requires{" "}
|
||||
{process.env.NODE_ENV === "production" ? "the owner" : "you"} to
|
||||
activate Vercel AI Gateway.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
window.open(
|
||||
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
|
||||
"_blank"
|
||||
);
|
||||
window.location.href = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/`;
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,14 +2,6 @@ import Link from "next/link";
|
|||
import { memo } from "react";
|
||||
import { useChatVisibility } from "@/hooks/use-chat-visibility";
|
||||
import type { Chat } from "@/lib/db/schema";
|
||||
import {
|
||||
CheckCircleFillIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
MoreHorizontalIcon,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
} from "./icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -19,12 +11,20 @@ import {
|
|||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
} from "../ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "./ui/sidebar";
|
||||
} from "../ui/sidebar";
|
||||
import {
|
||||
CheckCircleFillIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
MoreHorizontalIcon,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
} from "./icons";
|
||||
|
||||
const PureChatItem = ({
|
||||
chat,
|
||||
|
|
@ -44,16 +44,20 @@ const PureChatItem = ({
|
|||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="h-8 rounded-none text-[13px] text-sidebar-foreground/50 transition-all duration-150 hover:bg-transparent hover:text-sidebar-foreground data-active:bg-transparent data-active:font-normal data-active:text-sidebar-foreground/50 data-[active=true]:text-sidebar-foreground data-[active=true]:font-medium data-[active=true]:border-b data-[active=true]:border-dashed data-[active=true]:border-sidebar-foreground/50"
|
||||
isActive={isActive}
|
||||
>
|
||||
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
|
||||
<span>{chat.title}</span>
|
||||
<span className="truncate">{chat.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
<DropdownMenu modal={true}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
className="mr-0.5 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
className="mr-0.5 rounded-md text-sidebar-foreground/50 ring-0 transition-colors duration-150 focus-visible:ring-0 hover:text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
showOnHover={!isActive}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
|
|
@ -100,8 +104,8 @@ const PureChatItem = ({
|
|||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-destructive focus:bg-destructive/15 focus:text-destructive dark:text-red-500"
|
||||
onSelect={() => onDelete(chat.id)}
|
||||
variant="destructive"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>Delete</span>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { User } from "next-auth";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
|
|
@ -19,10 +20,10 @@ import {
|
|||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import type { AuthUser } from "@/lib/auth";
|
||||
import type { Chat } from "@/lib/db/schema";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { LoaderIcon } from "./icons";
|
||||
|
|
@ -97,7 +98,7 @@ export function getChatHistoryPaginationKey(
|
|||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
|
||||
}
|
||||
|
||||
export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
||||
export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null;
|
||||
|
|
@ -111,7 +112,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
} = useSWRInfinite<ChatHistory>(
|
||||
user ? getChatHistoryPaginationKey : () => null,
|
||||
fetcher,
|
||||
{ fallbackData: [] }
|
||||
{ fallbackData: [], revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
|
@ -132,43 +133,32 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
|
||||
setShowDeleteDialog(false);
|
||||
|
||||
const deletePromise = fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatToDelete}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
if (isCurrentChat) {
|
||||
router.replace("/");
|
||||
}
|
||||
|
||||
mutate((chatHistories) => {
|
||||
if (chatHistories) {
|
||||
return chatHistories.map((chatHistory) => ({
|
||||
...chatHistory,
|
||||
chats: chatHistory.chats.filter((chat) => chat.id !== chatToDelete),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatToDelete}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
toast.promise(deletePromise, {
|
||||
loading: "Deleting chat...",
|
||||
success: () => {
|
||||
mutate((chatHistories) => {
|
||||
if (chatHistories) {
|
||||
return chatHistories.map((chatHistory) => ({
|
||||
...chatHistory,
|
||||
chats: chatHistory.chats.filter(
|
||||
(chat) => chat.id !== chatToDelete
|
||||
),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
if (isCurrentChat) {
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return "Chat deleted successfully";
|
||||
},
|
||||
error: "Failed to delete chat",
|
||||
});
|
||||
toast.success("Chat deleted");
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupContent>
|
||||
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
|
||||
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
|
||||
Login to save and revisit previous chats!
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
|
|
@ -178,19 +168,19 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
Today
|
||||
</div>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
||||
History
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-0.5 px-1">
|
||||
{[44, 32, 28, 64, 52].map((item) => (
|
||||
<div
|
||||
className="flex h-8 items-center gap-2 rounded-md px-2"
|
||||
className="flex h-8 items-center gap-2 rounded-lg px-2"
|
||||
key={item}
|
||||
>
|
||||
<div
|
||||
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
|
||||
className="h-3 max-w-(--skeleton-width) flex-1 animate-pulse rounded-md bg-sidebar-foreground/[0.06]"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": `${item}%`,
|
||||
|
|
@ -207,9 +197,12 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
|
||||
if (hasEmptyChatHistory) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
||||
History
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
|
||||
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
|
||||
Your conversations will appear here once you start chatting!
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
|
|
@ -219,7 +212,10 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
||||
History
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{paginatedChatHistories &&
|
||||
|
|
@ -231,10 +227,10 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
const groupedChats = groupChatsByDate(chatsFromHistory);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{groupedChats.today.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
||||
Today
|
||||
</div>
|
||||
{groupedChats.today.map((chat) => (
|
||||
|
|
@ -254,7 +250,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
|
||||
{groupedChats.yesterday.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
||||
Yesterday
|
||||
</div>
|
||||
{groupedChats.yesterday.map((chat) => (
|
||||
|
|
@ -274,7 +270,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
|
||||
{groupedChats.lastWeek.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
||||
Last 7 days
|
||||
</div>
|
||||
{groupedChats.lastWeek.map((chat) => (
|
||||
|
|
@ -294,7 +290,7 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
|
||||
{groupedChats.lastMonth.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
||||
Last 30 days
|
||||
</div>
|
||||
{groupedChats.lastMonth.map((chat) => (
|
||||
|
|
@ -314,8 +310,8 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
|
||||
{groupedChats.older.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
Older than last month
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
||||
Older
|
||||
</div>
|
||||
{groupedChats.older.map((chat) => (
|
||||
<ChatItem
|
||||
|
|
@ -344,16 +340,12 @@ export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
|||
}}
|
||||
/>
|
||||
|
||||
{hasReachedEnd ? (
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
|
||||
You have reached the end of your chat history.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 flex flex-row items-center gap-2 p-2 text-zinc-500 dark:text-zinc-400">
|
||||
{hasReachedEnd ? null : (
|
||||
<div className="mt-1 flex flex-row items-center gap-2 px-4 py-2 text-sidebar-foreground/50">
|
||||
<div className="animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
<div>Loading Chats...</div>
|
||||
<div className="text-[11px]">Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
|
|
@ -6,8 +6,8 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "../ui/button";
|
||||
import { SidebarLeftIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function SidebarToggle({
|
||||
className,
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronUp } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { User } from "next-auth";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -16,64 +17,67 @@ import {
|
|||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { signOut, useSession } from "@/lib/client";
|
||||
import { guestRegex } from "@/lib/constants";
|
||||
import { LoaderIcon } from "./icons";
|
||||
import { toast } from "./toast";
|
||||
|
||||
export function SidebarUserNav({
|
||||
user,
|
||||
}: {
|
||||
user: { email?: string | null; isAnonymous?: boolean | null };
|
||||
}) {
|
||||
function emailToHue(email: string): number {
|
||||
let hash = 0;
|
||||
for (const char of email) {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
}
|
||||
return Math.abs(hash) % 360;
|
||||
}
|
||||
|
||||
export function SidebarUserNav({ user }: { user: User }) {
|
||||
const router = useRouter();
|
||||
const { data, isPending } = useSession();
|
||||
const { data, status } = useSession();
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const isGuest = data?.user?.isAnonymous ?? user.isAnonymous ?? false;
|
||||
const isGuest = guestRegex.test(data?.user?.email ?? "");
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{isPending ? (
|
||||
<SidebarMenuButton className="h-10 justify-between bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="size-6 animate-pulse rounded-full bg-zinc-500/30" />
|
||||
<span className="animate-pulse rounded-md bg-zinc-500/30 text-transparent">
|
||||
Loading auth status
|
||||
{status === "loading" ? (
|
||||
<SidebarMenuButton className="h-10 justify-between rounded-lg bg-transparent text-sidebar-foreground/50 transition-colors duration-150 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="size-6 animate-pulse rounded-full bg-sidebar-foreground/10" />
|
||||
<span className="animate-pulse rounded-md bg-sidebar-foreground/10 text-transparent text-[13px]">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
<div className="animate-spin text-zinc-500">
|
||||
<div className="animate-spin text-sidebar-foreground/50">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<SidebarMenuButton
|
||||
className="h-10 bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
className="h-8 px-2 rounded-lg bg-transparent text-sidebar-foreground/70 transition-colors duration-150 hover:text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
data-testid="user-nav-button"
|
||||
>
|
||||
<Image
|
||||
alt={user.email ?? "User Avatar"}
|
||||
className="rounded-full"
|
||||
height={24}
|
||||
src={`https://avatar.vercel.sh/${user.email}`}
|
||||
width={24}
|
||||
<div
|
||||
className="size-5 shrink-0 rounded-full ring-1 ring-sidebar-border/50"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, oklch(0.35 0.08 ${emailToHue(user.email ?? "")}), oklch(0.25 0.05 ${emailToHue(user.email ?? "") + 40}))`,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate" data-testid="user-email">
|
||||
<span className="truncate text-[13px]" data-testid="user-email">
|
||||
{isGuest ? "Guest" : user?.email}
|
||||
</span>
|
||||
<ChevronUp className="ml-auto" />
|
||||
<ChevronUp className="ml-auto size-3.5 text-sidebar-foreground/50" />
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-popper-anchor-width)"
|
||||
className="w-(--radix-popper-anchor-width) rounded-lg border border-border/60 bg-card/95 backdrop-blur-xl shadow-[var(--shadow-float)]"
|
||||
data-testid="user-nav-menu"
|
||||
side="top"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
className="cursor-pointer text-[13px]"
|
||||
data-testid="user-nav-item-theme"
|
||||
onSelect={() =>
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
|
|
@ -84,9 +88,9 @@ export function SidebarUserNav({
|
|||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
|
||||
<button
|
||||
className="w-full cursor-pointer"
|
||||
className="w-full cursor-pointer text-[13px]"
|
||||
onClick={() => {
|
||||
if (isPending) {
|
||||
if (status === "loading") {
|
||||
toast({
|
||||
type: "error",
|
||||
description:
|
||||
|
|
@ -100,12 +104,7 @@ export function SidebarUserNav({
|
|||
router.push("/login");
|
||||
} else {
|
||||
signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
},
|
||||
},
|
||||
redirectTo: "/",
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import Form from "next/form";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
import { signOut } from "@/app/(auth)/auth";
|
||||
|
||||
export const SignOutForm = () => {
|
||||
return (
|
||||
|
|
@ -9,11 +8,9 @@ export const SignOutForm = () => {
|
|||
action={async () => {
|
||||
"use server";
|
||||
|
||||
await auth.api.signOut({
|
||||
headers: await headers(),
|
||||
await signOut({
|
||||
redirectTo: "/",
|
||||
});
|
||||
|
||||
redirect("/");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
137
components/chat/slash-commands.tsx
Normal file
137
components/chat/slash-commands.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BombIcon,
|
||||
ListIcon,
|
||||
PaletteIcon,
|
||||
PenLineIcon,
|
||||
PenSquareIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SlashCommand = {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
action: string;
|
||||
shortcut?: string;
|
||||
};
|
||||
|
||||
export const slashCommands: SlashCommand[] = [
|
||||
{
|
||||
name: "new",
|
||||
description: "Start a new chat",
|
||||
icon: <PenSquareIcon className="size-3.5" />,
|
||||
action: "new",
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear current chat",
|
||||
icon: <Trash2Icon className="size-3.5" />,
|
||||
action: "clear",
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
description: "Rename current chat",
|
||||
icon: <PenLineIcon className="size-3.5" />,
|
||||
action: "rename",
|
||||
},
|
||||
{
|
||||
name: "model",
|
||||
description: "Change the AI model",
|
||||
icon: <ListIcon className="size-3.5" />,
|
||||
action: "model",
|
||||
},
|
||||
{
|
||||
name: "theme",
|
||||
description: "Toggle dark/light mode",
|
||||
icon: <PaletteIcon className="size-3.5" />,
|
||||
action: "theme",
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
description: "Delete current chat",
|
||||
icon: <XIcon className="size-3.5" />,
|
||||
action: "delete",
|
||||
},
|
||||
{
|
||||
name: "purge",
|
||||
description: "Delete all chats",
|
||||
icon: <BombIcon className="size-3.5" />,
|
||||
action: "purge",
|
||||
},
|
||||
];
|
||||
|
||||
type SlashCommandMenuProps = {
|
||||
query: string;
|
||||
onSelect: (command: SlashCommand) => void;
|
||||
onClose: () => void;
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
export function SlashCommandMenu({
|
||||
query,
|
||||
onSelect,
|
||||
onClose: _onClose,
|
||||
selectedIndex,
|
||||
}: SlashCommandMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = slashCommands.filter((cmd) =>
|
||||
cmd.name.startsWith(query.toLowerCase())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = menuRef.current?.querySelector("[data-selected='true']");
|
||||
if (selected) {
|
||||
selected.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-border/50 bg-card/95 shadow-[var(--shadow-float)] backdrop-blur-xl"
|
||||
ref={menuRef}
|
||||
>
|
||||
<div className="px-4 py-2.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/40">
|
||||
Commands
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto pb-1 no-scrollbar">
|
||||
{filtered.map((cmd, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors",
|
||||
index === selectedIndex ? "bg-muted/70" : "hover:bg-muted/40"
|
||||
)}
|
||||
data-selected={index === selectedIndex}
|
||||
key={cmd.name}
|
||||
onClick={() => onSelect(cmd)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center text-muted-foreground/60">
|
||||
{cmd.icon}
|
||||
</div>
|
||||
<span className="font-mono text-[13px] text-foreground">
|
||||
/{cmd.name}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted-foreground/50">
|
||||
{cmd.description}
|
||||
</span>
|
||||
{cmd.shortcut && (
|
||||
<span className="ml-auto text-[11px] text-muted-foreground/30">
|
||||
{cmd.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import { LoaderIcon } from "@/components/icons";
|
||||
import { LoaderIcon } from "@/components/chat/icons";
|
||||
|
||||
import { Button } from "./ui/button";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
|
|
@ -3,8 +3,9 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { memo } from "react";
|
||||
import { suggestions } from "@/lib/constants";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { Suggestion } from "./ai-elements/suggestion";
|
||||
import { Suggestion } from "../ai-elements/suggestion";
|
||||
import type { VisibilityType } from "./visibility-selector";
|
||||
|
||||
type SuggestedActionsProps = {
|
||||
|
|
@ -14,28 +15,33 @@ type SuggestedActionsProps = {
|
|||
};
|
||||
|
||||
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
|
||||
const suggestedActions = [
|
||||
"What are the advantages of using Next.js?",
|
||||
"Write code to demonstrate Dijkstra's algorithm",
|
||||
"Help me write an essay about Silicon Valley",
|
||||
"What is the weather in San Francisco?",
|
||||
];
|
||||
const suggestedActions = suggestions;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid w-full gap-2 sm:grid-cols-2"
|
||||
className="flex w-full gap-2.5 overflow-x-auto pb-1 sm:grid sm:grid-cols-2 sm:overflow-visible"
|
||||
data-testid="suggested-actions"
|
||||
style={{
|
||||
scrollbarWidth: "none",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
msOverflowStyle: "none",
|
||||
}}
|
||||
>
|
||||
{suggestedActions.map((suggestedAction, index) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
className="min-w-[200px] shrink-0 sm:min-w-0 sm:shrink"
|
||||
exit={{ opacity: 0, y: 16 }}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
key={suggestedAction}
|
||||
transition={{ delay: 0.05 * index }}
|
||||
transition={{
|
||||
delay: 0.06 * index,
|
||||
duration: 0.4,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
>
|
||||
<Suggestion
|
||||
className="h-auto w-full whitespace-normal p-3 text-left"
|
||||
className="h-auto w-full whitespace-nowrap rounded-xl border border-border/50 bg-card/30 px-4 py-3 text-left text-[12px] leading-relaxed text-muted-foreground transition-all duration-200 sm:whitespace-normal sm:p-4 sm:text-[13px] hover:-translate-y-0.5 hover:bg-card/60 hover:text-foreground hover:shadow-[var(--shadow-card)]"
|
||||
onClick={(suggestion) => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
78
components/chat/suggestion.tsx
Normal file
78
components/chat/suggestion.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import type { UISuggestion } from "@/lib/editor/suggestions";
|
||||
import { Button } from "../ui/button";
|
||||
import { CrossIcon, SparklesIcon } from "./icons";
|
||||
|
||||
export const SuggestionDialog = ({
|
||||
suggestion,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
suggestion: UISuggestion;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<div className="sticky inset-0 z-40 h-full w-full">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
role="presentation"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="absolute left-1/2 top-1/2 z-50 flex w-[min(20rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col gap-3 rounded-2xl border bg-background p-4 font-sans text-sm shadow-xl"
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
key={suggestion.id}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex size-5 items-center justify-center rounded-md bg-muted/60 text-muted-foreground ring-1 ring-border/50">
|
||||
<SparklesIcon size={10} />
|
||||
</div>
|
||||
<div className="font-medium">Suggestion</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<CrossIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-muted-foreground leading-relaxed">
|
||||
{suggestion.description}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="w-fit rounded-full px-3 py-1.5"
|
||||
onClick={onApply}
|
||||
variant="outline"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
className="w-fit rounded-full px-3 py-1.5"
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,8 +3,9 @@
|
|||
import { exampleSetup } from "prosemirror-example-setup";
|
||||
import { inputRules } from "prosemirror-inputrules";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import { type Decoration, DecorationSet, EditorView } from "prosemirror-view";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { Suggestion } from "@/lib/db/schema";
|
||||
import {
|
||||
|
|
@ -21,7 +22,9 @@ import {
|
|||
projectWithPositions,
|
||||
suggestionsPlugin,
|
||||
suggestionsPluginKey,
|
||||
type UISuggestion,
|
||||
} from "@/lib/editor/suggestions";
|
||||
import { SuggestionDialog } from "./suggestion";
|
||||
|
||||
type EditorProps = {
|
||||
content: string;
|
||||
|
|
@ -30,6 +33,9 @@ type EditorProps = {
|
|||
isCurrentVersion: boolean;
|
||||
currentVersionIndex: number;
|
||||
suggestions: Suggestion[];
|
||||
onSuggestionSelect?: (suggestion: UISuggestion | null) => void;
|
||||
onSuggestionApply?: () => void;
|
||||
activeSuggestion?: UISuggestion | null;
|
||||
};
|
||||
|
||||
function PureEditor({
|
||||
|
|
@ -40,6 +46,10 @@ function PureEditor({
|
|||
}: EditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
const [activeSuggestion, setActiveSuggestion] = useState<UISuggestion | null>(
|
||||
null
|
||||
);
|
||||
const suggestionsRef = useRef<UISuggestion[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && !editorRef.current) {
|
||||
|
|
@ -63,6 +73,21 @@ function PureEditor({
|
|||
|
||||
editorRef.current = new EditorView(containerRef.current, {
|
||||
state,
|
||||
handleDOMEvents: {
|
||||
click(_view, event) {
|
||||
const target = event.target as HTMLElement;
|
||||
const highlight = target.closest(".suggestion-highlight");
|
||||
if (highlight) {
|
||||
const id = highlight.getAttribute("data-suggestion-id");
|
||||
const found = suggestionsRef.current.find((s) => s.id === id);
|
||||
if (found) {
|
||||
setActiveSuggestion(found);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -72,8 +97,6 @@ function PureEditor({
|
|||
editorRef.current = null;
|
||||
}
|
||||
};
|
||||
// NOTE: we only want to run this effect once
|
||||
// eslint-disable-next-line
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -134,6 +157,8 @@ function PureEditor({
|
|||
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
|
||||
);
|
||||
|
||||
suggestionsRef.current = projectedSuggestions;
|
||||
|
||||
const decorations = createDecorations(
|
||||
projectedSuggestions,
|
||||
editorRef.current
|
||||
|
|
@ -145,8 +170,61 @@ function PureEditor({
|
|||
}
|
||||
}, [suggestions, content]);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
if (!editorRef.current || !activeSuggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { state, dispatch } = editorRef.current;
|
||||
const currentState = suggestionsPluginKey.getState(state);
|
||||
const currentDecorations = currentState?.decorations;
|
||||
|
||||
if (currentDecorations) {
|
||||
const newDecorations = DecorationSet.create(
|
||||
state.doc,
|
||||
currentDecorations.find().filter((decoration: Decoration) => {
|
||||
return decoration.spec.suggestionId !== activeSuggestion.id;
|
||||
})
|
||||
);
|
||||
|
||||
const decorationTransaction = state.tr;
|
||||
decorationTransaction.setMeta(suggestionsPluginKey, {
|
||||
decorations: newDecorations,
|
||||
selected: null,
|
||||
});
|
||||
dispatch(decorationTransaction);
|
||||
}
|
||||
|
||||
const textTransaction = editorRef.current.state.tr.replaceWith(
|
||||
activeSuggestion.selectionStart,
|
||||
activeSuggestion.selectionEnd,
|
||||
state.schema.text(activeSuggestion.suggestedText)
|
||||
);
|
||||
textTransaction.setMeta("no-debounce", true);
|
||||
dispatch(textTransaction);
|
||||
|
||||
setActiveSuggestion(null);
|
||||
}, [activeSuggestion]);
|
||||
|
||||
return (
|
||||
<div className="prose dark:prose-invert relative" ref={containerRef} />
|
||||
<>
|
||||
<div
|
||||
className="prose dark:prose-invert prose-neutral relative max-w-none"
|
||||
ref={containerRef}
|
||||
/>
|
||||
{activeSuggestion &&
|
||||
containerRef.current?.closest("[data-slot='artifact-content']") &&
|
||||
createPortal(
|
||||
<SuggestionDialog
|
||||
onApply={handleApply}
|
||||
onClose={() => setActiveSuggestion(null)}
|
||||
suggestion={activeSuggestion}
|
||||
/>,
|
||||
containerRef.current.closest(
|
||||
"[data-slot='artifact-content']"
|
||||
) as HTMLElement
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -34,8 +34,8 @@ function Toast(props: ToastProps) {
|
|||
setMultiLine(lines > 1);
|
||||
};
|
||||
|
||||
update(); // initial check
|
||||
const ro = new ResizeObserver(update); // re-check on width changes
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
|
||||
return () => ro.disconnect();
|
||||
|
|
@ -45,7 +45,7 @@ function Toast(props: ToastProps) {
|
|||
<div className="flex toast-mobile:w-[356px] w-full justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-zinc-100 p-3",
|
||||
"flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-card border border-border/50 shadow-[var(--shadow-float)] p-3",
|
||||
multiLine ? "items-start" : "items-center"
|
||||
)}
|
||||
data-testid="toast"
|
||||
|
|
@ -60,7 +60,7 @@ function Toast(props: ToastProps) {
|
|||
>
|
||||
{iconsByType[type]}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-950" ref={descriptionRef}>
|
||||
<div className="text-sm text-foreground" ref={descriptionRef}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
"use client";
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import cx from "classnames";
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { WrenchIcon, XIcon } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
type Dispatch,
|
||||
|
|
@ -245,24 +241,18 @@ const ReadingLevelSelector = ({
|
|||
};
|
||||
|
||||
export const Tools = ({
|
||||
isToolbarVisible,
|
||||
selectedTool,
|
||||
setSelectedTool,
|
||||
sendMessage,
|
||||
isAnimating,
|
||||
setIsToolbarVisible,
|
||||
tools,
|
||||
}: {
|
||||
isToolbarVisible: boolean;
|
||||
selectedTool: string | null;
|
||||
setSelectedTool: Dispatch<SetStateAction<string | null>>;
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
isAnimating: boolean;
|
||||
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
|
||||
tools: ArtifactToolbarItem[];
|
||||
}) => {
|
||||
const [primaryTool, ...secondaryTools] = tools;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
|
|
@ -270,45 +260,53 @@ export const Tools = ({
|
|||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isToolbarVisible &&
|
||||
secondaryTools.map((secondaryTool) => (
|
||||
<Tool
|
||||
description={secondaryTool.description}
|
||||
icon={secondaryTool.icon}
|
||||
isAnimating={isAnimating}
|
||||
key={secondaryTool.description}
|
||||
onClick={secondaryTool.onClick}
|
||||
selectedTool={selectedTool}
|
||||
sendMessage={sendMessage}
|
||||
setSelectedTool={setSelectedTool}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<Tool
|
||||
description={primaryTool.description}
|
||||
icon={primaryTool.icon}
|
||||
isAnimating={isAnimating}
|
||||
isToolbarVisible={isToolbarVisible}
|
||||
onClick={primaryTool.onClick}
|
||||
selectedTool={selectedTool}
|
||||
sendMessage={sendMessage}
|
||||
setIsToolbarVisible={setIsToolbarVisible}
|
||||
setSelectedTool={setSelectedTool}
|
||||
/>
|
||||
{[...tools].reverse().map((tool) => (
|
||||
<Tool
|
||||
description={tool.description}
|
||||
icon={tool.icon}
|
||||
isAnimating={isAnimating}
|
||||
key={tool.description}
|
||||
onClick={tool.onClick}
|
||||
selectedTool={selectedTool}
|
||||
sendMessage={sendMessage}
|
||||
setSelectedTool={setSelectedTool}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const createFixErrorTool = (
|
||||
consoleOutput: string,
|
||||
documentId?: string
|
||||
): ArtifactToolbarItem => ({
|
||||
icon: <WrenchIcon className="size-4" />,
|
||||
description: "Fix error",
|
||||
onClick: ({ sendMessage: send }) => {
|
||||
send({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Fix the error in the existing script${documentId ? ` (id: ${documentId})` : ""} using updateDocument. Do not create a new script. Console error:\n\n${consoleOutput}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const PureToolbar = ({
|
||||
isToolbarVisible,
|
||||
isToolbarVisible: _isToolbarVisible,
|
||||
setIsToolbarVisible,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
setMessages,
|
||||
artifactKind,
|
||||
consoleError,
|
||||
documentId,
|
||||
artifactActions,
|
||||
onClose,
|
||||
}: {
|
||||
isToolbarVisible: boolean;
|
||||
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
|
||||
|
|
@ -317,6 +315,10 @@ const PureToolbar = ({
|
|||
stop: UseChatHelpers<ChatMessage>["stop"];
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
artifactKind: ArtifactKind;
|
||||
consoleError?: string;
|
||||
documentId?: string;
|
||||
artifactActions?: ReactNode;
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
|
@ -368,7 +370,12 @@ const PureToolbar = ({
|
|||
throw new Error("Artifact definition not found!");
|
||||
}
|
||||
|
||||
const toolsByArtifactKind = artifactDefinition.toolbar;
|
||||
const toolsByArtifactKind = consoleError
|
||||
? [
|
||||
createFixErrorTool(consoleError, documentId),
|
||||
...artifactDefinition.toolbar.slice(1),
|
||||
]
|
||||
: artifactDefinition.toolbar;
|
||||
|
||||
if (toolsByArtifactKind.length === 0) {
|
||||
return null;
|
||||
|
|
@ -377,26 +384,8 @@ const PureToolbar = ({
|
|||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<motion.div
|
||||
animate={
|
||||
isToolbarVisible
|
||||
? selectedTool === "adjust-reading-level"
|
||||
? {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: 6 * 43,
|
||||
transition: { delay: 0 },
|
||||
scale: 0.95,
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: toolsByArtifactKind.length * 50,
|
||||
transition: { delay: 0 },
|
||||
scale: 1,
|
||||
}
|
||||
: { opacity: 1, y: 0, height: 54, transition: { delay: 0 } }
|
||||
}
|
||||
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
className="fixed right-6 bottom-6 z-50 flex cursor-pointer flex-col items-center rounded-3xl border bg-background py-1 shadow-lg"
|
||||
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
|
||||
initial={{ opacity: 0, y: -20, scale: 1 }}
|
||||
onAnimationComplete={() => {
|
||||
|
|
@ -423,6 +412,17 @@ const PureToolbar = ({
|
|||
ref={toolbarRef}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
>
|
||||
{onClose && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-3 text-muted-foreground transition-colors hover:text-foreground"
|
||||
initial={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === "streaming" ? (
|
||||
<motion.div
|
||||
animate={{ scale: 1.4 }}
|
||||
|
|
@ -445,16 +445,17 @@ const PureToolbar = ({
|
|||
setSelectedTool={setSelectedTool}
|
||||
/>
|
||||
) : (
|
||||
<Tools
|
||||
isAnimating={isAnimating}
|
||||
isToolbarVisible={isToolbarVisible}
|
||||
key="tools"
|
||||
selectedTool={selectedTool}
|
||||
sendMessage={sendMessage}
|
||||
setIsToolbarVisible={setIsToolbarVisible}
|
||||
setSelectedTool={setSelectedTool}
|
||||
tools={toolsByArtifactKind}
|
||||
/>
|
||||
<>
|
||||
{artifactActions}
|
||||
<Tools
|
||||
isAnimating={isAnimating}
|
||||
key="tools"
|
||||
selectedTool={selectedTool}
|
||||
sendMessage={sendMessage}
|
||||
setSelectedTool={setSelectedTool}
|
||||
tools={toolsByArtifactKind}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</TooltipProvider>
|
||||
|
|
@ -471,6 +472,15 @@ export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
|
|||
if (prevProps.artifactKind !== nextProps.artifactKind) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.consoleError !== nextProps.consoleError) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.artifactActions !== nextProps.artifactActions) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.onClose !== nextProps.onClose) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
148
components/chat/version-footer.tsx
Normal file
148
components/chat/version-footer.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { isAfter } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, DiffIcon } from "lucide-react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useArtifact } from "@/hooks/use-artifact";
|
||||
import type { Document } from "@/lib/db/schema";
|
||||
import { cn, getDocumentTimestampByIndex } from "@/lib/utils";
|
||||
import { LoaderIcon } from "./icons";
|
||||
|
||||
type VersionFooterProps = {
|
||||
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
|
||||
documents: Document[] | undefined;
|
||||
currentVersionIndex: number;
|
||||
mode: "edit" | "diff";
|
||||
setMode: Dispatch<SetStateAction<"edit" | "diff">>;
|
||||
};
|
||||
|
||||
export const VersionFooter = ({
|
||||
handleVersionChange,
|
||||
documents,
|
||||
currentVersionIndex,
|
||||
mode,
|
||||
setMode,
|
||||
}: VersionFooterProps) => {
|
||||
const { artifact } = useArtifact();
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
|
||||
if (!documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirst = currentVersionIndex === 0;
|
||||
const isLast = currentVersionIndex === documents.length - 1;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="z-50 flex w-full shrink-0 items-center justify-between gap-3 border-t border-border/50 bg-background px-4 py-3"
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
disabled={isFirst}
|
||||
onClick={() => handleVersionChange("prev")}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</button>
|
||||
<span className="min-w-[4rem] text-center text-xs tabular-nums text-muted-foreground">
|
||||
{currentVersionIndex + 1} of {documents.length}
|
||||
</span>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
disabled={isLast}
|
||||
onClick={() => handleVersionChange("next")}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
mode === "diff" && "bg-muted text-foreground"
|
||||
)}
|
||||
onClick={() => setMode(mode === "diff" ? "edit" : "diff")}
|
||||
title="Show changes"
|
||||
type="button"
|
||||
>
|
||||
<DiffIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-foreground px-3 py-1.5 text-sm font-medium text-background transition-all duration-150 hover:opacity-90 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={isMutating}
|
||||
onClick={async () => {
|
||||
setIsMutating(true);
|
||||
|
||||
try {
|
||||
await mutate(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`,
|
||||
await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}×tamp=${getDocumentTimestampByIndex(
|
||||
documents,
|
||||
currentVersionIndex
|
||||
)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
),
|
||||
{
|
||||
optimisticData: documents
|
||||
? [
|
||||
...documents.filter((document) =>
|
||||
isAfter(
|
||||
new Date(document.createdAt),
|
||||
new Date(
|
||||
getDocumentTimestampByIndex(
|
||||
documents,
|
||||
currentVersionIndex
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
: [],
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
setIsMutating(false);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Restore
|
||||
{isMutating && (
|
||||
<div className="animate-spin">
|
||||
<LoaderIcon size={14} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-border px-3 py-1.5 text-sm font-medium transition-all duration-150 hover:bg-muted active:scale-[0.98]"
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
handleVersionChange("latest");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Latest
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -68,7 +68,12 @@ export function VisibilitySelector({
|
|||
className
|
||||
)}
|
||||
>
|
||||
<Button data-testid="visibility-selector" size="sm" variant="outline">
|
||||
<Button
|
||||
className="gap-1.5 rounded-lg border-border/50 text-muted-foreground shadow-none transition-colors hover:text-foreground focus-visible:ring-0 focus-visible:border-border/50 active:translate-y-0"
|
||||
data-testid="visibility-selector"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{selectedVisibility?.icon}
|
||||
<span className="md:sr-only">{selectedVisibility?.label}</span>
|
||||
<ChevronDownIcon />
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { ArtifactKind } from "./artifact";
|
||||
|
||||
export const DocumentSkeleton = ({
|
||||
artifactKind,
|
||||
}: {
|
||||
artifactKind: ArtifactKind;
|
||||
}) => {
|
||||
return artifactKind === "image" ? (
|
||||
<div className="flex h-[calc(100dvh-60px)] w-full flex-col items-center justify-center gap-4">
|
||||
<div className="size-96 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="h-12 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-1/3 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-52 animate-pulse rounded-lg bg-transparent" />
|
||||
<div className="h-8 w-52 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-2/3 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InlineDocumentSkeleton = () => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="h-4 w-48 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-40 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-36 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { motion } from "framer-motion";
|
||||
|
||||
export const Greeting = () => {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto mt-4 flex size-full max-w-3xl flex-col justify-center px-4 md:mt-16 md:px-8"
|
||||
key="overview"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="font-semibold text-xl md:text-2xl"
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
Hello there!
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-xl text-zinc-500 md:text-2xl"
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
How can I help you today?
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { deleteTrailingMessages } from "@/app/(chat)/actions";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { getTextFromMessage } from "@/lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
export type MessageEditorProps = {
|
||||
message: ChatMessage;
|
||||
setMode: Dispatch<SetStateAction<"view" | "edit">>;
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
};
|
||||
|
||||
export function MessageEditor({
|
||||
message,
|
||||
setMode,
|
||||
setMessages,
|
||||
regenerate,
|
||||
}: MessageEditorProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const [draftContent, setDraftContent] = useState<string>(
|
||||
getTextFromMessage(message)
|
||||
);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
adjustHeight();
|
||||
}
|
||||
}, [adjustHeight]);
|
||||
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDraftContent(event.target.value);
|
||||
adjustHeight();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Textarea
|
||||
className="w-full resize-none overflow-hidden rounded-xl bg-transparent text-base! outline-hidden"
|
||||
data-testid="message-editor"
|
||||
onChange={handleInput}
|
||||
ref={textareaRef}
|
||||
value={draftContent}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<Button
|
||||
className="h-fit px-3 py-2"
|
||||
onClick={() => {
|
||||
setMode("view");
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="h-fit px-3 py-2"
|
||||
data-testid="message-editor-send-button"
|
||||
disabled={isSubmitting}
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
await deleteTrailingMessages({
|
||||
id: message.id,
|
||||
});
|
||||
|
||||
setMessages((messages) => {
|
||||
const index = messages.findIndex((m) => m.id === message.id);
|
||||
|
||||
if (index !== -1) {
|
||||
const updatedMessage: ChatMessage = {
|
||||
...message,
|
||||
parts: [{ type: "text", text: draftContent }],
|
||||
};
|
||||
|
||||
return [...messages.slice(0, index), updatedMessage];
|
||||
}
|
||||
|
||||
return messages;
|
||||
});
|
||||
|
||||
setMode("view");
|
||||
regenerate();
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,395 +0,0 @@
|
|||
"use client";
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { useState } from "react";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { cn, sanitizeText } from "@/lib/utils";
|
||||
import { MessageContent, MessageResponse } from "./ai-elements/message";
|
||||
import {
|
||||
Tool,
|
||||
ToolContent,
|
||||
ToolHeader,
|
||||
ToolInput,
|
||||
ToolOutput,
|
||||
} from "./ai-elements/tool";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { DocumentToolResult } from "./document";
|
||||
import { DocumentPreview } from "./document-preview";
|
||||
import { SparklesIcon } from "./icons";
|
||||
import { MessageActions } from "./message-actions";
|
||||
import { MessageEditor } from "./message-editor";
|
||||
import { MessageReasoning } from "./message-reasoning";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import { Weather } from "./weather";
|
||||
|
||||
const PurePreviewMessage = ({
|
||||
addToolApprovalResponse,
|
||||
chatId,
|
||||
message,
|
||||
vote,
|
||||
isLoading,
|
||||
setMessages,
|
||||
regenerate,
|
||||
isReadonly,
|
||||
requiresScrollPadding: _requiresScrollPadding,
|
||||
}: {
|
||||
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
||||
chatId: string;
|
||||
message: ChatMessage;
|
||||
vote: Vote | undefined;
|
||||
isLoading: boolean;
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
requiresScrollPadding: boolean;
|
||||
}) => {
|
||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||
|
||||
const attachmentsFromMessage = message.parts.filter(
|
||||
(part) => part.type === "file"
|
||||
);
|
||||
|
||||
useDataStream();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/message fade-in w-full animate-in duration-200"
|
||||
data-role={message.role}
|
||||
data-testid={`message-${message.role}`}
|
||||
>
|
||||
<div
|
||||
className={cn("flex w-full items-start gap-2 md:gap-3", {
|
||||
"justify-end": message.role === "user" && mode !== "edit",
|
||||
"justify-start": message.role === "assistant",
|
||||
})}
|
||||
>
|
||||
{message.role === "assistant" && (
|
||||
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn("flex flex-col", {
|
||||
"gap-2 md:gap-4": message.parts?.some(
|
||||
(p) => p.type === "text" && p.text?.trim()
|
||||
),
|
||||
"w-full":
|
||||
(message.role === "assistant" &&
|
||||
(message.parts?.some(
|
||||
(p) => p.type === "text" && p.text?.trim()
|
||||
) ||
|
||||
message.parts?.some((p) => p.type.startsWith("tool-")))) ||
|
||||
mode === "edit",
|
||||
"max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]":
|
||||
message.role === "user" && mode !== "edit",
|
||||
})}
|
||||
>
|
||||
{attachmentsFromMessage.length > 0 && (
|
||||
<div
|
||||
className="flex flex-row justify-end gap-2"
|
||||
data-testid={"message-attachments"}
|
||||
>
|
||||
{attachmentsFromMessage.map((attachment) => (
|
||||
<PreviewAttachment
|
||||
attachment={{
|
||||
name: attachment.filename ?? "file",
|
||||
contentType: attachment.mediaType,
|
||||
url: attachment.url,
|
||||
}}
|
||||
key={attachment.url}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.parts?.map((part, index) => {
|
||||
const { type } = part;
|
||||
const key = `message-${message.id}-part-${index}`;
|
||||
|
||||
if (type === "reasoning") {
|
||||
const hasContent = part.text?.trim().length > 0;
|
||||
if (hasContent) {
|
||||
const isStreaming =
|
||||
"state" in part && part.state === "streaming";
|
||||
return (
|
||||
<MessageReasoning
|
||||
isLoading={isLoading || isStreaming}
|
||||
key={key}
|
||||
reasoning={part.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
if (mode === "view") {
|
||||
return (
|
||||
<div key={key}>
|
||||
<MessageContent
|
||||
className={cn({
|
||||
"wrap-break-word w-fit rounded-2xl px-3 py-2 text-right text-white":
|
||||
message.role === "user",
|
||||
"bg-transparent px-0 py-0 text-left":
|
||||
message.role === "assistant",
|
||||
})}
|
||||
data-testid="message-content"
|
||||
style={
|
||||
message.role === "user"
|
||||
? { backgroundColor: "#006cff" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MessageResponse>
|
||||
{sanitizeText(part.text)}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "edit") {
|
||||
return (
|
||||
<div
|
||||
className="flex w-full flex-row items-start gap-3"
|
||||
key={key}
|
||||
>
|
||||
<div className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessageEditor
|
||||
key={message.id}
|
||||
message={message}
|
||||
regenerate={regenerate}
|
||||
setMessages={setMessages}
|
||||
setMode={setMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "tool-getWeather") {
|
||||
const { toolCallId, state } = part;
|
||||
const approvalId = (part as { approval?: { id: string } })
|
||||
.approval?.id;
|
||||
const isDenied =
|
||||
state === "output-denied" ||
|
||||
(state === "approval-responded" &&
|
||||
(part as { approval?: { approved?: boolean } }).approval
|
||||
?.approved === false);
|
||||
const widthClass = "w-[min(100%,450px)]";
|
||||
|
||||
if (state === "output-available") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Weather weatherAtLocation={part.output} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDenied) {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader
|
||||
state="output-denied"
|
||||
type="tool-getWeather"
|
||||
/>
|
||||
<ToolContent>
|
||||
<div className="px-4 py-3 text-muted-foreground text-sm">
|
||||
Weather lookup was denied.
|
||||
</div>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "approval-responded") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state={state} type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
<ToolInput input={part.input} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state={state} type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
{(state === "input-available" ||
|
||||
state === "approval-requested") && (
|
||||
<ToolInput input={part.input} />
|
||||
)}
|
||||
{state === "approval-requested" && approvalId && (
|
||||
<div className="flex items-center justify-end gap-2 border-t px-4 py-3">
|
||||
<button
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
reason: "User denied weather lookup",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm transition-colors hover:bg-primary/90"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: true,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "tool-createDocument") {
|
||||
const { toolCallId } = part;
|
||||
|
||||
if (part.output && "error" in part.output) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
|
||||
key={toolCallId}
|
||||
>
|
||||
Error creating document: {String(part.output.error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentPreview
|
||||
isReadonly={isReadonly}
|
||||
key={toolCallId}
|
||||
result={part.output}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "tool-updateDocument") {
|
||||
const { toolCallId } = part;
|
||||
|
||||
if (part.output && "error" in part.output) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
|
||||
key={toolCallId}
|
||||
>
|
||||
Error updating document: {String(part.output.error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={toolCallId}>
|
||||
<DocumentPreview
|
||||
args={{ ...part.output, isUpdate: true }}
|
||||
isReadonly={isReadonly}
|
||||
result={part.output}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "tool-requestSuggestions") {
|
||||
const { toolCallId, state } = part;
|
||||
|
||||
return (
|
||||
<Tool defaultOpen={true} key={toolCallId}>
|
||||
<ToolHeader state={state} type="tool-requestSuggestions" />
|
||||
<ToolContent>
|
||||
{state === "input-available" && (
|
||||
<ToolInput input={part.input} />
|
||||
)}
|
||||
{state === "output-available" && (
|
||||
<ToolOutput
|
||||
errorText={undefined}
|
||||
output={
|
||||
"error" in part.output ? (
|
||||
<div className="rounded border p-2 text-red-500">
|
||||
Error: {String(part.output.error)}
|
||||
</div>
|
||||
) : (
|
||||
<DocumentToolResult
|
||||
isReadonly={isReadonly}
|
||||
result={part.output}
|
||||
type="request-suggestions"
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
{!isReadonly && (
|
||||
<MessageActions
|
||||
chatId={chatId}
|
||||
isLoading={isLoading}
|
||||
key={`action-${message.id}`}
|
||||
message={message}
|
||||
setMode={setMode}
|
||||
vote={vote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PreviewMessage = PurePreviewMessage;
|
||||
|
||||
export const ThinkingMessage = () => {
|
||||
return (
|
||||
<div
|
||||
className="group/message fade-in w-full animate-in duration-300"
|
||||
data-role="assistant"
|
||||
data-testid="message-assistant-loading"
|
||||
>
|
||||
<div className="flex items-start justify-start gap-3">
|
||||
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
|
||||
<div className="animate-pulse">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 md:gap-4">
|
||||
<div className="flex items-center gap-1 p-0 text-muted-foreground text-sm">
|
||||
<span className="animate-pulse">Thinking</span>
|
||||
<span className="inline-flex">
|
||||
<span className="animate-bounce [animation-delay:0ms]">.</span>
|
||||
<span className="animate-bounce [animation-delay:150ms]">.</span>
|
||||
<span className="animate-bounce [animation-delay:300ms]">.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
|
||||
import type { UISuggestion } from "@/lib/editor/suggestions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ArtifactKind } from "./artifact";
|
||||
import { CrossIcon, MessageIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onApply,
|
||||
artifactKind,
|
||||
}: {
|
||||
suggestion: UISuggestion;
|
||||
onApply: () => void;
|
||||
artifactKind: ArtifactKind;
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isExpanded ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: -20 }}
|
||||
className="absolute -right-12 z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl md:-right-16"
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
key={suggestion.id}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="size-4 rounded-full bg-muted-foreground/25" />
|
||||
<div className="font-medium">Assistant</div>
|
||||
</div>
|
||||
<button
|
||||
className="cursor-pointer text-gray-500 text-xs"
|
||||
onClick={() => {
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<CrossIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div>{suggestion.description}</div>
|
||||
<Button
|
||||
className="w-fit rounded-full px-3 py-1.5"
|
||||
onClick={onApply}
|
||||
variant="outline"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className={cn("cursor-pointer p-1 text-muted-foreground", {
|
||||
"absolute -right-8": artifactKind === "text",
|
||||
"sticky top-0 right-4": artifactKind === "code",
|
||||
})}
|
||||
onClick={() => {
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
>
|
||||
<MessageIcon size={windowWidth && windowWidth < 768 ? 16 : 14} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
|
@ -36,7 +36,7 @@ function AlertDialogOverlay({
|
|||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -58,7 +58,7 @@ function AlertDialogContent({
|
|||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-lg bg-background p-6 ring-1 ring-foreground/5 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -99,6 +99,22 @@ function AlertDialogFooter({
|
|||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-16 items-center justify-center rounded-full bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
|
|
@ -107,7 +123,7 @@ function AlertDialogTitle({
|
|||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
"text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -122,21 +138,8 @@ function AlertDialogDescription({
|
|||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -5,19 +5,20 @@ import { Slot } from "radix-ui"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
"border-border bg-input/30 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ import { cn } from "@/lib/utils"
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl!",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -49,7 +49,7 @@ function ButtonGroupText({
|
|||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex items-center gap-2 rounded-4xl border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -67,7 +67,7 @@ function ButtonGroupSeparator({
|
|||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -5,28 +5,29 @@ import { Slot } from "radix-ui"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none active:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
default:
|
||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
|
|
@ -12,6 +11,11 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
} from "@/components/ui/input-group"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
|
|
@ -21,7 +25,7 @@ function Command({
|
|||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
"flex size-full flex-col overflow-hidden rounded-4xl bg-popover p-1 text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -34,7 +38,7 @@ function CommandDialog({
|
|||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
|
|
@ -49,12 +53,13 @@ function CommandDialog({
|
|||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
className={cn(
|
||||
"top-1/3 translate-y-0 overflow-hidden rounded-4xl! p-0",
|
||||
className
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
@ -65,19 +70,20 @@ function CommandInput({
|
|||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-9 bg-input/30">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -90,7 +96,7 @@ function CommandList({
|
|||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -99,12 +105,13 @@ function CommandList({
|
|||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -118,7 +125,7 @@ function CommandGroup({
|
|||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-3 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -133,7 +140,7 @@ function CommandSeparator({
|
|||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
className={cn("my-1 h-px bg-border/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -141,17 +148,20 @@ function CommandSeparator({
|
|||
|
||||
function CommandItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-lg px-3 py-2 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-2xl data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</CommandPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +173,7 @@ function CommandShortcut({
|
|||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
|
|
@ -39,7 +39,7 @@ function DialogOverlay({
|
|||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -56,24 +56,28 @@ function DialogContent({
|
|||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-background p-6 text-sm ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-4 right-4"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
|
|
@ -85,7 +89,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -125,7 +129,7 @@ function DialogTitle({
|
|||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -138,7 +142,10 @@ function DialogDescription({
|
|||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue