Compare commits
No commits in common. "2becdb4a56e7683ae08aef927cec1c6c52dfad5e" and "06b61bd82905164883ed119c5f5fe59915f5d1ca" have entirely different histories.
2becdb4a56
...
06b61bd829
178 changed files with 8416 additions and 5547 deletions
19
.env.example
19
.env.example
|
|
@ -1,15 +1,24 @@
|
||||||
# generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
|
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
|
||||||
AUTH_SECRET=****
|
BETTER_AUTH_SECRET=****
|
||||||
|
BETTER_AUTH_URL=****
|
||||||
|
|
||||||
# required for non-vercel deployments, vercel uses OIDC automatically
|
# 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
|
||||||
# https://vercel.com/ai-gateway
|
# https://vercel.com/ai-gateway
|
||||||
AI_GATEWAY_API_KEY=****
|
AI_GATEWAY_API_KEY=****
|
||||||
|
|
||||||
# https://vercel.com/docs/vercel-blob
|
|
||||||
|
# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/vercel-blob
|
||||||
BLOB_READ_WRITE_TOKEN=****
|
BLOB_READ_WRITE_TOKEN=****
|
||||||
|
|
||||||
# https://vercel.com/docs/postgres
|
# Instructions to create a PostgreSQL database here: https://vercel.com/docs/postgres
|
||||||
POSTGRES_URL=****
|
POSTGRES_URL=****
|
||||||
|
|
||||||
|
|
||||||
|
# Instructions to create a Redis store here:
|
||||||
# https://vercel.com/docs/redis
|
# https://vercel.com/docs/redis
|
||||||
REDIS_URL=****
|
REDIS_URL=****
|
||||||
|
|
|
||||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10.32.1
|
version: 9.12.3
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
21
.gitignore
vendored
21
.gitignore
vendored
|
|
@ -1,27 +1,46 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
build
|
build
|
||||||
|
|
||||||
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# local env files
|
||||||
.env.local
|
.env.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
|
# turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
.env
|
.env
|
||||||
.vercel
|
.vercel
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
# Playwright
|
||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/*
|
/playwright/*
|
||||||
|
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
tsconfig.tsbuildinfo
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<a href="https://chatbot.ai-sdk.dev/demo">
|
<a href="https://chat.vercel.ai/">
|
||||||
<img alt="Chatbot" src="app/(chat)/opengraph-image.png">
|
<img alt="Chatbot" src="app/(chat)/opengraph-image.png">
|
||||||
<h1 align="center">Chatbot</h1>
|
<h1 align="center">Chatbot</h1>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://chatbot.ai-sdk.dev/docs"><strong>Read Docs</strong></a> ·
|
<a href="https://chatbot.dev"><strong>Read Docs</strong></a> ·
|
||||||
<a href="#features"><strong>Features</strong></a> ·
|
<a href="#features"><strong>Features</strong></a> ·
|
||||||
<a href="#model-providers"><strong>Model Providers</strong></a> ·
|
<a href="#model-providers"><strong>Model Providers</strong></a> ·
|
||||||
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
|
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
|
|
||||||
## Model Providers
|
## Model Providers
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### AI Gateway Authentication
|
### AI Gateway Authentication
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import { createUser, getUser } from "@/lib/db/queries";
|
|
||||||
|
|
||||||
import { signIn } from "./auth";
|
|
||||||
|
|
||||||
const authFormSchema = z.object({
|
const authFormSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
|
|
@ -25,10 +22,11 @@ export const login = async (
|
||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
});
|
});
|
||||||
|
|
||||||
await signIn("credentials", {
|
await auth.api.signInEmail({
|
||||||
email: validatedData.email,
|
body: {
|
||||||
password: validatedData.password,
|
email: validatedData.email,
|
||||||
redirect: false,
|
password: validatedData.password,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { status: "success" };
|
return { status: "success" };
|
||||||
|
|
@ -61,24 +59,29 @@ export const register = async (
|
||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [user] = await getUser(validatedData.email);
|
const result = await auth.api.signUpEmail({
|
||||||
|
body: {
|
||||||
if (user) {
|
email: validatedData.email,
|
||||||
return { status: "user_exists" } as RegisterActionState;
|
password: validatedData.password,
|
||||||
}
|
name: validatedData.email,
|
||||||
await createUser(validatedData.email, validatedData.password);
|
},
|
||||||
await signIn("credentials", {
|
|
||||||
email: validatedData.email,
|
|
||||||
password: validatedData.password,
|
|
||||||
redirect: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return { status: "failed" };
|
||||||
|
}
|
||||||
|
|
||||||
return { status: "success" };
|
return { status: "success" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return { status: "invalid_data" };
|
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" };
|
return { status: "failed" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { GET, POST } from "@/app/(auth)/auth";
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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,30 +2,36 @@
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useActionState, useEffect, useState } from "react";
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { AuthForm } from "@/components/chat/auth-form";
|
import { AuthForm } from "@/components/auth-form";
|
||||||
import { SubmitButton } from "@/components/chat/submit-button";
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
import { toast } from "@/components/chat/toast";
|
import { toast } from "@/components/toast";
|
||||||
|
import { useSession } from "@/lib/client";
|
||||||
import { type LoginActionState, login } from "../actions";
|
import { type LoginActionState, login } from "../actions";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [isSuccessful, setIsSuccessful] = useState(false);
|
const [isSuccessful, setIsSuccessful] = useState(false);
|
||||||
|
|
||||||
const [state, formAction] = useActionState<LoginActionState, FormData>(
|
const [state, formAction] = useActionState<LoginActionState, FormData>(
|
||||||
login,
|
login,
|
||||||
{ status: "idle" }
|
{
|
||||||
|
status: "idle",
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { update: updateSession } = useSession();
|
const { refetch } = useSession();
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
|
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.status === "failed") {
|
if (state.status === "failed") {
|
||||||
toast({ type: "error", description: "Invalid credentials!" });
|
toast({
|
||||||
|
type: "error",
|
||||||
|
description: "Invalid credentials!",
|
||||||
|
});
|
||||||
} else if (state.status === "invalid_data") {
|
} else if (state.status === "invalid_data") {
|
||||||
toast({
|
toast({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
|
@ -33,8 +39,8 @@ export default function Page() {
|
||||||
});
|
});
|
||||||
} else if (state.status === "success") {
|
} else if (state.status === "success") {
|
||||||
setIsSuccessful(true);
|
setIsSuccessful(true);
|
||||||
updateSession();
|
refetch();
|
||||||
router.refresh();
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, [state.status]);
|
}, [state.status]);
|
||||||
|
|
||||||
|
|
@ -44,23 +50,28 @@ export default function Page() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
|
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
|
||||||
Sign in to your account to continue
|
<h3 className="font-semibold text-xl dark:text-zinc-50">Sign In</h3>
|
||||||
</p>
|
<p className="text-gray-500 text-sm dark:text-zinc-400">
|
||||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
Use your email and password to sign in
|
||||||
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
|
</p>
|
||||||
<p className="text-center text-[13px] text-muted-foreground">
|
</div>
|
||||||
{"No account? "}
|
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||||
<Link
|
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
|
||||||
className="text-foreground underline-offset-4 hover:underline"
|
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
|
||||||
href="/register"
|
{"Don't have an account? "}
|
||||||
>
|
<Link
|
||||||
Sign up
|
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
|
||||||
</Link>
|
href="/register"
|
||||||
</p>
|
>
|
||||||
</AuthForm>
|
Sign up
|
||||||
</>
|
</Link>
|
||||||
|
{" for free."}
|
||||||
|
</p>
|
||||||
|
</AuthForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,29 @@
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { useActionState, useEffect, useState } from "react";
|
import { useActionState, useEffect, useState } from "react";
|
||||||
import { AuthForm } from "@/components/chat/auth-form";
|
import { AuthForm } from "@/components/auth-form";
|
||||||
import { SubmitButton } from "@/components/chat/submit-button";
|
import { SubmitButton } from "@/components/submit-button";
|
||||||
import { toast } from "@/components/chat/toast";
|
import { toast } from "@/components/toast";
|
||||||
|
import { useSession } from "@/lib/client";
|
||||||
import { type RegisterActionState, register } from "../actions";
|
import { type RegisterActionState, register } from "../actions";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [isSuccessful, setIsSuccessful] = useState(false);
|
const [isSuccessful, setIsSuccessful] = useState(false);
|
||||||
|
|
||||||
const [state, formAction] = useActionState<RegisterActionState, FormData>(
|
const [state, formAction] = useActionState<RegisterActionState, FormData>(
|
||||||
register,
|
register,
|
||||||
{ status: "idle" }
|
{
|
||||||
|
status: "idle",
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { update: updateSession } = useSession();
|
const { refetch } = useSession();
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
|
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.status === "user_exists") {
|
if (state.status === "user_exists") {
|
||||||
toast({ type: "error", description: "Account already exists!" });
|
toast({ type: "error", description: "Account already exists!" });
|
||||||
|
|
@ -33,10 +36,11 @@ export default function Page() {
|
||||||
description: "Failed validating your submission!",
|
description: "Failed validating your submission!",
|
||||||
});
|
});
|
||||||
} else if (state.status === "success") {
|
} else if (state.status === "success") {
|
||||||
toast({ type: "success", description: "Account created!" });
|
toast({ type: "success", description: "Account created successfully!" });
|
||||||
|
|
||||||
setIsSuccessful(true);
|
setIsSuccessful(true);
|
||||||
updateSession();
|
refetch();
|
||||||
router.refresh();
|
router.push("/");
|
||||||
}
|
}
|
||||||
}, [state.status]);
|
}, [state.status]);
|
||||||
|
|
||||||
|
|
@ -46,21 +50,28 @@ export default function Page() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Create account</h1>
|
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
|
||||||
<p className="text-sm text-muted-foreground">Get started for free</p>
|
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
|
||||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
<h3 className="font-semibold text-xl dark:text-zinc-50">Sign Up</h3>
|
||||||
<SubmitButton isSuccessful={isSuccessful}>Sign up</SubmitButton>
|
<p className="text-gray-500 text-sm dark:text-zinc-400">
|
||||||
<p className="text-center text-[13px] text-muted-foreground">
|
Create an account with your email and password
|
||||||
{"Have an account? "}
|
</p>
|
||||||
<Link
|
</div>
|
||||||
className="text-foreground underline-offset-4 hover:underline"
|
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||||
href="/login"
|
<SubmitButton isSuccessful={isSuccessful}>Sign Up</SubmitButton>
|
||||||
>
|
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
|
||||||
Sign in
|
{"Already have an account? "}
|
||||||
</Link>
|
<Link
|
||||||
</p>
|
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
|
||||||
</AuthForm>
|
href="/login"
|
||||||
</>
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
{" instead."}
|
||||||
|
</p>
|
||||||
|
</AuthForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,11 @@
|
||||||
|
|
||||||
import { generateText, type UIMessage } from "ai";
|
import { generateText, type UIMessage } from "ai";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import type { VisibilityType } from "@/components/visibility-selector";
|
||||||
import type { VisibilityType } from "@/components/chat/visibility-selector";
|
|
||||||
import { titleModel } from "@/lib/ai/models";
|
|
||||||
import { titlePrompt } from "@/lib/ai/prompts";
|
import { titlePrompt } from "@/lib/ai/prompts";
|
||||||
import { getTitleModel } from "@/lib/ai/providers";
|
import { getTitleModel } from "@/lib/ai/providers";
|
||||||
import {
|
import {
|
||||||
deleteMessagesByChatIdAfterTimestamp,
|
deleteMessagesByChatIdAfterTimestamp,
|
||||||
getChatById,
|
|
||||||
getMessageById,
|
getMessageById,
|
||||||
updateChatVisibilityById,
|
updateChatVisibilityById,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
|
|
@ -29,9 +26,6 @@ export async function generateTitleFromUserMessage({
|
||||||
model: getTitleModel(),
|
model: getTitleModel(),
|
||||||
system: titlePrompt,
|
system: titlePrompt,
|
||||||
prompt: getTextFromMessage(message),
|
prompt: getTextFromMessage(message),
|
||||||
providerOptions: {
|
|
||||||
gateway: { order: titleModel.gatewayOrder },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return text
|
return text
|
||||||
.replace(/^[#*"\s]+/, "")
|
.replace(/^[#*"\s]+/, "")
|
||||||
|
|
@ -40,20 +34,7 @@ export async function generateTitleFromUserMessage({
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTrailingMessages({ id }: { id: string }) {
|
export async function deleteTrailingMessages({ id }: { id: string }) {
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [message] = await getMessageById({ id });
|
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({
|
await deleteMessagesByChatIdAfterTimestamp({
|
||||||
chatId: message.chatId,
|
chatId: message.chatId,
|
||||||
|
|
@ -68,15 +49,5 @@ export async function updateChatVisibility({
|
||||||
chatId: string;
|
chatId: string;
|
||||||
visibility: VisibilityType;
|
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 });
|
await updateChatVisibilityById({ chatId, visibility });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
app/(chat)/api/auth/[...all]/route.ts
Normal file
4
app/(chat)/api/auth/[...all]/route.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
|
|
@ -10,21 +10,15 @@ import {
|
||||||
import { checkBotId } from "botid/server";
|
import { checkBotId } from "botid/server";
|
||||||
import { after } from "next/server";
|
import { after } from "next/server";
|
||||||
import { createResumableStreamContext } from "resumable-stream";
|
import { createResumableStreamContext } from "resumable-stream";
|
||||||
import { auth, type UserType } from "@/app/(auth)/auth";
|
|
||||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||||
import {
|
import { allowedModelIds } from "@/lib/ai/models";
|
||||||
allowedModelIds,
|
|
||||||
chatModels,
|
|
||||||
DEFAULT_CHAT_MODEL,
|
|
||||||
getCapabilities,
|
|
||||||
} from "@/lib/ai/models";
|
|
||||||
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
|
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
|
||||||
import { getLanguageModel } from "@/lib/ai/providers";
|
import { getLanguageModel } from "@/lib/ai/providers";
|
||||||
import { createDocument } from "@/lib/ai/tools/create-document";
|
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 { getWeather } from "@/lib/ai/tools/get-weather";
|
||||||
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
|
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
|
||||||
import { updateDocument } from "@/lib/ai/tools/update-document";
|
import { updateDocument } from "@/lib/ai/tools/update-document";
|
||||||
|
import { getSession, getUserType, type UserType } from "@/lib/auth";
|
||||||
import { isProductionEnvironment } from "@/lib/constants";
|
import { isProductionEnvironment } from "@/lib/constants";
|
||||||
import {
|
import {
|
||||||
createStreamId,
|
createStreamId,
|
||||||
|
|
@ -73,20 +67,20 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
const [, session] = await Promise.all([
|
const [, session] = await Promise.all([
|
||||||
checkBotId().catch(() => null),
|
checkBotId().catch(() => null),
|
||||||
auth(),
|
getSession(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:chat").toResponse();
|
return new ChatbotError("unauthorized:chat").toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatModel = allowedModelIds.has(selectedChatModel)
|
if (!allowedModelIds.has(selectedChatModel)) {
|
||||||
? selectedChatModel
|
return new ChatbotError("bad_request:api").toResponse();
|
||||||
: DEFAULT_CHAT_MODEL;
|
}
|
||||||
|
|
||||||
await checkIpRateLimit(ipAddress(request));
|
await checkIpRateLimit(ipAddress(request));
|
||||||
|
|
||||||
const userType: UserType = session.user.type;
|
const userType: UserType = getUserType(session.user);
|
||||||
|
|
||||||
const messageCount = await getMessageCountByUserId({
|
const messageCount = await getMessageCountByUserId({
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
|
|
@ -107,7 +101,9 @@ export async function POST(request: Request) {
|
||||||
if (chat.userId !== session.user.id) {
|
if (chat.userId !== session.user.id) {
|
||||||
return new ChatbotError("forbidden:chat").toResponse();
|
return new ChatbotError("forbidden:chat").toResponse();
|
||||||
}
|
}
|
||||||
messagesFromDb = await getMessagesByChatId({ id });
|
if (!isToolApprovalFlow) {
|
||||||
|
messagesFromDb = await getMessagesByChatId({ id });
|
||||||
|
}
|
||||||
} else if (message?.role === "user") {
|
} else if (message?.role === "user") {
|
||||||
await saveChat({
|
await saveChat({
|
||||||
id,
|
id,
|
||||||
|
|
@ -118,43 +114,9 @@ export async function POST(request: Request) {
|
||||||
titlePromise = generateTitleFromUserMessage({ message });
|
titlePromise = generateTitleFromUserMessage({ message });
|
||||||
}
|
}
|
||||||
|
|
||||||
let uiMessages: ChatMessage[];
|
const uiMessages = isToolApprovalFlow
|
||||||
|
? (messages as ChatMessage[])
|
||||||
if (isToolApprovalFlow && messages) {
|
: [...convertToUIMessages(messagesFromDb), message as ChatMessage];
|
||||||
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);
|
const { longitude, latitude, city, country } = geolocation(request);
|
||||||
|
|
||||||
|
|
@ -180,11 +142,10 @@ export async function POST(request: Request) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelConfig = chatModels.find((m) => m.id === chatModel);
|
const isReasoningModel =
|
||||||
const modelCapabilities = await getCapabilities();
|
selectedChatModel.endsWith("-thinking") ||
|
||||||
const capabilities = modelCapabilities[chatModel];
|
(selectedChatModel.includes("reasoning") &&
|
||||||
const isReasoningModel = capabilities?.reasoning === true;
|
!selectedChatModel.includes("non-reasoning"));
|
||||||
const supportsTools = capabilities?.tools === true;
|
|
||||||
|
|
||||||
const modelMessages = await convertToModelMessages(uiMessages);
|
const modelMessages = await convertToModelMessages(uiMessages);
|
||||||
|
|
||||||
|
|
@ -192,46 +153,30 @@ export async function POST(request: Request) {
|
||||||
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
|
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
|
||||||
execute: async ({ writer: dataStream }) => {
|
execute: async ({ writer: dataStream }) => {
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: getLanguageModel(chatModel),
|
model: getLanguageModel(selectedChatModel),
|
||||||
system: systemPrompt({ requestHints, supportsTools }),
|
system: systemPrompt({ selectedChatModel, requestHints }),
|
||||||
messages: modelMessages,
|
messages: modelMessages,
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
experimental_activeTools:
|
experimental_activeTools: isReasoningModel
|
||||||
isReasoningModel && !supportsTools
|
? []
|
||||||
? []
|
: [
|
||||||
: [
|
"getWeather",
|
||||||
"getWeather",
|
"createDocument",
|
||||||
"createDocument",
|
"updateDocument",
|
||||||
"editDocument",
|
"requestSuggestions",
|
||||||
"updateDocument",
|
],
|
||||||
"requestSuggestions",
|
providerOptions: isReasoningModel
|
||||||
],
|
? {
|
||||||
providerOptions: {
|
anthropic: {
|
||||||
...(modelConfig?.gatewayOrder && {
|
thinking: { type: "enabled", budgetTokens: 10_000 },
|
||||||
gateway: { order: modelConfig.gatewayOrder },
|
},
|
||||||
}),
|
}
|
||||||
...(modelConfig?.reasoningEffort && {
|
: undefined,
|
||||||
openai: { reasoningEffort: modelConfig.reasoningEffort },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
tools: {
|
tools: {
|
||||||
getWeather,
|
getWeather,
|
||||||
createDocument: createDocument({
|
createDocument: createDocument({ session, dataStream }),
|
||||||
session,
|
updateDocument: updateDocument({ session, dataStream }),
|
||||||
dataStream,
|
requestSuggestions: requestSuggestions({ session, dataStream }),
|
||||||
modelId: chatModel,
|
|
||||||
}),
|
|
||||||
editDocument: editDocument({ dataStream, session }),
|
|
||||||
updateDocument: updateDocument({
|
|
||||||
session,
|
|
||||||
dataStream,
|
|
||||||
modelId: chatModel,
|
|
||||||
}),
|
|
||||||
requestSuggestions: requestSuggestions({
|
|
||||||
session,
|
|
||||||
dataStream,
|
|
||||||
modelId: chatModel,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
experimental_telemetry: {
|
experimental_telemetry: {
|
||||||
isEnabled: isProductionEnvironment,
|
isEnabled: isProductionEnvironment,
|
||||||
|
|
@ -244,13 +189,9 @@ export async function POST(request: Request) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (titlePromise) {
|
if (titlePromise) {
|
||||||
try {
|
const title = await titlePromise;
|
||||||
const title = await titlePromise;
|
dataStream.write({ type: "data-chat-title", data: title });
|
||||||
dataStream.write({ type: "data-chat-title", data: title });
|
updateChatTitleById({ chatId: id, title });
|
||||||
updateChatTitleById({ chatId: id, title });
|
|
||||||
} catch (_) {
|
|
||||||
/* non-fatal */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
generateId: generateUUID,
|
generateId: generateUUID,
|
||||||
|
|
@ -321,7 +262,7 @@ export async function POST(request: Request) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* non-critical */
|
// ignore redis errors
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -354,7 +295,7 @@ export async function DELETE(request: Request) {
|
||||||
return new ChatbotError("bad_request:api").toResponse();
|
return new ChatbotError("bad_request:api").toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:chat").toResponse();
|
return new ChatbotError("unauthorized:chat").toResponse();
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,18 @@ const userMessageSchema = z.object({
|
||||||
parts: z.array(partSchema),
|
parts: z.array(partSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolApprovalMessageSchema = z.object({
|
// For tool approval flows, we accept all messages (more permissive schema)
|
||||||
|
const messageSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
role: z.enum(["user", "assistant"]),
|
role: z.string(),
|
||||||
parts: z.array(z.record(z.unknown())),
|
parts: z.array(z.any()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const postRequestBodySchema = z.object({
|
export const postRequestBodySchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
|
// Either a single new message or all messages (for tool approvals)
|
||||||
message: userMessageSchema.optional(),
|
message: userMessageSchema.optional(),
|
||||||
messages: z.array(toolApprovalMessageSchema).optional(),
|
messages: z.array(messageSchema).optional(),
|
||||||
selectedChatModel: z.string(),
|
selectedChatModel: z.string(),
|
||||||
selectedVisibilityType: z.enum(["public", "private"]),
|
selectedVisibilityType: z.enum(["public", "private"]),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,12 @@
|
||||||
import { z } from "zod";
|
import type { ArtifactKind } from "@/components/artifact";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
import type { ArtifactKind } from "@/components/chat/artifact";
|
|
||||||
import {
|
import {
|
||||||
deleteDocumentsByIdAfterTimestamp,
|
deleteDocumentsByIdAfterTimestamp,
|
||||||
getDocumentsById,
|
getDocumentsById,
|
||||||
saveDocument,
|
saveDocument,
|
||||||
updateDocumentContent,
|
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { ChatbotError } from "@/lib/errors";
|
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) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
|
|
@ -27,7 +18,7 @@ export async function GET(request: Request) {
|
||||||
).toResponse();
|
).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:document").toResponse();
|
return new ChatbotError("unauthorized:document").toResponse();
|
||||||
|
|
@ -59,29 +50,18 @@ export async function POST(request: Request) {
|
||||||
).toResponse();
|
).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("not_found:document").toResponse();
|
return new ChatbotError("not_found:document").toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
let content: string;
|
const {
|
||||||
let title: string;
|
content,
|
||||||
let kind: ArtifactKind;
|
title,
|
||||||
let isManualEdit: boolean | undefined;
|
kind,
|
||||||
|
}: { content: string; title: string; kind: ArtifactKind } =
|
||||||
try {
|
await request.json();
|
||||||
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 });
|
const documents = await getDocumentsById({ id });
|
||||||
|
|
||||||
|
|
@ -93,11 +73,6 @@ 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({
|
const document = await saveDocument({
|
||||||
id,
|
id,
|
||||||
content,
|
content,
|
||||||
|
|
@ -128,7 +103,7 @@ export async function DELETE(request: Request) {
|
||||||
).toResponse();
|
).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:document").toResponse();
|
return new ChatbotError("unauthorized:document").toResponse();
|
||||||
|
|
@ -142,18 +117,9 @@ export async function DELETE(request: Request) {
|
||||||
return new ChatbotError("forbidden:document").toResponse();
|
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({
|
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
|
||||||
id,
|
id,
|
||||||
timestamp: parsedTimestamp,
|
timestamp: new Date(timestamp),
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(documentsDeleted, { status: 200 });
|
return Response.json(documentsDeleted, { status: 200 });
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,23 @@ import { put } from "@vercel/blob";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
|
// Use Blob instead of File since File is not available in Node.js environment
|
||||||
const FileSchema = z.object({
|
const FileSchema = z.object({
|
||||||
file: z
|
file: z
|
||||||
.instanceof(Blob)
|
.instanceof(Blob)
|
||||||
.refine((file) => file.size <= 5 * 1024 * 1024, {
|
.refine((file) => file.size <= 5 * 1024 * 1024, {
|
||||||
message: "File size should be less than 5MB",
|
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), {
|
.refine((file) => ["image/jpeg", "image/png"].includes(file.type), {
|
||||||
message: "File type should be JPEG or PNG",
|
message: "File type should be JPEG or PNG",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -44,12 +46,12 @@ export async function POST(request: Request) {
|
||||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
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 filename = (formData.get("file") as File).name;
|
||||||
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
||||||
const fileBuffer = await file.arrayBuffer();
|
const fileBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await put(`${safeName}`, fileBuffer, {
|
const data = await put(`${filename}`, fileBuffer, {
|
||||||
access: "public",
|
access: "public",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
|
import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
|
||||||
import { ChatbotError } from "@/lib/errors";
|
import { ChatbotError } from "@/lib/errors";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
|
|
||||||
const limit = Math.min(
|
const limit = Number.parseInt(searchParams.get("limit") || "10", 10);
|
||||||
Math.max(Number.parseInt(searchParams.get("limit") || "10", 10), 1),
|
|
||||||
50
|
|
||||||
);
|
|
||||||
const startingAfter = searchParams.get("starting_after");
|
const startingAfter = searchParams.get("starting_after");
|
||||||
const endingBefore = searchParams.get("ending_before");
|
const endingBefore = searchParams.get("ending_before");
|
||||||
|
|
||||||
|
|
@ -20,7 +17,7 @@ export async function GET(request: NextRequest) {
|
||||||
).toResponse();
|
).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:chat").toResponse();
|
return new ChatbotError("unauthorized:chat").toResponse();
|
||||||
|
|
@ -37,7 +34,7 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE() {
|
export async function DELETE() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:chat").toResponse();
|
return new ChatbotError("unauthorized:chat").toResponse();
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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 { auth } from "@/app/(auth)/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
|
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
|
||||||
import { ChatbotError } from "@/lib/errors";
|
import { ChatbotError } from "@/lib/errors";
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ export async function GET(request: Request) {
|
||||||
).toResponse();
|
).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:suggestions").toResponse();
|
return new ChatbotError("unauthorized:suggestions").toResponse();
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,7 @@
|
||||||
import { z } from "zod";
|
import { getSession } from "@/lib/auth";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
|
||||||
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
|
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
|
||||||
import { ChatbotError } from "@/lib/errors";
|
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) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const chatId = searchParams.get("chatId");
|
const chatId = searchParams.get("chatId");
|
||||||
|
|
@ -20,7 +13,7 @@ export async function GET(request: Request) {
|
||||||
).toResponse();
|
).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:vote").toResponse();
|
return new ChatbotError("unauthorized:vote").toResponse();
|
||||||
|
|
@ -42,23 +35,21 @@ export async function GET(request: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: Request) {
|
export async function PATCH(request: Request) {
|
||||||
let chatId: string;
|
const {
|
||||||
let messageId: string;
|
chatId,
|
||||||
let type: "up" | "down";
|
messageId,
|
||||||
|
type,
|
||||||
|
}: { chatId: string; messageId: string; type: "up" | "down" } =
|
||||||
|
await request.json();
|
||||||
|
|
||||||
try {
|
if (!chatId || !messageId || !type) {
|
||||||
const parsed = voteSchema.parse(await request.json());
|
|
||||||
chatId = parsed.chatId;
|
|
||||||
messageId = parsed.messageId;
|
|
||||||
type = parsed.type;
|
|
||||||
} catch {
|
|
||||||
return new ChatbotError(
|
return new ChatbotError(
|
||||||
"bad_request:api",
|
"bad_request:api",
|
||||||
"Parameters chatId, messageId, and type are required."
|
"Parameters chatId, messageId, and type are required."
|
||||||
).toResponse();
|
).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:vote").toResponse();
|
return new ChatbotError("unauthorized:vote").toResponse();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,81 @@
|
||||||
export default function Page() {
|
import { cookies } from "next/headers";
|
||||||
return null;
|
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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,35 @@
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import { AppSidebar } from "@/components/chat/app-sidebar";
|
import { DataStreamProvider } from "@/components/data-stream-provider";
|
||||||
import { DataStreamProvider } from "@/components/chat/data-stream-provider";
|
|
||||||
import { ChatShell } from "@/components/chat/shell";
|
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import { ActiveChatProvider } from "@/hooks/use-active-chat";
|
import { getSession } from "@/lib/auth";
|
||||||
import { auth } from "../(auth)/auth";
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Script
|
<Script
|
||||||
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
|
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
|
||||||
strategy="lazyOnload"
|
strategy="beforeInteractive"
|
||||||
/>
|
/>
|
||||||
<DataStreamProvider>
|
<DataStreamProvider>
|
||||||
<Suspense fallback={<div className="flex h-dvh bg-sidebar" />}>
|
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||||
<SidebarShell>{children}</SidebarShell>
|
<SidebarWrapper>{children}</SidebarWrapper>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</DataStreamProvider>
|
</DataStreamProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function SidebarShell({ children }: { children: React.ReactNode }) {
|
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||||
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
|
const [session, cookieStore] = await Promise.all([getSession(), cookies()]);
|
||||||
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={!isCollapsed}>
|
<SidebarProvider defaultOpen={!isCollapsed}>
|
||||||
<AppSidebar user={session?.user} />
|
<AppSidebar user={session?.user} />
|
||||||
<SidebarInset>
|
<SidebarInset>{children}</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>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,52 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { Chat } from "@/components/chat";
|
||||||
|
import { DataStreamHandler } from "@/components/data-stream-handler";
|
||||||
|
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
|
||||||
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return null;
|
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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
542
app/globals.css
542
app/globals.css
|
|
@ -1,42 +1,138 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "katex/dist/katex.min.css";
|
@import "katex/dist/katex.min.css";
|
||||||
|
|
||||||
|
/* include utility classes in streamdown */
|
||||||
@source "../node_modules/streamdown/dist/index.js";
|
@source "../node_modules/streamdown/dist/index.js";
|
||||||
|
|
||||||
|
/* custom variant for setting dark mode programmatically */
|
||||||
@custom-variant dark (&:is(.dark, .dark *));
|
@custom-variant dark (&:is(.dark, .dark *));
|
||||||
|
|
||||||
|
/* include plugins */
|
||||||
@plugin "tailwindcss-animate";
|
@plugin "tailwindcss-animate";
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@theme inline {
|
/* define design tokens (light mode) */
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
:root {
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--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-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
|
--color-sidebar: var(--sidebar-background);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
|
@ -46,156 +142,24 @@
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
/*
|
||||||
--radius: 0.625rem;
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
--background: oklch(0.985 0 0);
|
so we've added these compatibility styles to make sure everything still
|
||||||
--foreground: oklch(0.12 0 0);
|
looks the same as it did with Tailwind CSS v3.
|
||||||
--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 {
|
@layer base {
|
||||||
*,
|
*,
|
||||||
::after,
|
::after,
|
||||||
::before,
|
::before,
|
||||||
::backdrop,
|
::backdrop,
|
||||||
::file-selector-button {
|
::file-selector-button {
|
||||||
border-color: var(--border);
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 {
|
@utility text-balance {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
@ -212,14 +176,6 @@ textarea:focus-visible {
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility no-scrollbar {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
:root {
|
:root {
|
||||||
--foreground-rgb: 0, 0, 0;
|
--foreground-rgb: 0, 0, 0;
|
||||||
|
|
@ -236,6 +192,22 @@ textarea:focus-visible {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.skeleton {
|
.skeleton {
|
||||||
* {
|
* {
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
|
|
@ -255,246 +227,92 @@ textarea:focus-visible {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 {
|
.ProseMirror {
|
||||||
outline: none;
|
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 {
|
.cm-gutters {
|
||||||
@apply bg-transparent! border-r-0! outline-hidden!;
|
@apply bg-background! dark:bg-zinc-800! outline-hidden! selection:bg-zinc-900!;
|
||||||
}
|
|
||||||
|
|
||||||
.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-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
||||||
.ͼo.cm-selectionBackground,
|
.ͼo.cm-selectionBackground,
|
||||||
.ͼo.cm-content::selection {
|
.ͼo.cm-content::selection {
|
||||||
background: oklch(0.55 0.12 250 / 0.15) !important;
|
@apply bg-zinc-200! dark:bg-zinc-900!;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.cm-activeLineGutter {
|
||||||
@apply bg-transparent!;
|
@apply bg-transparent!;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-activeLineGutter .cm-gutterElement {
|
.cm-activeLine {
|
||||||
color: var(--foreground) !important;
|
@apply rounded-r-sm!;
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-gutter.cm-lineNumbers .cm-gutterElement {
|
.cm-lineNumbers {
|
||||||
padding-right: 12px !important;
|
@apply min-w-7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-foldGutter {
|
.cm-foldGutter {
|
||||||
@apply min-w-3;
|
@apply min-w-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-cursor {
|
.cm-lineNumbers .cm-activeLineGutter {
|
||||||
border-left-color: oklch(0.55 0.12 250) !important;
|
@apply rounded-l-sm!;
|
||||||
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 {
|
.suggestion-highlight {
|
||||||
@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;
|
@apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
|
||||||
user-select: none;
|
}
|
||||||
-webkit-user-select: none;
|
|
||||||
|
/* minimal scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: --alpha(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* firefox scrollbar styling */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
@apply border-border outline-ring/50;
|
||||||
scrollbar-color: oklch(0 0 0 / 0.12) transparent;
|
|
||||||
}
|
}
|
||||||
|
body {
|
||||||
.dark * {
|
@apply bg-background text-foreground;
|
||||||
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,10 +1,11 @@
|
||||||
|
import { Analytics } from "@vercel/analytics/next";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import "katex/dist/katex.min.css";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { SessionProvider } from "next-auth/react";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://chat.vercel.ai"),
|
metadataBase: new URL("https://chat.vercel.ai"),
|
||||||
|
|
@ -13,7 +14,7 @@ export const metadata: Metadata = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport = {
|
export const viewport = {
|
||||||
maximumScale: 1,
|
maximumScale: 1, // Disable auto-zoom on mobile Safari
|
||||||
};
|
};
|
||||||
|
|
||||||
const geist = Geist({
|
const geist = Geist({
|
||||||
|
|
@ -56,6 +57,10 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
className={`${geist.variable} ${geistMono.variable}`}
|
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"
|
lang="en"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
|
|
@ -74,12 +79,10 @@ export default function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
enableSystem
|
enableSystem
|
||||||
>
|
>
|
||||||
<SessionProvider
|
<Toaster position="top-center" />
|
||||||
basePath={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth`}
|
{children}
|
||||||
>
|
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
|
||||||
</SessionProvider>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CodeEditor } from "@/components/chat/code-editor";
|
import { CodeEditor } from "@/components/code-editor";
|
||||||
import {
|
import {
|
||||||
Console,
|
Console,
|
||||||
type ConsoleOutput,
|
type ConsoleOutput,
|
||||||
type ConsoleOutputContent,
|
type ConsoleOutputContent,
|
||||||
} from "@/components/chat/console";
|
} from "@/components/console";
|
||||||
import { Artifact } from "@/components/chat/create-artifact";
|
import { Artifact } from "@/components/create-artifact";
|
||||||
import {
|
import {
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
RedoIcon,
|
RedoIcon,
|
||||||
UndoIcon,
|
UndoIcon,
|
||||||
} from "@/components/chat/icons";
|
} from "@/components/icons";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
|
||||||
const OUTPUT_HANDLERS = {
|
const OUTPUT_HANDLERS = {
|
||||||
|
|
@ -93,7 +93,7 @@ export const codeArtifact = new Artifact<"code", Metadata>({
|
||||||
content: ({ metadata, setMetadata, ...props }) => {
|
content: ({ metadata, setMetadata, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative min-h-[200px]">
|
<div className="px-1">
|
||||||
<CodeEditor {...props} />
|
<CodeEditor {...props} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -193,20 +193,14 @@ export const codeArtifact = new Artifact<"code", Metadata>({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
} catch (error: unknown) {
|
} catch (error: any) {
|
||||||
setMetadata((metadata) => ({
|
setMetadata((metadata) => ({
|
||||||
...metadata,
|
...metadata,
|
||||||
outputs: [
|
outputs: [
|
||||||
...metadata.outputs.filter((output) => output.id !== runId),
|
...metadata.outputs.filter((output) => output.id !== runId),
|
||||||
{
|
{
|
||||||
id: runId,
|
id: runId,
|
||||||
contents: [
|
contents: [{ type: "text", value: error.message }],
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
value:
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: "failed",
|
status: "failed",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,75 @@
|
||||||
import { streamText } from "ai";
|
import { streamObject } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||||
import { getLanguageModel } from "@/lib/ai/providers";
|
import { getArtifactModel } from "@/lib/ai/providers";
|
||||||
import { createDocumentHandler } from "@/lib/artifacts/server";
|
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">({
|
export const codeDocumentHandler = createDocumentHandler<"code">({
|
||||||
kind: "code",
|
kind: "code",
|
||||||
onCreateDocument: async ({ title, dataStream, modelId }) => {
|
onCreateDocument: async ({ title, dataStream }) => {
|
||||||
let draftContent = "";
|
let draftContent = "";
|
||||||
|
|
||||||
const { fullStream } = streamText({
|
const { fullStream } = streamObject({
|
||||||
model: getLanguageModel(modelId),
|
model: getArtifactModel(),
|
||||||
system: `${codePrompt}\n\nOutput ONLY the code. No explanations, no markdown fences, no wrapping.`,
|
system: codePrompt,
|
||||||
prompt: title,
|
prompt: title,
|
||||||
|
schema: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const delta of fullStream) {
|
for await (const delta of fullStream) {
|
||||||
if (delta.type === "text-delta") {
|
const { type } = delta;
|
||||||
draftContent += delta.text;
|
|
||||||
dataStream.write({
|
if (type === "object") {
|
||||||
type: "data-codeDelta",
|
const { object } = delta;
|
||||||
data: stripFences(draftContent),
|
const { code } = object;
|
||||||
transient: true,
|
|
||||||
});
|
if (code) {
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-codeDelta",
|
||||||
|
data: code ?? "",
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
draftContent = code;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return stripFences(draftContent);
|
return draftContent;
|
||||||
},
|
},
|
||||||
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
|
onUpdateDocument: async ({ document, description, dataStream }) => {
|
||||||
let draftContent = "";
|
let draftContent = "";
|
||||||
|
|
||||||
const { fullStream } = streamText({
|
const { fullStream } = streamObject({
|
||||||
model: getLanguageModel(modelId),
|
model: getArtifactModel(),
|
||||||
system: `${updateDocumentPrompt(document.content, "code")}\n\nOutput ONLY the complete updated code. No explanations, no markdown fences, no wrapping.`,
|
system: updateDocumentPrompt(document.content, "code"),
|
||||||
prompt: description,
|
prompt: description,
|
||||||
|
schema: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const delta of fullStream) {
|
for await (const delta of fullStream) {
|
||||||
if (delta.type === "text-delta") {
|
const { type } = delta;
|
||||||
draftContent += delta.text;
|
|
||||||
dataStream.write({
|
if (type === "object") {
|
||||||
type: "data-codeDelta",
|
const { object } = delta;
|
||||||
data: stripFences(draftContent),
|
const { code } = object;
|
||||||
transient: true,
|
|
||||||
});
|
if (code) {
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-codeDelta",
|
||||||
|
data: code ?? "",
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
draftContent = code;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return stripFences(draftContent);
|
return draftContent;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Artifact } from "@/components/chat/create-artifact";
|
import { Artifact } from "@/components/create-artifact";
|
||||||
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/chat/icons";
|
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/icons";
|
||||||
import { ImageEditor } from "@/components/chat/image-editor";
|
import { ImageEditor } from "@/components/image-editor";
|
||||||
|
|
||||||
export const imageArtifact = new Artifact({
|
export const imageArtifact = new Artifact({
|
||||||
kind: "image",
|
kind: "image",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import { parse, unparse } from "papaparse";
|
import { parse, unparse } from "papaparse";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Artifact } from "@/components/chat/create-artifact";
|
import { Artifact } from "@/components/create-artifact";
|
||||||
import {
|
import {
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
LineChartIcon,
|
LineChartIcon,
|
||||||
RedoIcon,
|
RedoIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
UndoIcon,
|
UndoIcon,
|
||||||
} from "@/components/chat/icons";
|
} from "@/components/icons";
|
||||||
import { SpreadsheetEditor } from "@/components/chat/sheet-editor";
|
import { SpreadsheetEditor } from "@/components/sheet-editor";
|
||||||
|
|
||||||
type Metadata = Record<string, never>;
|
type Metadata = any;
|
||||||
|
|
||||||
export const sheetArtifact = new Artifact<"sheet", Metadata>({
|
export const sheetArtifact = new Artifact<"sheet", Metadata>({
|
||||||
kind: "sheet",
|
kind: "sheet",
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,78 @@
|
||||||
import { streamText } from "ai";
|
import { streamObject } from "ai";
|
||||||
|
import { z } from "zod";
|
||||||
import { sheetPrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
import { sheetPrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||||
import { getLanguageModel } from "@/lib/ai/providers";
|
import { getArtifactModel } from "@/lib/ai/providers";
|
||||||
import { createDocumentHandler } from "@/lib/artifacts/server";
|
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||||
|
|
||||||
export const sheetDocumentHandler = createDocumentHandler<"sheet">({
|
export const sheetDocumentHandler = createDocumentHandler<"sheet">({
|
||||||
kind: "sheet",
|
kind: "sheet",
|
||||||
onCreateDocument: async ({ title, dataStream, modelId }) => {
|
onCreateDocument: async ({ title, dataStream }) => {
|
||||||
let draftContent = "";
|
let draftContent = "";
|
||||||
|
|
||||||
const { fullStream } = streamText({
|
const { fullStream } = streamObject({
|
||||||
model: getLanguageModel(modelId),
|
model: getArtifactModel(),
|
||||||
system: `${sheetPrompt}\n\nOutput ONLY the raw CSV data. No explanations, no markdown fences.`,
|
system: sheetPrompt,
|
||||||
prompt: title,
|
prompt: title,
|
||||||
|
schema: z.object({
|
||||||
|
csv: z.string().describe("CSV data"),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const delta of fullStream) {
|
for await (const delta of fullStream) {
|
||||||
if (delta.type === "text-delta") {
|
const { type } = delta;
|
||||||
draftContent += delta.text;
|
|
||||||
dataStream.write({
|
if (type === "object") {
|
||||||
type: "data-sheetDelta",
|
const { object } = delta;
|
||||||
data: draftContent,
|
const { csv } = object;
|
||||||
transient: true,
|
|
||||||
});
|
if (csv) {
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-sheetDelta",
|
||||||
|
data: csv,
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
draftContent = csv;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-sheetDelta",
|
||||||
|
data: draftContent,
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
return draftContent;
|
return draftContent;
|
||||||
},
|
},
|
||||||
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
|
onUpdateDocument: async ({ document, description, dataStream }) => {
|
||||||
let draftContent = "";
|
let draftContent = "";
|
||||||
|
|
||||||
const { fullStream } = streamText({
|
const { fullStream } = streamObject({
|
||||||
model: getLanguageModel(modelId),
|
model: getArtifactModel(),
|
||||||
system: `${updateDocumentPrompt(document.content, "sheet")}\n\nOutput ONLY the raw CSV data. No explanations, no markdown fences.`,
|
system: updateDocumentPrompt(document.content, "sheet"),
|
||||||
prompt: description,
|
prompt: description,
|
||||||
|
schema: z.object({
|
||||||
|
csv: z.string(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const delta of fullStream) {
|
for await (const delta of fullStream) {
|
||||||
if (delta.type === "text-delta") {
|
const { type } = delta;
|
||||||
draftContent += delta.text;
|
|
||||||
dataStream.write({
|
if (type === "object") {
|
||||||
type: "data-sheetDelta",
|
const { object } = delta;
|
||||||
data: draftContent,
|
const { csv } = object;
|
||||||
transient: true,
|
|
||||||
});
|
if (csv) {
|
||||||
|
dataStream.write({
|
||||||
|
type: "data-sheetDelta",
|
||||||
|
data: csv,
|
||||||
|
transient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
draftContent = csv;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Artifact } from "@/components/chat/create-artifact";
|
import { Artifact } from "@/components/create-artifact";
|
||||||
import { DiffView } from "@/components/chat/diffview";
|
import { DiffView } from "@/components/diffview";
|
||||||
import { DocumentSkeleton } from "@/components/chat/document-skeleton";
|
import { DocumentSkeleton } from "@/components/document-skeleton";
|
||||||
import {
|
import {
|
||||||
ClockRewind,
|
ClockRewind,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
|
|
@ -9,8 +9,8 @@ import {
|
||||||
PenIcon,
|
PenIcon,
|
||||||
RedoIcon,
|
RedoIcon,
|
||||||
UndoIcon,
|
UndoIcon,
|
||||||
} from "@/components/chat/icons";
|
} from "@/components/icons";
|
||||||
import { Editor } from "@/components/chat/text-editor";
|
import { Editor } from "@/components/text-editor";
|
||||||
import type { Suggestion } from "@/lib/db/schema";
|
import type { Suggestion } from "@/lib/db/schema";
|
||||||
import { getSuggestions } from "../actions";
|
import { getSuggestions } from "../actions";
|
||||||
|
|
||||||
|
|
@ -69,28 +69,21 @@ export const textArtifact = new Artifact<"text", TextArtifactMetadata>({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "diff") {
|
if (mode === "diff") {
|
||||||
const selectedContent = getDocumentContentById(currentVersionIndex);
|
const oldContent = getDocumentContentById(currentVersionIndex - 1);
|
||||||
const prevContent =
|
const newContent = getDocumentContentById(currentVersionIndex);
|
||||||
currentVersionIndex > 0
|
|
||||||
? getDocumentContentById(currentVersionIndex - 1)
|
|
||||||
: selectedContent;
|
|
||||||
|
|
||||||
return (
|
return <DiffView newContent={newContent} oldContent={oldContent} />;
|
||||||
<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 (
|
return (
|
||||||
<div className="flex flex-row px-4 py-8 md:px-16 md:py-12 lg:px-20">
|
<div className="flex flex-row px-4 py-8 md:p-20">
|
||||||
<Editor
|
<Editor
|
||||||
content={content}
|
content={content}
|
||||||
currentVersionIndex={currentVersionIndex}
|
currentVersionIndex={currentVersionIndex}
|
||||||
isCurrentVersion={isCurrentVersion}
|
isCurrentVersion={isCurrentVersion}
|
||||||
onSaveContent={onSaveContent}
|
onSaveContent={onSaveContent}
|
||||||
status={status}
|
status={status}
|
||||||
suggestions={isCurrentVersion && metadata ? metadata.suggestions : []}
|
suggestions={metadata ? metadata.suggestions : []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{metadata?.suggestions && metadata.suggestions.length > 0 ? (
|
{metadata?.suggestions && metadata.suggestions.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { smoothStream, streamText } from "ai";
|
import { smoothStream, streamText } from "ai";
|
||||||
import { updateDocumentPrompt } from "@/lib/ai/prompts";
|
import { updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||||
import { getLanguageModel } from "@/lib/ai/providers";
|
import { getArtifactModel } from "@/lib/ai/providers";
|
||||||
import { createDocumentHandler } from "@/lib/artifacts/server";
|
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||||
|
|
||||||
export const textDocumentHandler = createDocumentHandler<"text">({
|
export const textDocumentHandler = createDocumentHandler<"text">({
|
||||||
kind: "text",
|
kind: "text",
|
||||||
onCreateDocument: async ({ title, dataStream, modelId }) => {
|
onCreateDocument: async ({ title, dataStream }) => {
|
||||||
let draftContent = "";
|
let draftContent = "";
|
||||||
|
|
||||||
const { fullStream } = streamText({
|
const { fullStream } = streamText({
|
||||||
model: getLanguageModel(modelId),
|
model: getArtifactModel(),
|
||||||
system:
|
system:
|
||||||
"Write about the given topic. Markdown is supported. Use headings wherever appropriate.",
|
"Write about the given topic. Markdown is supported. Use headings wherever appropriate.",
|
||||||
experimental_transform: smoothStream({ chunking: "word" }),
|
experimental_transform: smoothStream({ chunking: "word" }),
|
||||||
|
|
@ -17,11 +17,16 @@ export const textDocumentHandler = createDocumentHandler<"text">({
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const delta of fullStream) {
|
for await (const delta of fullStream) {
|
||||||
if (delta.type === "text-delta") {
|
const { type } = delta;
|
||||||
draftContent += delta.text;
|
|
||||||
|
if (type === "text-delta") {
|
||||||
|
const { text } = delta;
|
||||||
|
|
||||||
|
draftContent += text;
|
||||||
|
|
||||||
dataStream.write({
|
dataStream.write({
|
||||||
type: "data-textDelta",
|
type: "data-textDelta",
|
||||||
data: delta.text,
|
data: text,
|
||||||
transient: true,
|
transient: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -29,22 +34,35 @@ export const textDocumentHandler = createDocumentHandler<"text">({
|
||||||
|
|
||||||
return draftContent;
|
return draftContent;
|
||||||
},
|
},
|
||||||
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
|
onUpdateDocument: async ({ document, description, dataStream }) => {
|
||||||
let draftContent = "";
|
let draftContent = "";
|
||||||
|
|
||||||
const { fullStream } = streamText({
|
const { fullStream } = streamText({
|
||||||
model: getLanguageModel(modelId),
|
model: getArtifactModel(),
|
||||||
system: updateDocumentPrompt(document.content, "text"),
|
system: updateDocumentPrompt(document.content, "text"),
|
||||||
experimental_transform: smoothStream({ chunking: "word" }),
|
experimental_transform: smoothStream({ chunking: "word" }),
|
||||||
prompt: description,
|
prompt: description,
|
||||||
|
providerOptions: {
|
||||||
|
openai: {
|
||||||
|
prediction: {
|
||||||
|
type: "content",
|
||||||
|
content: document.content,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const delta of fullStream) {
|
for await (const delta of fullStream) {
|
||||||
if (delta.type === "text-delta") {
|
const { type } = delta;
|
||||||
draftContent += delta.text;
|
|
||||||
|
if (type === "text-delta") {
|
||||||
|
const { text } = delta;
|
||||||
|
|
||||||
|
draftContent += text;
|
||||||
|
|
||||||
dataStream.write({
|
dataStream.write({
|
||||||
type: "data-textDelta",
|
type: "data-textDelta",
|
||||||
data: delta.text,
|
data: text,
|
||||||
transient: true,
|
transient: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "radix-maia",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
"css": "app/globals.css",
|
"css": "app/globals.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "zinc",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ export const MessageContent = ({
|
||||||
}: MessageContentProps) => (
|
}: MessageContentProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm text-foreground",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { ComponentProps, ReactNode } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
|
CommandDialog,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
|
|
@ -11,49 +12,54 @@ import {
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Dialog,
|
||||||
PopoverContent,
|
DialogContent,
|
||||||
PopoverTrigger,
|
DialogTitle,
|
||||||
} from "@/components/ui/popover";
|
DialogTrigger,
|
||||||
import type { Popover as PopoverPrimitive } from "radix-ui";
|
} from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type ModelSelectorProps = React.ComponentProps<typeof PopoverPrimitive.Root>;
|
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||||
|
|
||||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||||
<Popover {...props} />
|
<Dialog {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ModelSelectorTriggerProps = ComponentProps<typeof PopoverTrigger>;
|
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||||
|
|
||||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||||
<PopoverTrigger {...props} />
|
<DialogTrigger {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ModelSelectorContentProps = ComponentProps<typeof PopoverContent> & {
|
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModelSelectorContent = ({
|
export const ModelSelectorContent = ({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
title: _title,
|
title = "Model Selector",
|
||||||
...props
|
...props
|
||||||
}: ModelSelectorContentProps) => (
|
}: ModelSelectorContentProps) => (
|
||||||
<PopoverContent
|
<DialogContent
|
||||||
align="start"
|
aria-describedby={undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[280px] p-0 rounded-xl border border-border/60 bg-card/95 backdrop-blur-xl shadow-[var(--shadow-float)]",
|
"outline! border-none! p-0 outline-border! outline-solid!",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
side="top"
|
|
||||||
sideOffset={8}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</DialogContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||||
|
|
||||||
|
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||||
|
<CommandDialog {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||||
|
|
@ -62,13 +68,13 @@ export const ModelSelectorInput = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ModelSelectorInputProps) => (
|
}: ModelSelectorInputProps) => (
|
||||||
<CommandInput className={cn("h-auto py-2.5 text-[13px]", className)} {...props} />
|
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||||
|
|
||||||
export const ModelSelectorList = ({ className, ...props }: ModelSelectorListProps) => (
|
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||||
<CommandList className={cn("max-h-[280px]", className)} {...props} />
|
<CommandList {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||||
|
|
@ -85,8 +91,8 @@ export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||||
|
|
||||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||||
|
|
||||||
export const ModelSelectorItem = ({ className, ...props }: ModelSelectorItemProps) => (
|
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||||
<CommandItem className={cn("w-full text-[13px] rounded-lg", className)} {...props} />
|
<CommandItem {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||||
|
|
@ -176,10 +182,10 @@ export const ModelSelectorLogo = ({
|
||||||
<img
|
<img
|
||||||
{...props}
|
{...props}
|
||||||
alt={`${provider} logo`}
|
alt={`${provider} logo`}
|
||||||
className={cn("size-4 dark:invert", className)}
|
className={cn("size-3 dark:invert", className)}
|
||||||
height={16}
|
height={12}
|
||||||
src={`https://models.dev/logos/${provider}.svg`}
|
src={`https://models.dev/logos/${provider}.svg`}
|
||||||
width={16}
|
width={12}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -191,7 +197,7 @@ export const ModelSelectorLogoGroup = ({
|
||||||
}: ModelSelectorLogoGroupProps) => (
|
}: ModelSelectorLogoGroupProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:p-px [&>img]:ring-1 [&>img]:ring-border/30",
|
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
|
||||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -12,7 +13,7 @@ import { cjk } from "@streamdown/cjk";
|
||||||
import { code } from "@streamdown/code";
|
import { code } from "@streamdown/code";
|
||||||
import { math } from "@streamdown/math";
|
import { math } from "@streamdown/math";
|
||||||
import { mermaid } from "@streamdown/mermaid";
|
import { mermaid } from "@streamdown/mermaid";
|
||||||
import { ChevronDownIcon } from "lucide-react";
|
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
memo,
|
memo,
|
||||||
|
|
@ -136,7 +137,7 @@ export const Reasoning = memo(
|
||||||
return (
|
return (
|
||||||
<ReasoningContext.Provider value={contextValue}>
|
<ReasoningContext.Provider value={contextValue}>
|
||||||
<Collapsible
|
<Collapsible
|
||||||
className={cn("not-prose", className)}
|
className={cn("not-prose mb-4", className)}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -156,7 +157,7 @@ export type ReasoningTriggerProps = ComponentProps<
|
||||||
|
|
||||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||||
if (isStreaming || duration === 0) {
|
if (isStreaming || duration === 0) {
|
||||||
return <Shimmer className="font-medium" duration={1}>Thinking...</Shimmer>;
|
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||||
}
|
}
|
||||||
if (duration === undefined) {
|
if (duration === undefined) {
|
||||||
return <p>Thought for a few seconds</p>;
|
return <p>Thought for a few seconds</p>;
|
||||||
|
|
@ -176,13 +177,14 @@ export const ReasoningTrigger = memo(
|
||||||
return (
|
return (
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 text-muted-foreground text-[13px] leading-[1.65] transition-colors hover:text-foreground",
|
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
|
<BrainIcon className="size-4" />
|
||||||
{getThinkingMessage(isStreaming, duration)}
|
{getThinkingMessage(isStreaming, duration)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -197,43 +199,29 @@ export const ReasoningTrigger = memo(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ReasoningContentProps = HTMLAttributes<HTMLDivElement> & {
|
export type ReasoningContentProps = ComponentProps<
|
||||||
|
typeof CollapsibleContent
|
||||||
|
> & {
|
||||||
children: string;
|
children: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||||
|
|
||||||
export const ReasoningContent = memo(
|
export const ReasoningContent = memo(
|
||||||
({ className, children, ...props }: ReasoningContentProps) => {
|
({ className, children, ...props }: ReasoningContentProps) => (
|
||||||
const { isStreaming, isOpen } = useReasoning();
|
<CollapsibleContent
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
className={cn(
|
||||||
useEffect(() => {
|
"mt-4 text-sm",
|
||||||
if (isStreaming && scrollRef.current) {
|
"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",
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
className
|
||||||
}
|
)}
|
||||||
}, [children, isStreaming]);
|
{...props}
|
||||||
|
>
|
||||||
if (!isOpen) return null;
|
<Streamdown plugins={streamdownPlugins} {...props}>
|
||||||
|
{children}
|
||||||
return (
|
</Streamdown>
|
||||||
<div
|
</CollapsibleContent>
|
||||||
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";
|
Reasoning.displayName = "Reasoning";
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,9 @@ export const ToolOutput = ({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
||||||
errorText && "bg-destructive/10 text-destructive"
|
errorText
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: "bg-muted/50 text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{errorText && <div>{errorText}</div>}
|
{errorText && <div>{errorText}</div>}
|
||||||
|
|
|
||||||
150
components/app-sidebar.tsx
Normal file
150
components/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
components/artifact-actions.tsx
Normal file
107
components/artifact-actions.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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,13 +1,14 @@
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
|
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
|
||||||
import { CrossIcon } from "./icons";
|
import { CrossIcon } from "./icons";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
function PureArtifactCloseButton() {
|
function PureArtifactCloseButton() {
|
||||||
const { setArtifact } = useArtifact();
|
const { setArtifact } = useArtifact();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<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"
|
className="h-fit p-2 dark:hover:bg-zinc-700"
|
||||||
data-testid="artifact-close-button"
|
data-testid="artifact-close-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setArtifact((currentArtifact) =>
|
setArtifact((currentArtifact) =>
|
||||||
|
|
@ -19,10 +20,10 @@ function PureArtifactCloseButton() {
|
||||||
: { ...initialArtifactData, status: "idle" }
|
: { ...initialArtifactData, status: "idle" }
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
type="button"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<CrossIcon size={16} />
|
<CrossIcon size={18} />
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
535
components/artifact.tsx
Normal file
535
components/artifact.tsx
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
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,7 +1,7 @@
|
||||||
import Form from "next/form";
|
import Form from "next/form";
|
||||||
|
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { Label } from "../ui/label";
|
import { Label } from "./ui/label";
|
||||||
|
|
||||||
export function AuthForm({
|
export function AuthForm({
|
||||||
action,
|
action,
|
||||||
|
|
@ -15,33 +15,40 @@ export function AuthForm({
|
||||||
defaultEmail?: string;
|
defaultEmail?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Form action={action} className="flex flex-col gap-4">
|
<Form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="font-normal text-muted-foreground" htmlFor="email">
|
<Label
|
||||||
Email
|
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||||
|
htmlFor="email"
|
||||||
|
>
|
||||||
|
Email Address
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-10 rounded-lg border-border/50 bg-muted/50 text-sm transition-colors focus:border-foreground/20 focus:bg-muted"
|
className="bg-muted text-md md:text-sm"
|
||||||
defaultValue={defaultEmail}
|
defaultValue={defaultEmail}
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="you@someo.ne"
|
placeholder="user@acme.com"
|
||||||
required
|
required
|
||||||
type="email"
|
type="email"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="font-normal text-muted-foreground" htmlFor="password">
|
<Label
|
||||||
|
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||||
|
htmlFor="password"
|
||||||
|
>
|
||||||
Password
|
Password
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="h-10 rounded-lg border-border/50 bg-muted/50 text-sm transition-colors focus:border-foreground/20 focus:bg-muted"
|
className="bg-muted text-md md:text-sm"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
required
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
77
components/chat-header.tsx
Normal file
77
components/chat-header.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
});
|
||||||
288
components/chat.tsx
Normal file
288
components/chat.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
"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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
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,482 +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,
|
|
||||||
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,76 +0,0 @@
|
||||||
"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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,38 +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/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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
"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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,387 +0,0 @@
|
||||||
"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,59 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
"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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -20,7 +20,6 @@ type EditorProps = {
|
||||||
function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
|
function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const editorRef = useRef<EditorView | null>(null);
|
const editorRef = useRef<EditorView | null>(null);
|
||||||
const userScrolledRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (containerRef.current && !editorRef.current) {
|
if (containerRef.current && !editorRef.current) {
|
||||||
|
|
@ -41,6 +40,8 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
|
||||||
editorRef.current = null;
|
editorRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// NOTE: we only want to run this effect once
|
||||||
|
// eslint-disable-next-line
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -58,44 +59,17 @@ 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 currentSelection = editorRef.current.state.selection;
|
||||||
|
|
||||||
const newState = EditorState.create({
|
const newState = EditorState.create({
|
||||||
doc: editorRef.current.state.doc,
|
doc: editorRef.current.state.doc,
|
||||||
extensions: [
|
extensions: [basicSetup, python(), oneDark, updateListener],
|
||||||
basicSetup,
|
|
||||||
python(),
|
|
||||||
oneDark,
|
|
||||||
updateListener,
|
|
||||||
scrollListener,
|
|
||||||
],
|
|
||||||
selection: currentSelection,
|
selection: currentSelection,
|
||||||
});
|
});
|
||||||
|
|
||||||
editorRef.current.setState(newState);
|
editorRef.current.setState(newState);
|
||||||
}
|
}
|
||||||
}, [onSaveContent, status]);
|
}, [onSaveContent]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status !== "streaming") {
|
|
||||||
userScrolledRef.current = false;
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorRef.current && content) {
|
if (editorRef.current && content) {
|
||||||
|
|
@ -112,43 +86,36 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
editorRef.current.dispatch(transaction);
|
editorRef.current.dispatch(transaction);
|
||||||
|
|
||||||
if (status === "streaming" && !userScrolledRef.current) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const dom = editorRef.current?.scrollDOM;
|
|
||||||
if (dom) {
|
|
||||||
dom.scrollTo({ top: dom.scrollHeight });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [content, status]);
|
}, [content, status]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="not-prose relative w-full min-h-[300px] pb-[calc(50dvh)]"
|
className="not-prose relative w-full pb-[calc(80dvh)] text-sm"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CodeEditor = memo(PureCodeEditor, (prevProps, nextProps) => {
|
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;
|
||||||
|
}
|
||||||
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
|
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevProps.content !== nextProps.content) {
|
if (prevProps.content !== nextProps.content) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevProps.status !== nextProps.status) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export const CodeEditor = memo(PureCodeEditor, areEqual);
|
||||||
|
|
@ -8,9 +8,9 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useArtifactSelector } from "@/hooks/use-artifact";
|
import { useArtifactSelector } from "@/hooks/use-artifact";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { Spinner } from "../ui/spinner";
|
|
||||||
import { CrossSmallIcon, TerminalWindowIcon } from "./icons";
|
import { CrossSmallIcon, TerminalWindowIcon } from "./icons";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Spinner } from "./ui/spinner";
|
||||||
|
|
||||||
export type ConsoleOutputContent = {
|
export type ConsoleOutputContent = {
|
||||||
type: "text" | "image";
|
type: "text" | "image";
|
||||||
|
|
@ -31,6 +31,7 @@ type ConsoleProps = {
|
||||||
export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
const [height, setHeight] = useState<number>(300);
|
const [height, setHeight] = useState<number>(300);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const consoleEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
|
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
|
||||||
|
|
||||||
|
|
@ -66,13 +67,9 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
};
|
};
|
||||||
}, [resize, stopResizing]);
|
}, [resize, stopResizing]);
|
||||||
|
|
||||||
const consoleContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (consoleOutputs.length > 0) {
|
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
consoleContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
}, []);
|
||||||
}
|
|
||||||
}, [consoleOutputs.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isArtifactVisible) {
|
if (!isArtifactVisible) {
|
||||||
|
|
@ -104,35 +101,38 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-auto border-t border-border/50 bg-background",
|
"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 }
|
{
|
||||||
|
"select-none": isResizing,
|
||||||
|
}
|
||||||
)}
|
)}
|
||||||
ref={consoleContainerRef}
|
|
||||||
style={{ height }}
|
style={{ height }}
|
||||||
>
|
>
|
||||||
<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="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 items-center gap-2.5 text-[13px] text-muted-foreground">
|
<div className="flex flex-row items-center gap-3 pl-2 text-sm text-zinc-800 dark:text-zinc-50">
|
||||||
<TerminalWindowIcon />
|
<div className="text-muted-foreground">
|
||||||
<span>Console</span>
|
<TerminalWindowIcon />
|
||||||
|
</div>
|
||||||
|
<div>Console</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="size-7 text-muted-foreground/50 hover:text-foreground"
|
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||||
onClick={() => setConsoleOutputs([])}
|
onClick={() => setConsoleOutputs([])}
|
||||||
size="icon-sm"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<CrossSmallIcon />
|
<CrossSmallIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background">
|
<div>
|
||||||
{[...consoleOutputs].reverse().map((consoleOutput, index) => (
|
{consoleOutputs.map((consoleOutput, index) => (
|
||||||
<div
|
<div
|
||||||
className="flex border-b border-border/30 px-4 py-2.5 font-mono text-[12px] leading-relaxed"
|
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"
|
||||||
key={consoleOutput.id}
|
key={consoleOutput.id}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn("w-10 shrink-0 tabular-nums", {
|
className={cn("w-12 shrink-0", {
|
||||||
"text-muted-foreground": [
|
"text-muted-foreground": [
|
||||||
"in_progress",
|
"in_progress",
|
||||||
"loading_packages",
|
"loading_packages",
|
||||||
|
|
@ -141,14 +141,16 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
"text-red-400": consoleOutput.status === "failed",
|
"text-red-400": consoleOutput.status === "failed",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
[{consoleOutputs.length - index}]
|
[{index + 1}]
|
||||||
</div>
|
</div>
|
||||||
{["in_progress", "loading_packages"].includes(
|
{["in_progress", "loading_packages"].includes(
|
||||||
consoleOutput.status
|
consoleOutput.status
|
||||||
) ? (
|
) ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Spinner className="size-3.5" />
|
<div className="mt-0.5 mb-auto size-fit self-center">
|
||||||
<span className="text-muted-foreground">
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
{consoleOutput.status === "in_progress"
|
{consoleOutput.status === "in_progress"
|
||||||
? "Initializing..."
|
? "Initializing..."
|
||||||
: consoleOutput.status === "loading_packages"
|
: consoleOutput.status === "loading_packages"
|
||||||
|
|
@ -156,25 +158,23 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
content.type === "text" ? content.value : null
|
content.type === "text" ? content.value : null
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="no-scrollbar flex w-full min-w-0 flex-col gap-2 overflow-x-auto text-foreground">
|
<div className="flex w-full flex-col gap-2 overflow-x-scroll text-zinc-900 dark:text-zinc-50">
|
||||||
{consoleOutput.contents.map((content) =>
|
{consoleOutput.contents.map((content) =>
|
||||||
content.type === "image" ? (
|
content.type === "image" ? (
|
||||||
<picture
|
<picture key={`${consoleOutput.id}-${content.value}`}>
|
||||||
key={`${consoleOutput.id}-img-${content.value.slice(0, 32)}`}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
alt="output"
|
alt="output"
|
||||||
className="max-w-full rounded-md"
|
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
|
||||||
src={content.value}
|
src={content.value}
|
||||||
/>
|
/>
|
||||||
</picture>
|
</picture>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="w-full whitespace-pre-line break-words"
|
className="w-full whitespace-pre-line break-words"
|
||||||
key={`${consoleOutput.id}-txt-${content.value.slice(0, 32)}`}
|
key={`${consoleOutput.id}-${content.value}`}
|
||||||
>
|
>
|
||||||
{content.value}
|
{content.value}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,6 +184,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div ref={consoleEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -23,6 +23,7 @@ export function DataStreamHandler() {
|
||||||
setDataStream([]);
|
setDataStream([]);
|
||||||
|
|
||||||
for (const delta of newDeltas) {
|
for (const delta of newDeltas) {
|
||||||
|
// Handle chat title updates
|
||||||
if (delta.type === "data-chat-title") {
|
if (delta.type === "data-chat-title") {
|
||||||
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -11,8 +11,8 @@ import { EditorState } from "prosemirror-state";
|
||||||
import { EditorView } from "prosemirror-view";
|
import { EditorView } from "prosemirror-view";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { renderToString } from "react-dom/server";
|
import { renderToString } from "react-dom/server";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
|
||||||
import { DiffType, diffEditor } from "@/lib/editor/diff";
|
import { DiffType, diffEditor } from "@/lib/editor/diff";
|
||||||
|
|
||||||
const diffSchema = new Schema({
|
const diffSchema = new Schema({
|
||||||
|
|
@ -27,11 +27,11 @@ const diffSchema = new Schema({
|
||||||
switch (mark.attrs.type) {
|
switch (mark.attrs.type) {
|
||||||
case DiffType.Inserted:
|
case DiffType.Inserted:
|
||||||
className =
|
className =
|
||||||
"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 rounded-sm px-0.5 -mx-0.5";
|
"bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300";
|
||||||
break;
|
break;
|
||||||
case DiffType.Deleted:
|
case DiffType.Deleted:
|
||||||
className =
|
className =
|
||||||
"bg-red-500/15 line-through text-red-600 dark:text-red-400 rounded-sm px-0.5 -mx-0.5 opacity-70";
|
"bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
className = "";
|
className = "";
|
||||||
|
|
@ -60,10 +60,10 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
|
||||||
const parser = DOMParser.fromSchema(diffSchema);
|
const parser = DOMParser.fromSchema(diffSchema);
|
||||||
|
|
||||||
const oldHtmlContent = renderToString(
|
const oldHtmlContent = renderToString(
|
||||||
<MessageResponse>{oldContent}</MessageResponse>
|
<Streamdown>{oldContent}</Streamdown>
|
||||||
);
|
);
|
||||||
const newHtmlContent = renderToString(
|
const newHtmlContent = renderToString(
|
||||||
<MessageResponse>{newContent}</MessageResponse>
|
<Streamdown>{newContent}</Streamdown>
|
||||||
);
|
);
|
||||||
|
|
||||||
const oldContainer = document.createElement("div");
|
const oldContainer = document.createElement("div");
|
||||||
|
|
@ -86,15 +86,6 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
|
||||||
state,
|
state,
|
||||||
editable: () => false,
|
editable: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const firstDiff = editorRef.current?.querySelector(
|
|
||||||
"[class*='bg-emerald'], [class*='bg-red']"
|
|
||||||
);
|
|
||||||
if (firstDiff) {
|
|
||||||
firstDiff.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -105,10 +96,5 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
|
||||||
};
|
};
|
||||||
}, [oldContent, newContent]);
|
}, [oldContent, newContent]);
|
||||||
|
|
||||||
return (
|
return <div className="diff-editor" ref={editorRef} />;
|
||||||
<div
|
|
||||||
className="diff-editor prose dark:prose-invert prose-neutral relative max-w-none"
|
|
||||||
ref={editorRef}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
@ -15,33 +15,21 @@ import type { Document } from "@/lib/db/schema";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
import type { ArtifactKind, UIArtifact } from "./artifact";
|
import type { ArtifactKind, UIArtifact } from "./artifact";
|
||||||
import { CodeEditor } from "./code-editor";
|
import { CodeEditor } from "./code-editor";
|
||||||
|
import { DocumentToolCall, DocumentToolResult } from "./document";
|
||||||
import { InlineDocumentSkeleton } from "./document-skeleton";
|
import { InlineDocumentSkeleton } from "./document-skeleton";
|
||||||
import {
|
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from "./icons";
|
||||||
CodeIcon,
|
|
||||||
FileIcon,
|
|
||||||
FullscreenIcon,
|
|
||||||
ImageIcon,
|
|
||||||
LoaderIcon,
|
|
||||||
} from "./icons";
|
|
||||||
import { ImageEditor } from "./image-editor";
|
import { ImageEditor } from "./image-editor";
|
||||||
import { SpreadsheetEditor } from "./sheet-editor";
|
import { SpreadsheetEditor } from "./sheet-editor";
|
||||||
import { Editor } from "./text-editor";
|
import { Editor } from "./text-editor";
|
||||||
|
|
||||||
type DocumentToolOutput = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
kind: ArtifactKind;
|
|
||||||
content?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DocumentPreviewProps = {
|
type DocumentPreviewProps = {
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
result?: Partial<DocumentToolOutput>;
|
result?: any;
|
||||||
args?: Partial<DocumentToolOutput> & { isUpdate?: boolean };
|
args?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DocumentPreview({
|
export function DocumentPreview({
|
||||||
isReadonly: _isReadonly,
|
isReadonly,
|
||||||
result,
|
result,
|
||||||
args,
|
args,
|
||||||
}: DocumentPreviewProps) {
|
}: DocumentPreviewProps) {
|
||||||
|
|
@ -49,12 +37,7 @@ export function DocumentPreview({
|
||||||
|
|
||||||
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
|
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
|
||||||
Document[]
|
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 previewDocument = useMemo(() => documents?.[0], [documents]);
|
||||||
const hitboxRef = useRef<HTMLDivElement>(null);
|
const hitboxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -75,28 +58,30 @@ export function DocumentPreview({
|
||||||
}
|
}
|
||||||
}, [artifact.documentId, setArtifact]);
|
}, [artifact.documentId, setArtifact]);
|
||||||
|
|
||||||
if (isDocumentsFetching) {
|
if (artifact.isVisible) {
|
||||||
const kind = result?.kind ?? args?.kind ?? artifact.kind;
|
if (result) {
|
||||||
const title = result?.title ?? args?.title ?? artifact.title;
|
return (
|
||||||
|
<DocumentToolResult
|
||||||
|
isReadonly={isReadonly}
|
||||||
|
result={{ id: result.id, title: result.title, kind: result.kind }}
|
||||||
|
type="create"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
if (args) {
|
||||||
<div className="w-full max-w-[450px]">
|
return (
|
||||||
{title ? (
|
<DocumentToolCall
|
||||||
<DocumentHeader isStreaming={true} kind={kind} title={title} />
|
args={{ title: args.title, kind: args.kind }}
|
||||||
) : (
|
isReadonly={isReadonly}
|
||||||
<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">
|
type="create"
|
||||||
<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>
|
if (isDocumentsFetching) {
|
||||||
)}
|
return <LoadingSkeleton artifactKind={result.kind ?? args.kind} />;
|
||||||
<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
|
const document: Document | null = previewDocument
|
||||||
|
|
@ -135,19 +120,23 @@ export function DocumentPreview({
|
||||||
|
|
||||||
const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
|
const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
|
||||||
<div className="w-full max-w-[450px]">
|
<div className="w-full max-w-[450px]">
|
||||||
<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 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-2.5">
|
<div className="flex flex-row items-center gap-3">
|
||||||
<div className="size-3.5 animate-pulse rounded bg-muted-foreground/15" />
|
<div className="text-muted-foreground">
|
||||||
<div className="h-3.5 w-24 animate-pulse rounded bg-muted-foreground/15" />
|
<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>
|
</div>
|
||||||
<div className="w-8" />
|
|
||||||
</div>
|
</div>
|
||||||
{artifactKind === "image" ? (
|
{artifactKind === "image" ? (
|
||||||
<div className="overflow-hidden rounded-b-2xl border border-t-0 border-border/50 bg-muted">
|
<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/10" />
|
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/20" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[257px] overflow-hidden rounded-b-2xl border border-t-0 border-border/50 bg-muted p-6">
|
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted p-8 pt-4 dark:border-zinc-700">
|
||||||
<InlineDocumentSkeleton />
|
<InlineDocumentSkeleton />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -160,7 +149,7 @@ const PureHitboxLayer = ({
|
||||||
setArtifact,
|
setArtifact,
|
||||||
}: {
|
}: {
|
||||||
hitboxRef: React.RefObject<HTMLDivElement>;
|
hitboxRef: React.RefObject<HTMLDivElement>;
|
||||||
result?: Partial<DocumentToolOutput>;
|
result: any;
|
||||||
setArtifact: (
|
setArtifact: (
|
||||||
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)
|
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -169,19 +158,23 @@ const PureHitboxLayer = ({
|
||||||
(event: MouseEvent<HTMLElement>) => {
|
(event: MouseEvent<HTMLElement>) => {
|
||||||
const boundingBox = event.currentTarget.getBoundingClientRect();
|
const boundingBox = event.currentTarget.getBoundingClientRect();
|
||||||
|
|
||||||
setArtifact((artifact) => ({
|
setArtifact((artifact) =>
|
||||||
...artifact,
|
artifact.status === "streaming"
|
||||||
...(result?.id && { documentId: result.id }),
|
? { ...artifact, isVisible: true }
|
||||||
...(result?.title && { title: result.title }),
|
: {
|
||||||
...(result?.kind && { kind: result.kind }),
|
...artifact,
|
||||||
isVisible: true,
|
title: result.title,
|
||||||
boundingBox: {
|
documentId: result.id,
|
||||||
left: boundingBox.x,
|
kind: result.kind,
|
||||||
top: boundingBox.y,
|
isVisible: true,
|
||||||
width: boundingBox.width,
|
boundingBox: {
|
||||||
height: boundingBox.height,
|
left: boundingBox.x,
|
||||||
},
|
top: boundingBox.y,
|
||||||
}));
|
width: boundingBox.width,
|
||||||
|
height: boundingBox.height,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[setArtifact, result]
|
[setArtifact, result]
|
||||||
);
|
);
|
||||||
|
|
@ -195,7 +188,7 @@ const PureHitboxLayer = ({
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-end p-4">
|
<div className="flex w-full items-center justify-end p-4">
|
||||||
<div className="absolute top-[13px] right-[9px] rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
<div className="absolute top-[13px] right-[9px] rounded-md p-2 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||||
<FullscreenIcon />
|
<FullscreenIcon />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -219,22 +212,20 @@ const PureDocumentHeader = ({
|
||||||
kind: ArtifactKind;
|
kind: ArtifactKind;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<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-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-center gap-2.5">
|
<div className="flex flex-row items-start gap-3 sm:items-center">
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<div className="animate-spin">
|
<div className="animate-spin">
|
||||||
<LoaderIcon size={14} />
|
<LoaderIcon />
|
||||||
</div>
|
</div>
|
||||||
) : kind === "image" ? (
|
) : kind === "image" ? (
|
||||||
<ImageIcon size={14} />
|
<ImageIcon />
|
||||||
) : kind === "code" ? (
|
|
||||||
<CodeIcon size={14} />
|
|
||||||
) : (
|
) : (
|
||||||
<FileIcon size={14} />
|
<FileIcon />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium">{title}</div>
|
<div className="-translate-y-1 font-medium sm:translate-y-0">{title}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-8" />
|
<div className="w-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -255,9 +246,9 @@ const DocumentContent = ({ document }: { document: Document }) => {
|
||||||
const { artifact } = useArtifact();
|
const { artifact } = useArtifact();
|
||||||
|
|
||||||
const containerClassName = cn(
|
const containerClassName = cn(
|
||||||
"h-[257px] overflow-hidden rounded-b-2xl border border-t-0 border-border/50 dark:bg-muted",
|
"h-[257px] overflow-y-scroll rounded-b-2xl border border-t-0 dark:border-zinc-700 dark:bg-muted",
|
||||||
{
|
{
|
||||||
"p-4 sm:px-10 sm:py-10": document.kind === "text",
|
"p-4 sm:px-14 sm:py-16": document.kind === "text",
|
||||||
"p-0": document.kind === "code",
|
"p-0": document.kind === "code",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -274,7 +265,7 @@ const DocumentContent = ({ document }: { document: Document }) => {
|
||||||
const handleSaveContent = () => null;
|
const handleSaveContent = () => null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(containerClassName, "relative")}>
|
<div className={containerClassName}>
|
||||||
{document.kind === "text" ? (
|
{document.kind === "text" ? (
|
||||||
<Editor {...commonProps} onSaveContent={handleSaveContent} />
|
<Editor {...commonProps} onSaveContent={handleSaveContent} />
|
||||||
) : document.kind === "code" ? (
|
) : document.kind === "code" ? (
|
||||||
|
|
@ -299,10 +290,6 @@ const DocumentContent = ({ document }: { document: Document }) => {
|
||||||
title={document.title}
|
title={document.title}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
39
components/document-skeleton.tsx
Normal file
39
components/document-skeleton.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -37,7 +37,7 @@ function PureDocumentToolResult({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="flex w-fit cursor-pointer flex-row items-center gap-2 rounded-xl border bg-background px-3 py-2"
|
className="flex w-fit cursor-pointer flex-row items-start gap-3 rounded-xl border bg-background px-3 py-2"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (isReadonly) {
|
if (isReadonly) {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|
@ -67,7 +67,7 @@ function PureDocumentToolResult({
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="text-muted-foreground">
|
<div className="mt-1 text-muted-foreground">
|
||||||
{type === "create" ? (
|
{type === "create" ? (
|
||||||
<FileIcon />
|
<FileIcon />
|
||||||
) : type === "update" ? (
|
) : type === "update" ? (
|
||||||
|
|
@ -88,9 +88,9 @@ export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
|
||||||
type DocumentToolCallProps = {
|
type DocumentToolCallProps = {
|
||||||
type: "create" | "update" | "request-suggestions";
|
type: "create" | "update" | "request-suggestions";
|
||||||
args:
|
args:
|
||||||
| { title: string; kind: ArtifactKind }
|
| { title: string; kind: ArtifactKind } // for create
|
||||||
| { id: string; description: string }
|
| { id: string; description: string } // for update
|
||||||
| { documentId: string };
|
| { documentId: string }; // for request-suggestions
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -130,7 +130,7 @@ function PureDocumentToolCall({
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-start gap-3">
|
<div className="flex flex-row items-start gap-3">
|
||||||
<div className="mt-1 text-neutral-500">
|
<div className="mt-1 text-zinc-500">
|
||||||
{type === "create" ? (
|
{type === "create" ? (
|
||||||
<FileIcon />
|
<FileIcon />
|
||||||
) : type === "update" ? (
|
) : type === "update" ? (
|
||||||
29
components/greeting.tsx
Normal file
29
components/greeting.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,7 +8,7 @@ import type { ChatMessage } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
MessageAction as Action,
|
MessageAction as Action,
|
||||||
MessageActions as Actions,
|
MessageActions as Actions,
|
||||||
} from "../ai-elements/message";
|
} from "./ai-elements/message";
|
||||||
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
|
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
|
||||||
|
|
||||||
export function PureMessageActions({
|
export function PureMessageActions({
|
||||||
|
|
@ -16,13 +16,13 @@ export function PureMessageActions({
|
||||||
message,
|
message,
|
||||||
vote,
|
vote,
|
||||||
isLoading,
|
isLoading,
|
||||||
onEdit,
|
setMode,
|
||||||
}: {
|
}: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
vote: Vote | undefined;
|
vote: Vote | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onEdit?: () => void;
|
setMode?: (mode: "view" | "edit") => void;
|
||||||
}) {
|
}) {
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
const [_, copyToClipboard] = useCopyToClipboard();
|
const [_, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
@ -47,25 +47,22 @@ export function PureMessageActions({
|
||||||
toast.success("Copied to clipboard!");
|
toast.success("Copied to clipboard!");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// User messages get edit (on hover) and copy actions
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
return (
|
return (
|
||||||
<Actions className="-mr-0.5 justify-end opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
|
<Actions className="-mr-0.5 justify-end">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="relative">
|
||||||
{onEdit && (
|
{setMode && (
|
||||||
<Action
|
<Action
|
||||||
className="size-7 text-muted-foreground/50 hover:text-foreground"
|
className="absolute top-0 -left-10 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
|
||||||
data-testid="message-edit-button"
|
data-testid="message-edit-button"
|
||||||
onClick={onEdit}
|
onClick={() => setMode("edit")}
|
||||||
tooltip="Edit"
|
tooltip="Edit"
|
||||||
>
|
>
|
||||||
<PencilEditIcon />
|
<PencilEditIcon />
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
<Action
|
<Action onClick={handleCopy} tooltip="Copy">
|
||||||
className="size-7 text-muted-foreground/50 hover:text-foreground"
|
|
||||||
onClick={handleCopy}
|
|
||||||
tooltip="Copy"
|
|
||||||
>
|
|
||||||
<CopyIcon />
|
<CopyIcon />
|
||||||
</Action>
|
</Action>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,17 +71,12 @@ export function PureMessageActions({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Actions className="-ml-0.5 opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
|
<Actions className="-ml-0.5">
|
||||||
<Action
|
<Action onClick={handleCopy} tooltip="Copy">
|
||||||
className="text-muted-foreground/50 hover:text-foreground"
|
|
||||||
onClick={handleCopy}
|
|
||||||
tooltip="Copy"
|
|
||||||
>
|
|
||||||
<CopyIcon />
|
<CopyIcon />
|
||||||
</Action>
|
</Action>
|
||||||
|
|
||||||
<Action
|
<Action
|
||||||
className="text-muted-foreground/50 hover:text-foreground"
|
|
||||||
data-testid="message-upvote"
|
data-testid="message-upvote"
|
||||||
disabled={vote?.isUpvoted}
|
disabled={vote?.isUpvoted}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -137,7 +129,6 @@ export function PureMessageActions({
|
||||||
</Action>
|
</Action>
|
||||||
|
|
||||||
<Action
|
<Action
|
||||||
className="text-muted-foreground/50 hover:text-foreground"
|
|
||||||
data-testid="message-downvote"
|
data-testid="message-downvote"
|
||||||
disabled={vote && !vote.isUpvoted}
|
disabled={vote && !vote.isUpvoted}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
112
components/message-editor.tsx
Normal file
112
components/message-editor.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
Reasoning,
|
Reasoning,
|
||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "../ai-elements/reasoning";
|
} from "./ai-elements/reasoning";
|
||||||
|
|
||||||
type MessageReasoningProps = {
|
type MessageReasoningProps = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
395
components/message.tsx
Normal file
395
components/message.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
||||||
|
"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,10 +1,8 @@
|
||||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||||
import { ArrowDownIcon } from "lucide-react";
|
import { ArrowDownIcon } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useMessages } from "@/hooks/use-messages";
|
import { useMessages } from "@/hooks/use-messages";
|
||||||
import type { Vote } from "@/lib/db/schema";
|
import type { Vote } from "@/lib/db/schema";
|
||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useDataStream } from "./data-stream-provider";
|
import { useDataStream } from "./data-stream-provider";
|
||||||
import { Greeting } from "./greeting";
|
import { Greeting } from "./greeting";
|
||||||
import { PreviewMessage, ThinkingMessage } from "./message";
|
import { PreviewMessage, ThinkingMessage } from "./message";
|
||||||
|
|
@ -19,9 +17,7 @@ type MessagesProps = {
|
||||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
isArtifactVisible: boolean;
|
isArtifactVisible: boolean;
|
||||||
isLoading?: boolean;
|
|
||||||
selectedModelId: string;
|
selectedModelId: string;
|
||||||
onEditMessage?: (message: ChatMessage) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function PureMessages({
|
function PureMessages({
|
||||||
|
|
@ -33,10 +29,7 @@ function PureMessages({
|
||||||
setMessages,
|
setMessages,
|
||||||
regenerate,
|
regenerate,
|
||||||
isReadonly,
|
isReadonly,
|
||||||
isArtifactVisible,
|
|
||||||
isLoading,
|
|
||||||
selectedModelId: _selectedModelId,
|
selectedModelId: _selectedModelId,
|
||||||
onEditMessage,
|
|
||||||
}: MessagesProps) {
|
}: MessagesProps) {
|
||||||
const {
|
const {
|
||||||
containerRef: messagesContainerRef,
|
containerRef: messagesContainerRef,
|
||||||
|
|
@ -44,37 +37,21 @@ function PureMessages({
|
||||||
isAtBottom,
|
isAtBottom,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
hasSentMessage,
|
hasSentMessage,
|
||||||
reset,
|
|
||||||
} = useMessages({
|
} = useMessages({
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
|
|
||||||
useDataStream();
|
useDataStream();
|
||||||
|
|
||||||
const prevChatIdRef = useRef(chatId);
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevChatIdRef.current !== chatId) {
|
|
||||||
prevChatIdRef.current = chatId;
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
}, [chatId, reset]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 bg-background">
|
<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
|
<div
|
||||||
className={cn(
|
className="absolute inset-0 touch-pan-y overflow-y-auto bg-background"
|
||||||
"absolute inset-0 touch-pan-y overflow-y-auto",
|
|
||||||
messages.length > 0 ? "bg-background" : "bg-transparent"
|
|
||||||
)}
|
|
||||||
ref={messagesContainerRef}
|
ref={messagesContainerRef}
|
||||||
style={isArtifactVisible ? { scrollbarWidth: "none" } : undefined}
|
|
||||||
>
|
>
|
||||||
<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">
|
<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 />}
|
||||||
|
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<PreviewMessage
|
<PreviewMessage
|
||||||
addToolApprovalResponse={addToolApprovalResponse}
|
addToolApprovalResponse={addToolApprovalResponse}
|
||||||
|
|
@ -85,7 +62,6 @@ function PureMessages({
|
||||||
isReadonly={isReadonly}
|
isReadonly={isReadonly}
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
onEdit={onEditMessage}
|
|
||||||
regenerate={regenerate}
|
regenerate={regenerate}
|
||||||
requiresScrollPadding={
|
requiresScrollPadding={
|
||||||
hasSentMessage && index === messages.length - 1
|
hasSentMessage && index === messages.length - 1
|
||||||
|
|
@ -99,9 +75,12 @@ function PureMessages({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{status === "submitted" && messages.at(-1)?.role !== "assistant" && (
|
{status === "submitted" &&
|
||||||
<ThinkingMessage />
|
!messages.some((msg) =>
|
||||||
)}
|
msg.parts?.some(
|
||||||
|
(part) => "state" in part && part.state === "approval-responded"
|
||||||
|
)
|
||||||
|
) && <ThinkingMessage />}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="min-h-[24px] min-w-[24px] shrink-0"
|
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||||
|
|
@ -112,15 +91,15 @@ function PureMessages({
|
||||||
|
|
||||||
<button
|
<button
|
||||||
aria-label="Scroll to bottom"
|
aria-label="Scroll to bottom"
|
||||||
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] ${
|
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 ${
|
||||||
isAtBottom
|
isAtBottom
|
||||||
? "pointer-events-none scale-90 opacity-0"
|
? "pointer-events-none scale-0 opacity-0"
|
||||||
: "pointer-events-auto scale-100 opacity-100"
|
: "pointer-events-auto scale-100 opacity-100"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => scrollToBottom("smooth")}
|
onClick={() => scrollToBottom("smooth")}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ArrowDownIcon className="size-3 text-muted-foreground" />
|
<ArrowDownIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -3,15 +3,7 @@
|
||||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import equal from "fast-deep-equal";
|
import equal from "fast-deep-equal";
|
||||||
import {
|
import { ArrowUpIcon, CheckIcon } from "lucide-react";
|
||||||
ArrowUpIcon,
|
|
||||||
BrainIcon,
|
|
||||||
EyeIcon,
|
|
||||||
LockIcon,
|
|
||||||
WrenchIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import {
|
import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
|
|
@ -23,7 +15,6 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
|
||||||
import { useLocalStorage, useWindowSize } from "usehooks-ts";
|
import { useLocalStorage, useWindowSize } from "usehooks-ts";
|
||||||
import {
|
import {
|
||||||
ModelSelector,
|
ModelSelector,
|
||||||
|
|
@ -37,11 +28,11 @@ import {
|
||||||
ModelSelectorTrigger,
|
ModelSelectorTrigger,
|
||||||
} from "@/components/ai-elements/model-selector";
|
} from "@/components/ai-elements/model-selector";
|
||||||
import {
|
import {
|
||||||
type ChatModel,
|
|
||||||
chatModels,
|
chatModels,
|
||||||
DEFAULT_CHAT_MODEL,
|
DEFAULT_CHAT_MODEL,
|
||||||
type ModelCapabilities,
|
modelsByProvider,
|
||||||
} from "@/lib/ai/models";
|
} from "@/lib/ai/models";
|
||||||
|
import { signIn, useSession } from "@/lib/client";
|
||||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
|
|
@ -50,20 +41,15 @@ import {
|
||||||
PromptInputSubmit,
|
PromptInputSubmit,
|
||||||
PromptInputTextarea,
|
PromptInputTextarea,
|
||||||
PromptInputTools,
|
PromptInputTools,
|
||||||
} from "../ai-elements/prompt-input";
|
} from "./ai-elements/prompt-input";
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { PaperclipIcon, StopIcon } from "./icons";
|
import { PaperclipIcon, StopIcon } from "./icons";
|
||||||
import { PreviewAttachment } from "./preview-attachment";
|
import { PreviewAttachment } from "./preview-attachment";
|
||||||
import {
|
|
||||||
type SlashCommand,
|
|
||||||
SlashCommandMenu,
|
|
||||||
slashCommands,
|
|
||||||
} from "./slash-commands";
|
|
||||||
import { SuggestedActions } from "./suggested-actions";
|
import { SuggestedActions } from "./suggested-actions";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import type { VisibilityType } from "./visibility-selector";
|
import type { VisibilityType } from "./visibility-selector";
|
||||||
|
|
||||||
function setCookie(name: string, value: string) {
|
function setCookie(name: string, value: string) {
|
||||||
const maxAge = 60 * 60 * 24 * 365;
|
const maxAge = 60 * 60 * 24 * 365; // 1 year
|
||||||
// biome-ignore lint/suspicious/noDocumentCookie: needed for client-side cookie setting
|
// biome-ignore lint/suspicious/noDocumentCookie: needed for client-side cookie setting
|
||||||
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`;
|
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`;
|
||||||
}
|
}
|
||||||
|
|
@ -83,9 +69,6 @@ function PureMultimodalInput({
|
||||||
selectedVisibilityType,
|
selectedVisibilityType,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
editingMessage,
|
|
||||||
onCancelEdit,
|
|
||||||
isLoading,
|
|
||||||
}: {
|
}: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
input: string;
|
input: string;
|
||||||
|
|
@ -96,21 +79,17 @@ function PureMultimodalInput({
|
||||||
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||||
sendMessage:
|
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||||
| UseChatHelpers<ChatMessage>["sendMessage"]
|
|
||||||
| (() => Promise<void>);
|
|
||||||
className?: string;
|
className?: string;
|
||||||
selectedVisibilityType: VisibilityType;
|
selectedVisibilityType: VisibilityType;
|
||||||
selectedModelId: string;
|
selectedModelId: string;
|
||||||
onModelChange?: (modelId: string) => void;
|
onModelChange?: (modelId: string) => void;
|
||||||
editingMessage?: ChatMessage | null;
|
|
||||||
onCancelEdit?: () => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
|
||||||
const { setTheme, resolvedTheme } = useTheme();
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
|
const { data: session, refetch: refetchSession } = useSession();
|
||||||
|
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||||
|
|
||||||
const hasAutoFocused = useRef(false);
|
const hasAutoFocused = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAutoFocused.current && width) {
|
if (!hasAutoFocused.current && width) {
|
||||||
|
|
@ -130,9 +109,12 @@ function PureMultimodalInput({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
const domValue = textareaRef.current.value;
|
const domValue = textareaRef.current.value;
|
||||||
|
// Prefer DOM value over localStorage to handle hydration
|
||||||
const finalValue = domValue || localStorageInput || "";
|
const finalValue = domValue || localStorageInput || "";
|
||||||
setInput(finalValue);
|
setInput(finalValue);
|
||||||
}
|
}
|
||||||
|
// Only run once after hydration
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [localStorageInput, setInput]);
|
}, [localStorageInput, setInput]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -140,80 +122,11 @@ function PureMultimodalInput({
|
||||||
}, [input, setLocalStorageInput]);
|
}, [input, setLocalStorageInput]);
|
||||||
|
|
||||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const val = event.target.value;
|
setInput(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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
|
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
|
||||||
const [slashOpen, setSlashOpen] = useState(false);
|
|
||||||
const [slashQuery, setSlashQuery] = useState("");
|
|
||||||
const [slashIndex, setSlashIndex] = useState(0);
|
|
||||||
|
|
||||||
const submitForm = useCallback(() => {
|
const submitForm = useCallback(() => {
|
||||||
window.history.pushState(
|
window.history.pushState(
|
||||||
|
|
@ -303,8 +216,8 @@ function PureMultimodalInput({
|
||||||
...currentAttachments,
|
...currentAttachments,
|
||||||
...successfullyUploadedAttachments,
|
...successfullyUploadedAttachments,
|
||||||
]);
|
]);
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
toast.error("Failed to upload files");
|
console.error("Error uploading files!", error);
|
||||||
} finally {
|
} finally {
|
||||||
setUploadQueue([]);
|
setUploadQueue([]);
|
||||||
}
|
}
|
||||||
|
|
@ -327,6 +240,7 @@ function PureMultimodalInput({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent default paste behavior for images
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
setUploadQueue((prev) => [...prev, "Pasted image"]);
|
setUploadQueue((prev) => [...prev, "Pasted image"]);
|
||||||
|
|
@ -349,7 +263,8 @@ function PureMultimodalInput({
|
||||||
...curr,
|
...curr,
|
||||||
...(successfullyUploadedAttachments as Attachment[]),
|
...(successfullyUploadedAttachments as Attachment[]),
|
||||||
]);
|
]);
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
|
console.error("Error uploading pasted images:", error);
|
||||||
toast.error("Failed to upload pasted image(s)");
|
toast.error("Failed to upload pasted image(s)");
|
||||||
} finally {
|
} finally {
|
||||||
setUploadQueue([]);
|
setUploadQueue([]);
|
||||||
|
|
@ -358,6 +273,7 @@ function PureMultimodalInput({
|
||||||
[setAttachments, uploadFile]
|
[setAttachments, uploadFile]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add paste event listener to textarea
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) {
|
if (!textarea) {
|
||||||
|
|
@ -370,25 +286,7 @@ function PureMultimodalInput({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative flex w-full flex-col gap-4", className)}>
|
<div className={cn("relative flex w-full flex-col gap-4", className)}>
|
||||||
{editingMessage && onCancelEdit && (
|
{messages.length === 0 &&
|
||||||
<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 &&
|
attachments.length === 0 &&
|
||||||
uploadQueue.length === 0 && (
|
uploadQueue.length === 0 && (
|
||||||
<SuggestedActions
|
<SuggestedActions
|
||||||
|
|
@ -407,32 +305,24 @@ function PureMultimodalInput({
|
||||||
type="file"
|
type="file"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{slashOpen && (
|
|
||||||
<SlashCommandMenu
|
|
||||||
onClose={() => setSlashOpen(false)}
|
|
||||||
onSelect={handleSlashSelect}
|
|
||||||
query={slashQuery}
|
|
||||||
selectedIndex={slashIndex}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PromptInput
|
<PromptInput
|
||||||
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)]"
|
className="[&>div]:rounded-xl"
|
||||||
onSubmit={() => {
|
onSubmit={async () => {
|
||||||
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) {
|
if (!input.trim() && attachments.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status === "ready" || status === "error") {
|
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") {
|
||||||
submitForm();
|
submitForm();
|
||||||
} else {
|
} else {
|
||||||
toast.error("Please wait for the model to finish its response!");
|
toast.error("Please wait for the model to finish its response!");
|
||||||
|
|
@ -441,7 +331,7 @@ function PureMultimodalInput({
|
||||||
>
|
>
|
||||||
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
||||||
<div
|
<div
|
||||||
className="flex w-full self-start flex-row gap-2 overflow-x-auto px-3 pt-3 no-scrollbar"
|
className="flex flex-row items-end gap-2 overflow-x-scroll"
|
||||||
data-testid="attachments-preview"
|
data-testid="attachments-preview"
|
||||||
>
|
>
|
||||||
{attachments.map((attachment) => (
|
{attachments.map((attachment) => (
|
||||||
|
|
@ -473,49 +363,14 @@ function PureMultimodalInput({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PromptInputTextarea
|
<PromptInputTextarea
|
||||||
className="min-h-24 text-[13px] leading-relaxed px-4 pt-3.5 pb-1.5 placeholder:text-muted-foreground/35"
|
className="p-6 min-h-24"
|
||||||
data-testid="multimodal-input"
|
data-testid="multimodal-input"
|
||||||
onChange={handleInput}
|
onChange={handleInput}
|
||||||
onKeyDown={(e) => {
|
placeholder="Send a message..."
|
||||||
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}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
/>
|
/>
|
||||||
<PromptInputFooter className="px-3 pb-3">
|
<PromptInputFooter>
|
||||||
<PromptInputTools>
|
<PromptInputTools>
|
||||||
<AttachmentsButton
|
<AttachmentsButton
|
||||||
fileInputRef={fileInputRef}
|
fileInputRef={fileInputRef}
|
||||||
|
|
@ -532,12 +387,7 @@ function PureMultimodalInput({
|
||||||
<StopButton setMessages={setMessages} stop={stop} />
|
<StopButton setMessages={setMessages} stop={stop} />
|
||||||
) : (
|
) : (
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
className={cn(
|
className="rounded-full"
|
||||||
"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"
|
data-testid="send-button"
|
||||||
disabled={!input.trim() || uploadQueue.length > 0}
|
disabled={!input.trim() || uploadQueue.length > 0}
|
||||||
status={status}
|
status={status}
|
||||||
|
|
@ -570,15 +420,6 @@ export const MultimodalInput = memo(
|
||||||
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
|
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -593,26 +434,14 @@ function PureAttachmentsButton({
|
||||||
status: UseChatHelpers<ChatMessage>["status"];
|
status: UseChatHelpers<ChatMessage>["status"];
|
||||||
selectedModelId: string;
|
selectedModelId: string;
|
||||||
}) {
|
}) {
|
||||||
const { data: modelsResponse } = useSWR(
|
const isReasoningModel =
|
||||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`,
|
selectedModelId.includes("reasoning") || selectedModelId.includes("think");
|
||||||
(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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className="aspect-square h-8 rounded-lg p-1 transition-colors hover:bg-accent"
|
||||||
"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"
|
data-testid="attachments-button"
|
||||||
disabled={status !== "ready" || !hasVision}
|
disabled={status !== "ready" || isReasoningModel}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
|
|
@ -634,31 +463,26 @@ function PureModelSelectorCompact({
|
||||||
onModelChange?: (modelId: string) => void;
|
onModelChange?: (modelId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
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 =
|
const selectedModel =
|
||||||
activeModels.find((m: ChatModel) => m.id === selectedModelId) ??
|
chatModels.find((m) => m.id === selectedModelId) ??
|
||||||
activeModels.find((m: ChatModel) => m.id === DEFAULT_CHAT_MODEL) ??
|
chatModels.find((m) => m.id === DEFAULT_CHAT_MODEL) ??
|
||||||
activeModels[0];
|
chatModels[0];
|
||||||
const [provider] = selectedModel.id.split("/");
|
const [provider] = selectedModel.id.split("/");
|
||||||
|
|
||||||
|
// Provider display names
|
||||||
|
const providerNames: Record<string, string> = {
|
||||||
|
anthropic: "Anthropic",
|
||||||
|
openai: "OpenAI",
|
||||||
|
google: "Google",
|
||||||
|
xai: "xAI",
|
||||||
|
reasoning: "Reasoning",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModelSelector onOpenChange={setOpen} open={open}>
|
<ModelSelector onOpenChange={setOpen} open={open}>
|
||||||
<ModelSelectorTrigger asChild>
|
<ModelSelectorTrigger asChild>
|
||||||
<Button
|
<Button className="h-8 w-[200px] justify-between px-2" variant="ghost">
|
||||||
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} />}
|
{provider && <ModelSelectorLogo provider={provider} />}
|
||||||
<ModelSelectorName>{selectedModel.name}</ModelSelectorName>
|
<ModelSelectorName>{selectedModel.name}</ModelSelectorName>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -666,123 +490,35 @@ function PureModelSelectorCompact({
|
||||||
<ModelSelectorContent>
|
<ModelSelectorContent>
|
||||||
<ModelSelectorInput placeholder="Search models..." />
|
<ModelSelectorInput placeholder="Search models..." />
|
||||||
<ModelSelectorList>
|
<ModelSelectorList>
|
||||||
{(() => {
|
{Object.entries(modelsByProvider).map(
|
||||||
const curatedIds = new Set(chatModels.map((m) => m.id));
|
([providerKey, providerModels]) => (
|
||||||
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
|
<ModelSelectorGroup
|
||||||
heading={
|
heading={providerNames[providerKey] ?? providerKey}
|
||||||
key === "_available"
|
key={providerKey}
|
||||||
? "Available"
|
|
||||||
: (providerNames[key] ?? key)
|
|
||||||
}
|
|
||||||
key={key}
|
|
||||||
>
|
>
|
||||||
{grouped[key].map(({ model, curated }) => {
|
{providerModels.map((model) => {
|
||||||
const logoProvider = model.id.split("/")[0];
|
const logoProvider = model.id.split("/")[0];
|
||||||
return (
|
return (
|
||||||
<ModelSelectorItem
|
<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}
|
key={model.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (!curated) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onModelChange?.(model.id);
|
onModelChange?.(model.id);
|
||||||
setCookie("chat-model", model.id);
|
setCookie("chat-model", model.id);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setTimeout(() => {
|
|
||||||
document
|
|
||||||
.querySelector<HTMLTextAreaElement>(
|
|
||||||
"[data-testid='multimodal-input']"
|
|
||||||
)
|
|
||||||
?.focus();
|
|
||||||
}, 50);
|
|
||||||
}}
|
}}
|
||||||
value={model.id}
|
value={model.id}
|
||||||
>
|
>
|
||||||
<ModelSelectorLogo provider={logoProvider} />
|
<ModelSelectorLogo provider={logoProvider} />
|
||||||
<ModelSelectorName>{model.name}</ModelSelectorName>
|
<ModelSelectorName>{model.name}</ModelSelectorName>
|
||||||
<div className="ml-auto flex items-center gap-2 text-foreground/70">
|
{model.id === selectedModel.id && (
|
||||||
{capabilities?.[model.id]?.tools && (
|
<CheckIcon className="ml-auto size-4" />
|
||||||
<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>
|
</ModelSelectorItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ModelSelectorGroup>
|
</ModelSelectorGroup>
|
||||||
));
|
)
|
||||||
})()}
|
)}
|
||||||
</ModelSelectorList>
|
</ModelSelectorList>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelector>
|
</ModelSelector>
|
||||||
|
|
@ -800,7 +536,7 @@ function PureStopButton({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
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"
|
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"
|
||||||
data-testid="stop-button"
|
data-testid="stop-button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Attachment } from "@/lib/types";
|
import type { Attachment } from "@/lib/types";
|
||||||
import { Spinner } from "../ui/spinner";
|
|
||||||
import { CrossSmallIcon } from "./icons";
|
import { CrossSmallIcon } from "./icons";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Spinner } from "./ui/spinner";
|
||||||
|
|
||||||
export const PreviewAttachment = ({
|
export const PreviewAttachment = ({
|
||||||
attachment,
|
attachment,
|
||||||
|
|
@ -16,16 +17,16 @@ export const PreviewAttachment = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group relative h-24 w-24 shrink-0 overflow-hidden rounded-xl border border-border/40 bg-muted"
|
className="group relative size-16 overflow-hidden rounded-lg border bg-muted"
|
||||||
data-testid="input-attachment-preview"
|
data-testid="input-attachment-preview"
|
||||||
>
|
>
|
||||||
{contentType?.startsWith("image") ? (
|
{contentType?.startsWith("image") ? (
|
||||||
<Image
|
<Image
|
||||||
alt={name ?? "attachment"}
|
alt={name ?? "An image attachment"}
|
||||||
className="size-full object-cover"
|
className="size-full object-cover"
|
||||||
height={96}
|
height={64}
|
||||||
src={url}
|
src={url}
|
||||||
width={96}
|
width={64}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
|
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
|
||||||
|
|
@ -35,22 +36,27 @@ export const PreviewAttachment = ({
|
||||||
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40 backdrop-blur-sm"
|
className="absolute inset-0 flex items-center justify-center bg-black/50"
|
||||||
data-testid="input-attachment-loader"
|
data-testid="input-attachment-loader"
|
||||||
>
|
>
|
||||||
<Spinner className="size-5" />
|
<Spinner className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onRemove && !isUploading && (
|
{onRemove && !isUploading && (
|
||||||
<button
|
<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"
|
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
type="button"
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
<CrossSmallIcon size={10} />
|
<CrossSmallIcon size={8} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -50,9 +50,8 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
|
||||||
frozen: true,
|
frozen: true,
|
||||||
width: 50,
|
width: 50,
|
||||||
renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1,
|
renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1,
|
||||||
cellClass: "border-t border-r dark:bg-neutral-950 dark:text-neutral-50",
|
cellClass: "border-t border-r dark:bg-zinc-950 dark:text-zinc-50",
|
||||||
headerCellClass:
|
headerCellClass: "border-t border-r dark:bg-zinc-900 dark:text-zinc-50",
|
||||||
"border-t border-r dark:bg-neutral-900 dark:text-neutral-50",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({
|
const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({
|
||||||
|
|
@ -60,10 +59,10 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
|
||||||
name: String.fromCharCode(65 + i),
|
name: String.fromCharCode(65 + i),
|
||||||
renderEditCell: textEditor,
|
renderEditCell: textEditor,
|
||||||
width: 120,
|
width: 120,
|
||||||
cellClass: cn("border-t dark:bg-neutral-950 dark:text-neutral-50", {
|
cellClass: cn("border-t dark:bg-zinc-950 dark:text-zinc-50", {
|
||||||
"border-l": i !== 0,
|
"border-l": i !== 0,
|
||||||
}),
|
}),
|
||||||
headerCellClass: cn("border-t dark:bg-neutral-900 dark:text-neutral-50", {
|
headerCellClass: cn("border-t dark:bg-zinc-900 dark:text-zinc-50", {
|
||||||
"border-l": i !== 0,
|
"border-l": i !== 0,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
@ -73,7 +72,7 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
|
||||||
|
|
||||||
const initialRows = useMemo(() => {
|
const initialRows = useMemo(() => {
|
||||||
return parseData.map((row, rowIndex) => {
|
return parseData.map((row, rowIndex) => {
|
||||||
const rowData: Record<string, string | number> = {
|
const rowData: any = {
|
||||||
id: rowIndex,
|
id: rowIndex,
|
||||||
rowNumber: rowIndex + 1,
|
rowNumber: rowIndex + 1,
|
||||||
};
|
};
|
||||||
|
|
@ -92,15 +91,15 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
|
||||||
setLocalRows(initialRows);
|
setLocalRows(initialRows);
|
||||||
}, [initialRows]);
|
}, [initialRows]);
|
||||||
|
|
||||||
const generateCsv = (data: string[][]) => {
|
const generateCsv = (data: any[][]) => {
|
||||||
return unparse(data);
|
return unparse(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowsChange = (newRows: Record<string, string | number>[]) => {
|
const handleRowsChange = (newRows: any[]) => {
|
||||||
setLocalRows(newRows);
|
setLocalRows(newRows);
|
||||||
|
|
||||||
const updatedData = newRows.map((row) => {
|
const updatedData = newRows.map((row) => {
|
||||||
return columns.slice(1).map((col) => String(row[col.key] ?? ""));
|
return columns.slice(1).map((col) => row[col.key] || "");
|
||||||
});
|
});
|
||||||
|
|
||||||
const newCsvContent = generateCsv(updatedData);
|
const newCsvContent = generateCsv(updatedData);
|
||||||
|
|
@ -2,6 +2,14 @@ import Link from "next/link";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { useChatVisibility } from "@/hooks/use-chat-visibility";
|
import { useChatVisibility } from "@/hooks/use-chat-visibility";
|
||||||
import type { Chat } from "@/lib/db/schema";
|
import type { Chat } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
CheckCircleFillIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LockIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
ShareIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from "./icons";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -11,20 +19,12 @@ import {
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "./ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
SidebarMenuAction,
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "../ui/sidebar";
|
} from "./ui/sidebar";
|
||||||
import {
|
|
||||||
CheckCircleFillIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
LockIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
ShareIcon,
|
|
||||||
TrashIcon,
|
|
||||||
} from "./icons";
|
|
||||||
|
|
||||||
const PureChatItem = ({
|
const PureChatItem = ({
|
||||||
chat,
|
chat,
|
||||||
|
|
@ -44,20 +44,16 @@ const PureChatItem = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton asChild isActive={isActive}>
|
||||||
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)}>
|
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
|
||||||
<span className="truncate">{chat.title}</span>
|
<span>{chat.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|
||||||
<DropdownMenu modal={true}>
|
<DropdownMenu modal={true}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuAction
|
<SidebarMenuAction
|
||||||
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"
|
className="mr-0.5 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
showOnHover={!isActive}
|
showOnHover={!isActive}
|
||||||
>
|
>
|
||||||
<MoreHorizontalIcon />
|
<MoreHorizontalIcon />
|
||||||
|
|
@ -104,8 +100,8 @@ const PureChatItem = ({
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-destructive focus:bg-destructive/15 focus:text-destructive dark:text-red-500"
|
||||||
onSelect={() => onDelete(chat.id)}
|
onSelect={() => onDelete(chat.id)}
|
||||||
variant="destructive"
|
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
|
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import type { User } from "next-auth";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWRInfinite from "swr/infinite";
|
import useSWRInfinite from "swr/infinite";
|
||||||
|
|
@ -20,10 +19,10 @@ import {
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import type { AuthUser } from "@/lib/auth";
|
||||||
import type { Chat } from "@/lib/db/schema";
|
import type { Chat } from "@/lib/db/schema";
|
||||||
import { fetcher } from "@/lib/utils";
|
import { fetcher } from "@/lib/utils";
|
||||||
import { LoaderIcon } from "./icons";
|
import { LoaderIcon } from "./icons";
|
||||||
|
|
@ -98,7 +97,7 @@ export function getChatHistoryPaginationKey(
|
||||||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
|
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarHistory({ user }: { user: User | undefined }) {
|
export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
|
||||||
const { setOpenMobile } = useSidebar();
|
const { setOpenMobile } = useSidebar();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null;
|
const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null;
|
||||||
|
|
@ -112,7 +111,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
} = useSWRInfinite<ChatHistory>(
|
} = useSWRInfinite<ChatHistory>(
|
||||||
user ? getChatHistoryPaginationKey : () => null,
|
user ? getChatHistoryPaginationKey : () => null,
|
||||||
fetcher,
|
fetcher,
|
||||||
{ fallbackData: [], revalidateOnFocus: false }
|
{ fallbackData: [] }
|
||||||
);
|
);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -133,32 +132,43 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
|
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
|
|
||||||
if (isCurrentChat) {
|
const deletePromise = fetch(
|
||||||
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}`,
|
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatToDelete}`,
|
||||||
{ method: "DELETE" }
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
toast.success("Chat deleted");
|
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",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
<SidebarGroup>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
|
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
|
||||||
Login to save and revisit previous chats!
|
Login to save and revisit previous chats!
|
||||||
</div>
|
</div>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
|
|
@ -168,19 +178,19 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||||
History
|
Today
|
||||||
</SidebarGroupLabel>
|
</div>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<div className="flex flex-col gap-0.5 px-1">
|
<div className="flex flex-col">
|
||||||
{[44, 32, 28, 64, 52].map((item) => (
|
{[44, 32, 28, 64, 52].map((item) => (
|
||||||
<div
|
<div
|
||||||
className="flex h-8 items-center gap-2 rounded-lg px-2"
|
className="flex h-8 items-center gap-2 rounded-md px-2"
|
||||||
key={item}
|
key={item}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="h-3 max-w-(--skeleton-width) flex-1 animate-pulse rounded-md bg-sidebar-foreground/[0.06]"
|
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--skeleton-width": `${item}%`,
|
"--skeleton-width": `${item}%`,
|
||||||
|
|
@ -197,12 +207,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
|
|
||||||
if (hasEmptyChatHistory) {
|
if (hasEmptyChatHistory) {
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
|
||||||
History
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
|
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
|
||||||
Your conversations will appear here once you start chatting!
|
Your conversations will appear here once you start chatting!
|
||||||
</div>
|
</div>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
|
|
@ -212,10 +219,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
|
||||||
History
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{paginatedChatHistories &&
|
{paginatedChatHistories &&
|
||||||
|
|
@ -227,10 +231,10 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
const groupedChats = groupChatsByDate(chatsFromHistory);
|
const groupedChats = groupChatsByDate(chatsFromHistory);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-6">
|
||||||
{groupedChats.today.length > 0 && (
|
{groupedChats.today.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||||
Today
|
Today
|
||||||
</div>
|
</div>
|
||||||
{groupedChats.today.map((chat) => (
|
{groupedChats.today.map((chat) => (
|
||||||
|
|
@ -250,7 +254,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
|
|
||||||
{groupedChats.yesterday.length > 0 && (
|
{groupedChats.yesterday.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||||
Yesterday
|
Yesterday
|
||||||
</div>
|
</div>
|
||||||
{groupedChats.yesterday.map((chat) => (
|
{groupedChats.yesterday.map((chat) => (
|
||||||
|
|
@ -270,7 +274,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
|
|
||||||
{groupedChats.lastWeek.length > 0 && (
|
{groupedChats.lastWeek.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||||
Last 7 days
|
Last 7 days
|
||||||
</div>
|
</div>
|
||||||
{groupedChats.lastWeek.map((chat) => (
|
{groupedChats.lastWeek.map((chat) => (
|
||||||
|
|
@ -290,7 +294,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
|
|
||||||
{groupedChats.lastMonth.length > 0 && (
|
{groupedChats.lastMonth.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||||
Last 30 days
|
Last 30 days
|
||||||
</div>
|
</div>
|
||||||
{groupedChats.lastMonth.map((chat) => (
|
{groupedChats.lastMonth.map((chat) => (
|
||||||
|
|
@ -310,8 +314,8 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
|
|
||||||
{groupedChats.older.length > 0 && (
|
{groupedChats.older.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
|
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||||
Older
|
Older than last month
|
||||||
</div>
|
</div>
|
||||||
{groupedChats.older.map((chat) => (
|
{groupedChats.older.map((chat) => (
|
||||||
<ChatItem
|
<ChatItem
|
||||||
|
|
@ -340,12 +344,16 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasReachedEnd ? null : (
|
{hasReachedEnd ? (
|
||||||
<div className="mt-1 flex flex-row items-center gap-2 px-4 py-2 text-sidebar-foreground/50">
|
<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">
|
||||||
<div className="animate-spin">
|
<div className="animate-spin">
|
||||||
<LoaderIcon />
|
<LoaderIcon />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px]">Loading...</div>
|
<div>Loading Chats...</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
|
|
@ -6,8 +6,8 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { SidebarLeftIcon } from "./icons";
|
import { SidebarLeftIcon } from "./icons";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
export function SidebarToggle({
|
export function SidebarToggle({
|
||||||
className,
|
className,
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronUp } from "lucide-react";
|
import { ChevronUp } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { User } from "next-auth";
|
|
||||||
import { signOut, useSession } from "next-auth/react";
|
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -17,67 +16,64 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { guestRegex } from "@/lib/constants";
|
import { signOut, useSession } from "@/lib/client";
|
||||||
import { LoaderIcon } from "./icons";
|
import { LoaderIcon } from "./icons";
|
||||||
import { toast } from "./toast";
|
import { toast } from "./toast";
|
||||||
|
|
||||||
function emailToHue(email: string): number {
|
export function SidebarUserNav({
|
||||||
let hash = 0;
|
user,
|
||||||
for (const char of email) {
|
}: {
|
||||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
user: { email?: string | null; isAnonymous?: boolean | null };
|
||||||
}
|
}) {
|
||||||
return Math.abs(hash) % 360;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarUserNav({ user }: { user: User }) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data, status } = useSession();
|
const { data, isPending } = useSession();
|
||||||
const { setTheme, resolvedTheme } = useTheme();
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
|
|
||||||
const isGuest = guestRegex.test(data?.user?.email ?? "");
|
const isGuest = data?.user?.isAnonymous ?? user.isAnonymous ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
{status === "loading" ? (
|
{isPending ? (
|
||||||
<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">
|
<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 items-center gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<div className="size-6 animate-pulse rounded-full bg-sidebar-foreground/10" />
|
<div className="size-6 animate-pulse rounded-full bg-zinc-500/30" />
|
||||||
<span className="animate-pulse rounded-md bg-sidebar-foreground/10 text-transparent text-[13px]">
|
<span className="animate-pulse rounded-md bg-zinc-500/30 text-transparent">
|
||||||
Loading...
|
Loading auth status
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-spin text-sidebar-foreground/50">
|
<div className="animate-spin text-zinc-500">
|
||||||
<LoaderIcon />
|
<LoaderIcon />
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
) : (
|
) : (
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
className="h-8 px-2 rounded-lg bg-transparent text-sidebar-foreground/70 transition-colors duration-150 hover:text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="h-10 bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
data-testid="user-nav-button"
|
data-testid="user-nav-button"
|
||||||
>
|
>
|
||||||
<div
|
<Image
|
||||||
className="size-5 shrink-0 rounded-full ring-1 ring-sidebar-border/50"
|
alt={user.email ?? "User Avatar"}
|
||||||
style={{
|
className="rounded-full"
|
||||||
background: `linear-gradient(135deg, oklch(0.35 0.08 ${emailToHue(user.email ?? "")}), oklch(0.25 0.05 ${emailToHue(user.email ?? "") + 40}))`,
|
height={24}
|
||||||
}}
|
src={`https://avatar.vercel.sh/${user.email}`}
|
||||||
|
width={24}
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-[13px]" data-testid="user-email">
|
<span className="truncate" data-testid="user-email">
|
||||||
{isGuest ? "Guest" : user?.email}
|
{isGuest ? "Guest" : user?.email}
|
||||||
</span>
|
</span>
|
||||||
<ChevronUp className="ml-auto size-3.5 text-sidebar-foreground/50" />
|
<ChevronUp className="ml-auto" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-(--radix-popper-anchor-width) rounded-lg border border-border/60 bg-card/95 backdrop-blur-xl shadow-[var(--shadow-float)]"
|
className="w-(--radix-popper-anchor-width)"
|
||||||
data-testid="user-nav-menu"
|
data-testid="user-nav-menu"
|
||||||
side="top"
|
side="top"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer text-[13px]"
|
className="cursor-pointer"
|
||||||
data-testid="user-nav-item-theme"
|
data-testid="user-nav-item-theme"
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||||
|
|
@ -88,9 +84,9 @@ export function SidebarUserNav({ user }: { user: User }) {
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
|
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
|
||||||
<button
|
<button
|
||||||
className="w-full cursor-pointer text-[13px]"
|
className="w-full cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (status === "loading") {
|
if (isPending) {
|
||||||
toast({
|
toast({
|
||||||
type: "error",
|
type: "error",
|
||||||
description:
|
description:
|
||||||
|
|
@ -104,7 +100,12 @@ export function SidebarUserNav({ user }: { user: User }) {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
} else {
|
} else {
|
||||||
signOut({
|
signOut({
|
||||||
redirectTo: "/",
|
fetchOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import Form from "next/form";
|
import Form from "next/form";
|
||||||
|
import { headers } from "next/headers";
|
||||||
import { signOut } from "@/app/(auth)/auth";
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
export const SignOutForm = () => {
|
export const SignOutForm = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -8,9 +9,11 @@ export const SignOutForm = () => {
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
await signOut({
|
await auth.api.signOut({
|
||||||
redirectTo: "/",
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
redirect("/");
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import { useFormStatus } from "react-dom";
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
import { LoaderIcon } from "@/components/chat/icons";
|
import { LoaderIcon } from "@/components/icons";
|
||||||
|
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
export function SubmitButton({
|
export function SubmitButton({
|
||||||
children,
|
children,
|
||||||
|
|
@ -3,9 +3,8 @@
|
||||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { suggestions } from "@/lib/constants";
|
|
||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { Suggestion } from "../ai-elements/suggestion";
|
import { Suggestion } from "./ai-elements/suggestion";
|
||||||
import type { VisibilityType } from "./visibility-selector";
|
import type { VisibilityType } from "./visibility-selector";
|
||||||
|
|
||||||
type SuggestedActionsProps = {
|
type SuggestedActionsProps = {
|
||||||
|
|
@ -15,33 +14,28 @@ type SuggestedActionsProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
|
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
|
||||||
const suggestedActions = suggestions;
|
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?",
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex w-full gap-2.5 overflow-x-auto pb-1 sm:grid sm:grid-cols-2 sm:overflow-visible"
|
className="grid w-full gap-2 sm:grid-cols-2"
|
||||||
data-testid="suggested-actions"
|
data-testid="suggested-actions"
|
||||||
style={{
|
|
||||||
scrollbarWidth: "none",
|
|
||||||
WebkitOverflowScrolling: "touch",
|
|
||||||
msOverflowStyle: "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{suggestedActions.map((suggestedAction, index) => (
|
{suggestedActions.map((suggestedAction, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="min-w-[200px] shrink-0 sm:min-w-0 sm:shrink"
|
exit={{ opacity: 0, y: 20 }}
|
||||||
exit={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
initial={{ opacity: 0, y: 16 }}
|
|
||||||
key={suggestedAction}
|
key={suggestedAction}
|
||||||
transition={{
|
transition={{ delay: 0.05 * index }}
|
||||||
delay: 0.06 * index,
|
|
||||||
duration: 0.4,
|
|
||||||
ease: [0.22, 1, 0.36, 1],
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Suggestion
|
<Suggestion
|
||||||
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)]"
|
className="h-auto w-full whitespace-normal p-3 text-left"
|
||||||
onClick={(suggestion) => {
|
onClick={(suggestion) => {
|
||||||
window.history.pushState(
|
window.history.pushState(
|
||||||
{},
|
{},
|
||||||
77
components/suggestion.tsx
Normal file
77
components/suggestion.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -3,9 +3,8 @@
|
||||||
import { exampleSetup } from "prosemirror-example-setup";
|
import { exampleSetup } from "prosemirror-example-setup";
|
||||||
import { inputRules } from "prosemirror-inputrules";
|
import { inputRules } from "prosemirror-inputrules";
|
||||||
import { EditorState } from "prosemirror-state";
|
import { EditorState } from "prosemirror-state";
|
||||||
import { type Decoration, DecorationSet, EditorView } from "prosemirror-view";
|
import { EditorView } from "prosemirror-view";
|
||||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
import { memo, useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
import type { Suggestion } from "@/lib/db/schema";
|
import type { Suggestion } from "@/lib/db/schema";
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,9 +21,7 @@ import {
|
||||||
projectWithPositions,
|
projectWithPositions,
|
||||||
suggestionsPlugin,
|
suggestionsPlugin,
|
||||||
suggestionsPluginKey,
|
suggestionsPluginKey,
|
||||||
type UISuggestion,
|
|
||||||
} from "@/lib/editor/suggestions";
|
} from "@/lib/editor/suggestions";
|
||||||
import { SuggestionDialog } from "./suggestion";
|
|
||||||
|
|
||||||
type EditorProps = {
|
type EditorProps = {
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -33,9 +30,6 @@ type EditorProps = {
|
||||||
isCurrentVersion: boolean;
|
isCurrentVersion: boolean;
|
||||||
currentVersionIndex: number;
|
currentVersionIndex: number;
|
||||||
suggestions: Suggestion[];
|
suggestions: Suggestion[];
|
||||||
onSuggestionSelect?: (suggestion: UISuggestion | null) => void;
|
|
||||||
onSuggestionApply?: () => void;
|
|
||||||
activeSuggestion?: UISuggestion | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function PureEditor({
|
function PureEditor({
|
||||||
|
|
@ -46,10 +40,6 @@ function PureEditor({
|
||||||
}: EditorProps) {
|
}: EditorProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const editorRef = useRef<EditorView | null>(null);
|
const editorRef = useRef<EditorView | null>(null);
|
||||||
const [activeSuggestion, setActiveSuggestion] = useState<UISuggestion | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const suggestionsRef = useRef<UISuggestion[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (containerRef.current && !editorRef.current) {
|
if (containerRef.current && !editorRef.current) {
|
||||||
|
|
@ -73,21 +63,6 @@ function PureEditor({
|
||||||
|
|
||||||
editorRef.current = new EditorView(containerRef.current, {
|
editorRef.current = new EditorView(containerRef.current, {
|
||||||
state,
|
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,6 +72,8 @@ function PureEditor({
|
||||||
editorRef.current = null;
|
editorRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// NOTE: we only want to run this effect once
|
||||||
|
// eslint-disable-next-line
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -157,8 +134,6 @@ function PureEditor({
|
||||||
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
|
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
|
||||||
);
|
);
|
||||||
|
|
||||||
suggestionsRef.current = projectedSuggestions;
|
|
||||||
|
|
||||||
const decorations = createDecorations(
|
const decorations = createDecorations(
|
||||||
projectedSuggestions,
|
projectedSuggestions,
|
||||||
editorRef.current
|
editorRef.current
|
||||||
|
|
@ -170,61 +145,8 @@ function PureEditor({
|
||||||
}
|
}
|
||||||
}, [suggestions, content]);
|
}, [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 (
|
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);
|
setMultiLine(lines > 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
update();
|
update(); // initial check
|
||||||
const ro = new ResizeObserver(update);
|
const ro = new ResizeObserver(update); // re-check on width changes
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
|
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
|
|
@ -45,7 +45,7 @@ function Toast(props: ToastProps) {
|
||||||
<div className="flex toast-mobile:w-[356px] w-full justify-center">
|
<div className="flex toast-mobile:w-[356px] w-full justify-center">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-zinc-100 p-3",
|
||||||
multiLine ? "items-start" : "items-center"
|
multiLine ? "items-start" : "items-center"
|
||||||
)}
|
)}
|
||||||
data-testid="toast"
|
data-testid="toast"
|
||||||
|
|
@ -60,7 +60,7 @@ function Toast(props: ToastProps) {
|
||||||
>
|
>
|
||||||
{iconsByType[type]}
|
{iconsByType[type]}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-foreground" ref={descriptionRef}>
|
<div className="text-sm text-zinc-950" ref={descriptionRef}>
|
||||||
{description}
|
{description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
import {
|
||||||
import { WrenchIcon, XIcon } from "lucide-react";
|
AnimatePresence,
|
||||||
|
motion,
|
||||||
|
useMotionValue,
|
||||||
|
useTransform,
|
||||||
|
} from "framer-motion";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
|
|
@ -241,18 +245,24 @@ const ReadingLevelSelector = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tools = ({
|
export const Tools = ({
|
||||||
|
isToolbarVisible,
|
||||||
selectedTool,
|
selectedTool,
|
||||||
setSelectedTool,
|
setSelectedTool,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
isAnimating,
|
isAnimating,
|
||||||
|
setIsToolbarVisible,
|
||||||
tools,
|
tools,
|
||||||
}: {
|
}: {
|
||||||
|
isToolbarVisible: boolean;
|
||||||
selectedTool: string | null;
|
selectedTool: string | null;
|
||||||
setSelectedTool: Dispatch<SetStateAction<string | null>>;
|
setSelectedTool: Dispatch<SetStateAction<string | null>>;
|
||||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
|
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
|
||||||
tools: ArtifactToolbarItem[];
|
tools: ArtifactToolbarItem[];
|
||||||
}) => {
|
}) => {
|
||||||
|
const [primaryTool, ...secondaryTools] = tools;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
|
@ -260,53 +270,45 @@ export const Tools = ({
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
>
|
>
|
||||||
{[...tools].reverse().map((tool) => (
|
<AnimatePresence>
|
||||||
<Tool
|
{isToolbarVisible &&
|
||||||
description={tool.description}
|
secondaryTools.map((secondaryTool) => (
|
||||||
icon={tool.icon}
|
<Tool
|
||||||
isAnimating={isAnimating}
|
description={secondaryTool.description}
|
||||||
key={tool.description}
|
icon={secondaryTool.icon}
|
||||||
onClick={tool.onClick}
|
isAnimating={isAnimating}
|
||||||
selectedTool={selectedTool}
|
key={secondaryTool.description}
|
||||||
sendMessage={sendMessage}
|
onClick={secondaryTool.onClick}
|
||||||
setSelectedTool={setSelectedTool}
|
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}
|
||||||
|
/>
|
||||||
</motion.div>
|
</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 = ({
|
const PureToolbar = ({
|
||||||
isToolbarVisible: _isToolbarVisible,
|
isToolbarVisible,
|
||||||
setIsToolbarVisible,
|
setIsToolbarVisible,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
status,
|
status,
|
||||||
stop,
|
stop,
|
||||||
setMessages,
|
setMessages,
|
||||||
artifactKind,
|
artifactKind,
|
||||||
consoleError,
|
|
||||||
documentId,
|
|
||||||
artifactActions,
|
|
||||||
onClose,
|
|
||||||
}: {
|
}: {
|
||||||
isToolbarVisible: boolean;
|
isToolbarVisible: boolean;
|
||||||
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
|
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
|
||||||
|
|
@ -315,10 +317,6 @@ const PureToolbar = ({
|
||||||
stop: UseChatHelpers<ChatMessage>["stop"];
|
stop: UseChatHelpers<ChatMessage>["stop"];
|
||||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||||
artifactKind: ArtifactKind;
|
artifactKind: ArtifactKind;
|
||||||
consoleError?: string;
|
|
||||||
documentId?: string;
|
|
||||||
artifactActions?: ReactNode;
|
|
||||||
onClose?: () => void;
|
|
||||||
}) => {
|
}) => {
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
@ -370,12 +368,7 @@ const PureToolbar = ({
|
||||||
throw new Error("Artifact definition not found!");
|
throw new Error("Artifact definition not found!");
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolsByArtifactKind = consoleError
|
const toolsByArtifactKind = artifactDefinition.toolbar;
|
||||||
? [
|
|
||||||
createFixErrorTool(consoleError, documentId),
|
|
||||||
...artifactDefinition.toolbar.slice(1),
|
|
||||||
]
|
|
||||||
: artifactDefinition.toolbar;
|
|
||||||
|
|
||||||
if (toolsByArtifactKind.length === 0) {
|
if (toolsByArtifactKind.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -384,8 +377,26 @@ const PureToolbar = ({
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={
|
||||||
className="fixed right-6 bottom-6 z-50 flex cursor-pointer flex-col items-center rounded-3xl border bg-background py-1 shadow-lg"
|
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"
|
||||||
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
|
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
|
||||||
initial={{ opacity: 0, y: -20, scale: 1 }}
|
initial={{ opacity: 0, y: -20, scale: 1 }}
|
||||||
onAnimationComplete={() => {
|
onAnimationComplete={() => {
|
||||||
|
|
@ -412,17 +423,6 @@ const PureToolbar = ({
|
||||||
ref={toolbarRef}
|
ref={toolbarRef}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
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" ? (
|
{status === "streaming" ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ scale: 1.4 }}
|
animate={{ scale: 1.4 }}
|
||||||
|
|
@ -445,17 +445,16 @@ const PureToolbar = ({
|
||||||
setSelectedTool={setSelectedTool}
|
setSelectedTool={setSelectedTool}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Tools
|
||||||
{artifactActions}
|
isAnimating={isAnimating}
|
||||||
<Tools
|
isToolbarVisible={isToolbarVisible}
|
||||||
isAnimating={isAnimating}
|
key="tools"
|
||||||
key="tools"
|
selectedTool={selectedTool}
|
||||||
selectedTool={selectedTool}
|
sendMessage={sendMessage}
|
||||||
sendMessage={sendMessage}
|
setIsToolbarVisible={setIsToolbarVisible}
|
||||||
setSelectedTool={setSelectedTool}
|
setSelectedTool={setSelectedTool}
|
||||||
tools={toolsByArtifactKind}
|
tools={toolsByArtifactKind}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
@ -472,15 +471,6 @@ export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
|
||||||
if (prevProps.artifactKind !== nextProps.artifactKind) {
|
if (prevProps.artifactKind !== nextProps.artifactKind) {
|
||||||
return false;
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -36,7 +36,7 @@ function AlertDialogOverlay({
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -58,7 +58,7 @@ function AlertDialogContent({
|
||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-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",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -99,22 +99,6 @@ 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({
|
function AlertDialogTitle({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
|
|
@ -123,7 +107,7 @@ function AlertDialogTitle({
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
data-slot="alert-dialog-title"
|
data-slot="alert-dialog-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -138,8 +122,21 @@ function AlertDialogDescription({
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description
|
||||||
data-slot="alert-dialog-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(
|
className={cn(
|
||||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,19 @@ import { Slot } from "radix-ui"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"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!",
|
"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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"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",
|
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-input/30 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
ghost:
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,14 @@ import { cn } from "@/lib/utils"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
const buttonGroupVariants = cva(
|
const buttonGroupVariants = cva(
|
||||||
"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",
|
"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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
orientation: {
|
orientation: {
|
||||||
horizontal:
|
horizontal:
|
||||||
"[&>*: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!",
|
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||||
vertical:
|
vertical:
|
||||||
"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!",
|
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|
@ -49,7 +49,7 @@ function ButtonGroupText({
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -67,7 +67,7 @@ function ButtonGroupSeparator({
|
||||||
data-slot="button-group-separator"
|
data-slot="button-group-separator"
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -5,29 +5,28 @@ import { Slot } from "radix-ui"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"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",
|
"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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
outline:
|
|
||||||
"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 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
||||||
destructive:
|
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",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
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",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
"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 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
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 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
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: "size-9",
|
||||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "size-8",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,11 +12,6 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
} from "@/components/ui/input-group"
|
|
||||||
import { SearchIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Command({
|
function Command({
|
||||||
className,
|
className,
|
||||||
|
|
@ -25,7 +21,7 @@ function Command({
|
||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
data-slot="command"
|
data-slot="command"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-full flex-col overflow-hidden rounded-4xl bg-popover p-1 text-popover-foreground",
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -38,7 +34,7 @@ function CommandDialog({
|
||||||
description = "Search for a command to run...",
|
description = "Search for a command to run...",
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
showCloseButton = false,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -53,13 +49,12 @@ function CommandDialog({
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn(
|
className={cn("overflow-hidden p-0", className)}
|
||||||
"top-1/3 translate-y-0 overflow-hidden rounded-4xl! p-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
showCloseButton={showCloseButton}
|
showCloseButton={showCloseButton}
|
||||||
>
|
>
|
||||||
{children}
|
<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>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|
@ -70,20 +65,19 @@ function CommandInput({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
return (
|
return (
|
||||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
<div
|
||||||
<InputGroup className="h-9 bg-input/30">
|
data-slot="command-input-wrapper"
|
||||||
<CommandPrimitive.Input
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
data-slot="command-input"
|
>
|
||||||
className={cn(
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
<CommandPrimitive.Input
|
||||||
className
|
data-slot="command-input"
|
||||||
)}
|
className={cn(
|
||||||
{...props}
|
"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
|
||||||
<InputGroupAddon>
|
)}
|
||||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
{...props}
|
||||||
</InputGroupAddon>
|
/>
|
||||||
</InputGroup>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +90,7 @@ function CommandList({
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -105,13 +99,12 @@ function CommandList({
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandEmpty({
|
function CommandEmpty({
|
||||||
className,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Empty
|
<CommandPrimitive.Empty
|
||||||
data-slot="command-empty"
|
data-slot="command-empty"
|
||||||
className={cn("py-6 text-center text-sm", className)}
|
className="py-6 text-center text-sm"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -125,7 +118,7 @@ function CommandGroup({
|
||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
data-slot="command-group"
|
data-slot="command-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -140,7 +133,7 @@ function CommandSeparator({
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Separator
|
<CommandPrimitive.Separator
|
||||||
data-slot="command-separator"
|
data-slot="command-separator"
|
||||||
className={cn("my-1 h-px bg-border/50", className)}
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -148,20 +141,17 @@ function CommandSeparator({
|
||||||
|
|
||||||
function CommandItem({
|
function CommandItem({
|
||||||
className,
|
className,
|
||||||
children,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
data-slot="command-item"
|
data-slot="command-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-lg px-3 py-2 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-2xl data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
/>
|
||||||
{children}
|
|
||||||
</CommandPrimitive.Item>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,7 +163,7 @@ function CommandShortcut({
|
||||||
<span
|
<span
|
||||||
data-slot="command-shortcut"
|
data-slot="command-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
|
|
@ -39,7 +39,7 @@ function DialogOverlay({
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -56,28 +56,24 @@ function DialogContent({
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
<DialogPrimitive.Close
|
||||||
<Button
|
data-slot="dialog-close"
|
||||||
variant="ghost"
|
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"
|
||||||
className="absolute top-4 right-4"
|
>
|
||||||
size="icon-sm"
|
<XIcon />
|
||||||
>
|
<span className="sr-only">Close</span>
|
||||||
<XIcon
|
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
|
|
@ -89,7 +85,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
className={cn("flex flex-col gap-2", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -129,7 +125,7 @@ function DialogTitle({
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn("text-base leading-none font-medium", className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -142,10 +138,7 @@ function DialogDescription({
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn(
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
|
|
@ -33,7 +33,6 @@ function DropdownMenuTrigger({
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
className,
|
className,
|
||||||
align = "start",
|
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
|
@ -42,8 +41,10 @@ function DropdownMenuContent({
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
align={align}
|
className={cn(
|
||||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
|
@ -73,7 +74,7 @@ function DropdownMenuItem({
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-lg px-3 py-2 text-sm outline-hidden select-none transition-colors duration-150 focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:focus:bg-destructive/10 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -85,29 +86,21 @@ function DropdownMenuCheckboxItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
checked,
|
checked,
|
||||||
inset,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
|
||||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
|
||||||
>
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CheckIcon
|
<CheckIcon className="size-4" />
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -129,28 +122,20 @@ function DropdownMenuRadioGroup({
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
inset,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
|
||||||
data-slot="dropdown-menu-radio-item-indicator"
|
|
||||||
>
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CheckIcon
|
<CircleIcon className="size-2 fill-current" />
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -170,7 +155,7 @@ function DropdownMenuLabel({
|
||||||
data-slot="dropdown-menu-label"
|
data-slot="dropdown-menu-label"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5",
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -185,7 +170,7 @@ function DropdownMenuSeparator({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-separator"
|
data-slot="dropdown-menu-separator"
|
||||||
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -199,7 +184,7 @@ function DropdownMenuShortcut({
|
||||||
<span
|
<span
|
||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -226,13 +211,13 @@ function DropdownMenuSubTrigger({
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center gap-2 rounded-xl px-3 py-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto" />
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +229,10 @@ function DropdownMenuSubContent({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn("z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ function HoverCardContent({
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 w-72 origin-(--radix-hover-card-content-transform-origin) rounded-2xl bg-popover p-4 text-sm text-popover-foreground shadow-2xl ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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