Refactor to use hooks (#436)
This commit is contained in:
parent
124efca9a1
commit
cb60f8b143
139 changed files with 8871 additions and 8726 deletions
22
.env.example
22
.env.example
|
|
@ -1,12 +1,16 @@
|
|||
# You must first activate a Billing Account here: https://platform.openai.com/account/billing/overview
|
||||
# Then get your OpenAI API Key here: https://platform.openai.com/account/api-keys
|
||||
OPENAI_API_KEY=XXXXXXXX
|
||||
# Get your OpenAI API Key here: https://platform.openai.com/account/api-keys
|
||||
OPENAI_API_KEY=****
|
||||
|
||||
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
|
||||
AUTH_SECRET=XXXXXXXX
|
||||
AUTH_SECRET=****
|
||||
|
||||
# Instructions to create kv database here: https://vercel.com/docs/storage/vercel-kv/quickstart and
|
||||
KV_URL=XXXXXXXX
|
||||
KV_REST_API_URL=XXXXXXXX
|
||||
KV_REST_API_TOKEN=XXXXXXXX
|
||||
KV_REST_API_READ_ONLY_TOKEN=XXXXXXXX
|
||||
/*
|
||||
* The following keys below are automatically created and
|
||||
* added to your environment when you deploy on vercel
|
||||
*/
|
||||
|
||||
# Instructions to create kv database here: https://vercel.com/docs/storage/vercel-blob
|
||||
BLOB_READ_WRITE_TOKEN=****
|
||||
|
||||
# Instructions to create a database here: https://vercel.com/docs/storage/vercel-postgres/quickstart
|
||||
POSTGRES_URL=****
|
||||
|
|
|
|||
41
.eslintrc.json
Normal file
41
.eslintrc.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"prettier",
|
||||
"plugin:tailwindcss/recommended"
|
||||
],
|
||||
"plugins": ["import", "tailwindcss"],
|
||||
"rules": {
|
||||
"tailwindcss/no-custom-classname": "off",
|
||||
"tailwindcss/classnames-order": "off",
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
["parent", "sibling"],
|
||||
"index",
|
||||
"object",
|
||||
"type"
|
||||
],
|
||||
"newlines-between": "always",
|
||||
"alphabetize": {
|
||||
"order": "asc",
|
||||
"caseInsensitive": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"typescript": {
|
||||
"alwaysTryTypes": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ignorePatterns": ["**/components/ui/**"]
|
||||
}
|
||||
44
README.md
44
README.md
|
|
@ -1,49 +1,47 @@
|
|||
<a href="https://chat.vercel.ai/">
|
||||
<img alt="Next.js 14 and App Router-ready AI chatbot." src="https://chat.vercel.ai/opengraph-image.png">
|
||||
<img alt="Next.js 14 and App Router-ready AI chatbot." src="app/(chat)/opengraph-image.png">
|
||||
<h1 align="center">Next.js AI Chatbot</h1>
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
An open-source AI chatbot app template built with Next.js, the Vercel AI SDK, OpenAI, and Vercel KV.
|
||||
An Open-Source AI Chatbot Template Built With Next.js and the AI SDK by Vercel.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#features"><strong>Features</strong></a> ·
|
||||
<a href="#model-providers"><strong>Model Providers</strong></a> ·
|
||||
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
|
||||
<a href="#running-locally"><strong>Running locally</strong></a> ·
|
||||
<a href="#authors"><strong>Authors</strong></a>
|
||||
<a href="#running-locally"><strong>Running locally</strong></a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
## Features
|
||||
|
||||
- [Next.js](https://nextjs.org) App Router
|
||||
- React Server Components (RSCs), Suspense, and Server Actions
|
||||
- [Vercel AI SDK](https://sdk.vercel.ai/docs) for streaming chat UI
|
||||
- Support for OpenAI (default), Anthropic, Cohere, Hugging Face, or custom AI chat models and/or LangChain
|
||||
- Advanced routing for seamless navigation and performance
|
||||
- React Server Components (RSCs) and Server Actions for server-side rendering and increased performance
|
||||
- [AI SDK](https://sdk.vercel.ai/docs)
|
||||
- Unified API for generating text, structured objects, and tool calls with LLMs
|
||||
- Hooks for building dynamic chat and generative user interfaces
|
||||
- Supports OpenAI (default), Anthropic, Cohere, and other model providers
|
||||
- [shadcn/ui](https://ui.shadcn.com)
|
||||
- Styling with [Tailwind CSS](https://tailwindcss.com)
|
||||
- [Radix UI](https://radix-ui.com) for headless component primitives
|
||||
- Icons from [Phosphor Icons](https://phosphoricons.com)
|
||||
- Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv)
|
||||
- [NextAuth.js](https://github.com/nextauthjs/next-auth) for authentication
|
||||
- Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility
|
||||
- Data Persistence
|
||||
- [Vercel Postgres powered by Neon](https://vercel.com/storage/postgres) for saving chat history and user data
|
||||
- [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage
|
||||
- [NextAuth.js](https://github.com/nextauthjs/next-auth)
|
||||
- Simple and secure authentication
|
||||
|
||||
## Model Providers
|
||||
|
||||
This template ships with OpenAI `gpt-3.5-turbo` as the default. However, thanks to the [Vercel AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), [Hugging Face](https://huggingface.co), or using [LangChain](https://js.langchain.com) with just a few lines of code.
|
||||
This template ships with OpenAI `gpt-4o` as the default. However, with the [AI SDK](https://sdk.vercel.ai/docs), you can switch LLM providers to [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [many more](https://sdk.vercel.ai/providers/ai-sdk-providers) with just a few lines of code.
|
||||
|
||||
## Deploy Your Own
|
||||
|
||||
You can deploy your own version of the Next.js AI Chatbot to Vercel with one click:
|
||||
|
||||
[](https://vercel.com/new/clone?demo-title=Next.js+Chat&demo-description=A+full-featured%2C+hackable+Next.js+AI+chatbot+built+by+Vercel+Labs&demo-url=https%3A%2F%2Fchat.vercel.ai%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F4aVPvWuTmBvzM5cEdRdqeW%2F4234f9baf160f68ffb385a43c3527645%2FCleanShot_2023-06-16_at_17.09.21.png&project-name=Next.js+Chat&repository-name=nextjs-chat&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot&from=templates&skippable-integrations=1&env=OPENAI_API_KEY%2CAUTH_SECRET&envDescription=How+to+get+these+env+vars&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&teamCreateStatus=hidden&stores=[{"type":"kv"}])
|
||||
|
||||
## Creating a KV Database Instance
|
||||
|
||||
Follow the steps outlined in the [quick start guide](https://vercel.com/docs/storage/vercel-kv/quickstart#create-a-kv-database) provided by Vercel. This guide will assist you in creating and configuring your KV database instance on Vercel, enabling your application to interact with it.
|
||||
|
||||
Remember to update your environment variables (`KV_URL`, `KV_REST_API_URL`, `KV_REST_API_TOKEN`, `KV_REST_API_READ_ONLY_TOKEN`) in the `.env` file with the appropriate credentials provided during the KV database setup.
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot&env=AUTH_SECRET,OPENAI_API_KEY&envDescription=Learn%20more%20about%20how%20to%20get%20the%20API%20Keys%20for%20the%20application&envLink=https%3A%2F%2Fgithub.com%2Fvercel%2Fai-chatbot%2Fblob%2Fmain%2F.env.example&demo-title=AI%20Chatbot&demo-description=An%20Open-Source%20AI%20Chatbot%20Template%20Built%20With%20Next.js%20and%20the%20AI%20SDK%20by%20Vercel.&demo-url=https%3A%2F%2Fchat.vercel.ai&stores=[{%22type%22:%22postgres%22},{%22type%22:%22blob%22}])
|
||||
|
||||
## Running locally
|
||||
|
||||
|
|
@ -61,11 +59,3 @@ pnpm dev
|
|||
```
|
||||
|
||||
Your app template should now be running on [localhost:3000](http://localhost:3000/).
|
||||
|
||||
## Authors
|
||||
|
||||
This library is created by [Vercel](https://vercel.com) and [Next.js](https://nextjs.org) team members, with contributions from:
|
||||
|
||||
- Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) - [Vercel](https://vercel.com)
|
||||
- Shu Ding ([@shuding\_](https://twitter.com/shuding_)) - [Vercel](https://vercel.com)
|
||||
- shadcn ([@shadcn](https://twitter.com/shadcn)) - [Vercel](https://vercel.com)
|
||||
|
|
|
|||
3
ai/custom-middleware.ts
Normal file
3
ai/custom-middleware.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { Experimental_LanguageModelV1Middleware } from "ai";
|
||||
|
||||
export const customMiddleware: Experimental_LanguageModelV1Middleware = {};
|
||||
8
ai/index.ts
Normal file
8
ai/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { openai } from "@ai-sdk/openai";
|
||||
import { experimental_wrapLanguageModel as wrapLanguageModel } from "ai";
|
||||
import { customMiddleware } from "./custom-middleware";
|
||||
|
||||
export const customModel = wrapLanguageModel({
|
||||
model: openai("gpt-4o"),
|
||||
middleware: customMiddleware,
|
||||
});
|
||||
85
app/(auth)/actions.ts
Normal file
85
app/(auth)/actions.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { createUser, getUser } from "@/db/queries";
|
||||
|
||||
import { signIn } from "./auth";
|
||||
|
||||
const authFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
export interface LoginActionState {
|
||||
status: "idle" | "in_progress" | "success" | "failed" | "invalid_data";
|
||||
}
|
||||
|
||||
export const login = async (
|
||||
_: LoginActionState,
|
||||
formData: FormData,
|
||||
): Promise<LoginActionState> => {
|
||||
try {
|
||||
const validatedData = authFormSchema.parse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
await signIn("credentials", {
|
||||
email: validatedData.email,
|
||||
password: validatedData.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { status: "invalid_data" };
|
||||
}
|
||||
|
||||
return { status: "failed" };
|
||||
}
|
||||
};
|
||||
|
||||
export interface RegisterActionState {
|
||||
status:
|
||||
| "idle"
|
||||
| "in_progress"
|
||||
| "success"
|
||||
| "failed"
|
||||
| "user_exists"
|
||||
| "invalid_data";
|
||||
}
|
||||
|
||||
export const register = async (
|
||||
_: RegisterActionState,
|
||||
formData: FormData,
|
||||
): Promise<RegisterActionState> => {
|
||||
try {
|
||||
const validatedData = authFormSchema.parse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
let [user] = await getUser(validatedData.email);
|
||||
|
||||
if (user) {
|
||||
return { status: "user_exists" } as RegisterActionState;
|
||||
} else {
|
||||
await createUser(validatedData.email, validatedData.password);
|
||||
await signIn("credentials", {
|
||||
email: validatedData.email,
|
||||
password: validatedData.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
return { status: "success" };
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { status: "invalid_data" };
|
||||
}
|
||||
|
||||
return { status: "failed" };
|
||||
}
|
||||
};
|
||||
1
app/(auth)/api/auth/[...nextauth]/route.ts
Normal file
1
app/(auth)/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { GET, POST } from "@/app/(auth)/auth";
|
||||
39
app/(auth)/auth.config.ts
Normal file
39
app/(auth)/auth.config.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextAuthConfig } from "next-auth";
|
||||
|
||||
export const authConfig = {
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
newUser: "/",
|
||||
},
|
||||
providers: [
|
||||
// added later in auth.ts since it requires bcrypt which is only compatible with Node.js
|
||||
// while this file is also used in non-Node.js environments
|
||||
],
|
||||
callbacks: {
|
||||
authorized({ auth, request: { nextUrl } }) {
|
||||
let isLoggedIn = !!auth?.user;
|
||||
let isOnChat = nextUrl.pathname.startsWith("/");
|
||||
let isOnRegister = nextUrl.pathname.startsWith("/register");
|
||||
let isOnLogin = nextUrl.pathname.startsWith("/login");
|
||||
|
||||
if (isLoggedIn && (isOnLogin || isOnRegister)) {
|
||||
return Response.redirect(new URL("/", nextUrl));
|
||||
}
|
||||
|
||||
if (isOnRegister || isOnLogin) {
|
||||
return true; // Always allow access to register and login pages
|
||||
}
|
||||
|
||||
if (isOnChat) {
|
||||
if (isLoggedIn) return true;
|
||||
return false; // Redirect unauthenticated users to login page
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
return Response.redirect(new URL("/", nextUrl));
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
} satisfies NextAuthConfig;
|
||||
53
app/(auth)/auth.ts
Normal file
53
app/(auth)/auth.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { compare } from "bcrypt-ts";
|
||||
import NextAuth, { User, Session } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
import { getUser } from "@/db/queries";
|
||||
|
||||
import { authConfig } from "./auth.config";
|
||||
|
||||
interface ExtendedSession extends Session {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
signIn,
|
||||
signOut,
|
||||
} = NextAuth({
|
||||
...authConfig,
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {},
|
||||
async authorize({ email, password }: any) {
|
||||
let users = await getUser(email);
|
||||
if (users.length === 0) return null;
|
||||
let passwordsMatch = await compare(password, users[0].password!);
|
||||
if (passwordsMatch) return users[0] as any;
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({
|
||||
session,
|
||||
token,
|
||||
}: {
|
||||
session: ExtendedSession;
|
||||
token: any;
|
||||
}) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
65
app/(auth)/login/page.tsx
Normal file
65
app/(auth)/login/page.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AuthForm } from "@/components/custom/auth-form";
|
||||
import { SubmitButton } from "@/components/custom/submit-button";
|
||||
|
||||
import { login, LoginActionState } from "../actions";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
const [state, formAction] = useActionState<LoginActionState, FormData>(
|
||||
login,
|
||||
{
|
||||
status: "idle",
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === "failed") {
|
||||
toast.error("Invalid credentials!");
|
||||
} else if (state.status === "invalid_data") {
|
||||
toast.error("Failed validating your submission!");
|
||||
} else if (state.status === "success") {
|
||||
router.refresh();
|
||||
}
|
||||
}, [state.status, router]);
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
setEmail(formData.get("email") as string);
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-2xl flex flex-col gap-12">
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
|
||||
<h3 className="text-xl font-semibold dark:text-zinc-50">Sign In</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400">
|
||||
Use your email and password to sign in
|
||||
</p>
|
||||
</div>
|
||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||
<SubmitButton>Sign in</SubmitButton>
|
||||
<p className="text-center text-sm text-gray-600 mt-4 dark:text-zinc-400">
|
||||
{"Don't have an account? "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
{" for free."}
|
||||
</p>
|
||||
</AuthForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
app/(auth)/register/page.tsx
Normal file
67
app/(auth)/register/page.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AuthForm } from "@/components/custom/auth-form";
|
||||
import { SubmitButton } from "@/components/custom/submit-button";
|
||||
|
||||
import { register, RegisterActionState } from "../actions";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [state, formAction] = useActionState<RegisterActionState, FormData>(
|
||||
register,
|
||||
{
|
||||
status: "idle",
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === "user_exists") {
|
||||
toast.error("Account already exists");
|
||||
} else if (state.status === "failed") {
|
||||
toast.error("Failed to create account");
|
||||
} else if (state.status === "invalid_data") {
|
||||
toast.error("Failed validating your submission!");
|
||||
} else if (state.status === "success") {
|
||||
toast.success("Account created successfully");
|
||||
router.refresh();
|
||||
}
|
||||
}, [state, router]);
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
setEmail(formData.get("email") as string);
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-2xl gap-12 flex flex-col">
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
|
||||
<h3 className="text-xl font-semibold dark:text-zinc-50">Sign Up</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400">
|
||||
Create an account with your email and password
|
||||
</p>
|
||||
</div>
|
||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||
<SubmitButton>Sign Up</SubmitButton>
|
||||
<p className="text-center text-sm text-gray-600 mt-4 dark:text-zinc-400">
|
||||
{"Already have an account? "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
{" instead."}
|
||||
</p>
|
||||
</AuthForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
app/(chat)/api/chat/route.ts
Normal file
94
app/(chat)/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { convertToCoreMessages, Message, streamText } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import { customModel } from "@/ai";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { deleteChatById, getChatById, saveChat } from "@/db/queries";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { id, messages }: { id: string; messages: Array<Message> } =
|
||||
await request.json();
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const coreMessages = convertToCoreMessages(messages);
|
||||
|
||||
const result = await streamText({
|
||||
model: customModel,
|
||||
system:
|
||||
"you are a friendly assistant! keep your responses concise and helpful.",
|
||||
messages: coreMessages,
|
||||
maxSteps: 5,
|
||||
tools: {
|
||||
getWeather: {
|
||||
description: "Get the current weather at a location",
|
||||
parameters: z.object({
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
}),
|
||||
execute: async ({ latitude, longitude }) => {
|
||||
const response = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto`,
|
||||
);
|
||||
|
||||
const weatherData = await response.json();
|
||||
return weatherData;
|
||||
},
|
||||
},
|
||||
},
|
||||
onFinish: async ({ responseMessages }) => {
|
||||
if (session.user && session.user.id) {
|
||||
try {
|
||||
await saveChat({
|
||||
id,
|
||||
messages: [...coreMessages, ...responseMessages],
|
||||
userId: session.user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save chat");
|
||||
}
|
||||
}
|
||||
},
|
||||
experimental_telemetry: {
|
||||
isEnabled: true,
|
||||
functionId: "stream-text",
|
||||
},
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse({});
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const chat = await getChatById({ id });
|
||||
|
||||
if (chat.userId !== session.user.id) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
await deleteChatById({ id });
|
||||
|
||||
return new Response("Chat deleted", { status: 200 });
|
||||
} catch (error) {
|
||||
return new Response("An error occurred while processing your request", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
69
app/(chat)/api/files/upload/route.ts
Normal file
69
app/(chat)/api/files/upload/route.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { put } from "@vercel/blob";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
const FileSchema = z.object({
|
||||
file: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.size <= 5 * 1024 * 1024, {
|
||||
message: "File size should be less than 5MB",
|
||||
})
|
||||
.refine(
|
||||
(file) =>
|
||||
["image/jpeg", "image/png", "application/pdf"].includes(file.type),
|
||||
{
|
||||
message: "File type should be JPEG, PNG, or PDF",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (request.body === null) {
|
||||
return new Response("Request body is empty", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validatedFile = FileSchema.safeParse({ file });
|
||||
|
||||
if (!validatedFile.success) {
|
||||
const errorMessage = validatedFile.error.errors
|
||||
.map((error) => error.message)
|
||||
.join(", ");
|
||||
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
const filename = file.name;
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
try {
|
||||
const data = await put(`${filename}`, fileBuffer, {
|
||||
access: "public",
|
||||
});
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to process request" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
13
app/(chat)/api/history/route.ts
Normal file
13
app/(chat)/api/history/route.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatsByUserId } from "@/db/queries";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user) {
|
||||
return Response.json("Unauthorized!", { status: 401 });
|
||||
}
|
||||
|
||||
const chats = await getChatsByUserId({ id: session.user.id! });
|
||||
return Response.json(chats);
|
||||
}
|
||||
|
|
@ -1,65 +1,108 @@
|
|||
import { type Metadata } from 'next'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { CoreMessage, CoreToolMessage, Message, ToolInvocation } from "ai";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { getChat, getMissingKeys } from '@/app/actions'
|
||||
import { Chat } from '@/components/chat'
|
||||
import { AI } from '@/lib/chat/actions'
|
||||
import { Session } from '@/lib/types'
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { Chat as PreviewChat } from "@/components/custom/chat";
|
||||
import { getChatById } from "@/db/queries";
|
||||
import { Chat } from "@/db/schema";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export interface ChatPageProps {
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
function addToolMessageToChat({
|
||||
toolMessage,
|
||||
messages,
|
||||
}: {
|
||||
toolMessage: CoreToolMessage;
|
||||
messages: Array<Message>;
|
||||
}): Array<Message> {
|
||||
return messages.map((message) => {
|
||||
if (message.toolInvocations) {
|
||||
return {
|
||||
...message,
|
||||
toolInvocations: message.toolInvocations.map((toolInvocation) => {
|
||||
const toolResult = toolMessage.content.find(
|
||||
(tool) => tool.toolCallId === toolInvocation.toolCallId,
|
||||
);
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: ChatPageProps): Promise<Metadata> {
|
||||
const session = await auth()
|
||||
if (toolResult) {
|
||||
return {
|
||||
...toolInvocation,
|
||||
state: "result",
|
||||
result: toolResult.result,
|
||||
};
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const chat = await getChat(params.id, session.user.id)
|
||||
|
||||
if (!chat || 'error' in chat) {
|
||||
redirect('/')
|
||||
} else {
|
||||
return {
|
||||
title: chat?.title.toString().slice(0, 50) ?? 'Chat'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ChatPage({ params }: ChatPageProps) {
|
||||
const session = (await auth()) as Session
|
||||
const missingKeys = await getMissingKeys()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect(`/login?next=/chat/${params.id}`)
|
||||
}
|
||||
|
||||
const userId = session.user.id as string
|
||||
const chat = await getChat(params.id, userId)
|
||||
|
||||
if (!chat || 'error' in chat) {
|
||||
redirect('/')
|
||||
} else {
|
||||
if (chat?.userId !== session?.user?.id) {
|
||||
notFound()
|
||||
return toolInvocation;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<AI initialAIState={{ chatId: chat.id, messages: chat.messages }}>
|
||||
<Chat
|
||||
id={chat.id}
|
||||
session={session}
|
||||
initialMessages={chat.messages}
|
||||
missingKeys={missingKeys}
|
||||
/>
|
||||
</AI>
|
||||
)
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
function convertToUIMessages(messages: Array<CoreMessage>): Array<Message> {
|
||||
return messages.reduce((chatMessages: Array<Message>, message) => {
|
||||
if (message.role === "tool") {
|
||||
return addToolMessageToChat({
|
||||
toolMessage: message as CoreToolMessage,
|
||||
messages: chatMessages,
|
||||
});
|
||||
}
|
||||
|
||||
let textContent = "";
|
||||
let toolInvocations: Array<ToolInvocation> = [];
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
textContent = message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text") {
|
||||
textContent += content.text;
|
||||
} else if (content.type === "tool-call") {
|
||||
toolInvocations.push({
|
||||
state: "call",
|
||||
toolCallId: content.toolCallId,
|
||||
toolName: content.toolName,
|
||||
args: content.args,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatMessages.push({
|
||||
id: generateUUID(),
|
||||
role: message.role,
|
||||
content: textContent,
|
||||
toolInvocations,
|
||||
});
|
||||
|
||||
return chatMessages;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: any }) {
|
||||
const { id } = params;
|
||||
const chatFromDb = await getChatById({ id });
|
||||
|
||||
if (!chatFromDb) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// type casting
|
||||
const chat: Chat = {
|
||||
...chatFromDb,
|
||||
messages: convertToUIMessages(chatFromDb.messages as Array<CoreMessage>),
|
||||
};
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (session.user.id !== chat.userId) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <PreviewChat id={chat.id} initialMessages={chat.messages} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { SidebarDesktop } from '@/components/sidebar-desktop'
|
||||
|
||||
interface ChatLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default async function ChatLayout({ children }: ChatLayoutProps) {
|
||||
return (
|
||||
<div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
|
||||
<SidebarDesktop />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
app/(chat)/opengraph-image.png
Normal file
BIN
app/(chat)/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
|
|
@ -1,22 +1,7 @@
|
|||
import { nanoid } from '@/lib/utils'
|
||||
import { Chat } from '@/components/chat'
|
||||
import { AI } from '@/lib/chat/actions'
|
||||
import { auth } from '@/auth'
|
||||
import { Session } from '@/lib/types'
|
||||
import { getMissingKeys } from '@/app/actions'
|
||||
import { Chat } from "@/components/custom/chat";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export const metadata = {
|
||||
title: 'Next.js AI Chatbot'
|
||||
}
|
||||
|
||||
export default async function IndexPage() {
|
||||
const id = nanoid()
|
||||
const session = (await auth()) as Session
|
||||
const missingKeys = await getMissingKeys()
|
||||
|
||||
return (
|
||||
<AI initialAIState={{ chatId: id, messages: [] }}>
|
||||
<Chat id={id} session={session} missingKeys={missingKeys} />
|
||||
</AI>
|
||||
)
|
||||
export default async function Page() {
|
||||
const id = generateUUID();
|
||||
return <Chat key={id} id={id} initialMessages={[]} />;
|
||||
}
|
||||
|
|
|
|||
BIN
app/(chat)/twitter-image.png
Normal file
BIN
app/(chat)/twitter-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
172
app/actions.ts
172
app/actions.ts
|
|
@ -1,172 +0,0 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { kv } from '@vercel/kv'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { type Chat } from '@/lib/types'
|
||||
|
||||
export async function getChats(userId?: string | null) {
|
||||
const session = await auth()
|
||||
|
||||
if (!userId) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (userId !== session?.user?.id) {
|
||||
return {
|
||||
error: 'Unauthorized'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const pipeline = kv.pipeline()
|
||||
const chats: string[] = await kv.zrange(`user:chat:${userId}`, 0, -1, {
|
||||
rev: true
|
||||
})
|
||||
|
||||
for (const chat of chats) {
|
||||
pipeline.hgetall(chat)
|
||||
}
|
||||
|
||||
const results = await pipeline.exec()
|
||||
|
||||
return results as Chat[]
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChat(id: string, userId: string) {
|
||||
const session = await auth()
|
||||
|
||||
if (userId !== session?.user?.id) {
|
||||
return {
|
||||
error: 'Unauthorized'
|
||||
}
|
||||
}
|
||||
|
||||
const chat = await kv.hgetall<Chat>(`chat:${id}`)
|
||||
|
||||
if (!chat || (userId && chat.userId !== userId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return chat
|
||||
}
|
||||
|
||||
export async function removeChat({ id, path }: { id: string; path: string }) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
error: 'Unauthorized'
|
||||
}
|
||||
}
|
||||
|
||||
// Convert uid to string for consistent comparison with session.user.id
|
||||
const uid = String(await kv.hget(`chat:${id}`, 'userId'))
|
||||
|
||||
if (uid !== session?.user?.id) {
|
||||
return {
|
||||
error: 'Unauthorized'
|
||||
}
|
||||
}
|
||||
|
||||
await kv.del(`chat:${id}`)
|
||||
await kv.zrem(`user:chat:${session.user.id}`, `chat:${id}`)
|
||||
|
||||
revalidatePath('/')
|
||||
return revalidatePath(path)
|
||||
}
|
||||
|
||||
export async function clearChats() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
error: 'Unauthorized'
|
||||
}
|
||||
}
|
||||
|
||||
const chats: string[] = await kv.zrange(`user:chat:${session.user.id}`, 0, -1)
|
||||
if (!chats.length) {
|
||||
return redirect('/')
|
||||
}
|
||||
const pipeline = kv.pipeline()
|
||||
|
||||
for (const chat of chats) {
|
||||
pipeline.del(chat)
|
||||
pipeline.zrem(`user:chat:${session.user.id}`, chat)
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
|
||||
revalidatePath('/')
|
||||
return redirect('/')
|
||||
}
|
||||
|
||||
export async function getSharedChat(id: string) {
|
||||
const chat = await kv.hgetall<Chat>(`chat:${id}`)
|
||||
|
||||
if (!chat || !chat.sharePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
return chat
|
||||
}
|
||||
|
||||
export async function shareChat(id: string) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
error: 'Unauthorized'
|
||||
}
|
||||
}
|
||||
|
||||
const chat = await kv.hgetall<Chat>(`chat:${id}`)
|
||||
|
||||
if (!chat || chat.userId !== session.user.id) {
|
||||
return {
|
||||
error: 'Something went wrong'
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...chat,
|
||||
sharePath: `/share/${chat.id}`
|
||||
}
|
||||
|
||||
await kv.hmset(`chat:${chat.id}`, payload)
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export async function saveChat(chat: Chat) {
|
||||
const session = await auth()
|
||||
|
||||
if (session && session.user) {
|
||||
const pipeline = kv.pipeline()
|
||||
pipeline.hmset(`chat:${chat.id}`, chat)
|
||||
pipeline.zadd(`user:chat:${chat.userId}`, {
|
||||
score: Date.now(),
|
||||
member: `chat:${chat.id}`
|
||||
})
|
||||
await pipeline.exec()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshHistory(path: string) {
|
||||
redirect(path)
|
||||
}
|
||||
|
||||
export async function getMissingKeys() {
|
||||
const keysRequired = ['OPENAI_API_KEY']
|
||||
return keysRequired
|
||||
.map(key => (process.env[key] ? '' : key))
|
||||
.filter(key => key !== '')
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
174
app/globals.css
174
app/globals.css
|
|
@ -1,76 +1,110 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "geist";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
src: url(/fonts/geist.woff2) format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "geist-mono";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
src: url(/fonts/geist-mono.woff2) format("woff2");
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
*[class^="text-"] {
|
||||
color: transparent;
|
||||
@apply rounded-md bg-foreground/20 select-none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,64 +1,36 @@
|
|||
import { GeistSans } from 'geist/font/sans'
|
||||
import { GeistMono } from 'geist/font/mono'
|
||||
import { Metadata } from "next";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import '@/app/globals.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TailwindIndicator } from '@/components/tailwind-indicator'
|
||||
import { Providers } from '@/components/providers'
|
||||
import { Header } from '@/components/header'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { Navbar } from "@/components/custom/navbar";
|
||||
import { ThemeProvider } from "@/components/custom/theme-provider";
|
||||
|
||||
export const metadata = {
|
||||
metadataBase: process.env.VERCEL_URL
|
||||
? new URL(`https://${process.env.VERCEL_URL}`)
|
||||
: undefined,
|
||||
title: {
|
||||
default: 'Next.js AI Chatbot',
|
||||
template: `%s - Next.js AI Chatbot`
|
||||
},
|
||||
description: 'An AI-powered chatbot template built with Next.js and Vercel.',
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
shortcut: '/favicon-16x16.png',
|
||||
apple: '/apple-touch-icon.png'
|
||||
}
|
||||
}
|
||||
import "./globals.css";
|
||||
|
||||
export const viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: 'white' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: 'black' }
|
||||
]
|
||||
}
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://chat.vercel.ai"),
|
||||
title: "Next.js Chatbot Template",
|
||||
description: "Next.js chatbot template using the AI SDK.",
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
'font-sans antialiased',
|
||||
GeistSans.variable,
|
||||
GeistMono.variable
|
||||
)}
|
||||
>
|
||||
<Toaster position="top-center" />
|
||||
<Providers
|
||||
<html lang="en">
|
||||
<body className="antialiased">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Header />
|
||||
<main className="flex flex-col flex-1 bg-muted/50">{children}</main>
|
||||
</div>
|
||||
<TailwindIndicator />
|
||||
</Providers>
|
||||
<Toaster position="top-center" />
|
||||
<Navbar />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
'use server'
|
||||
|
||||
import { signIn } from '@/auth'
|
||||
import { User } from '@/lib/types'
|
||||
import { AuthError } from 'next-auth'
|
||||
import { z } from 'zod'
|
||||
import { kv } from '@vercel/kv'
|
||||
import { ResultCode } from '@/lib/utils'
|
||||
|
||||
export async function getUser(email: string) {
|
||||
const user = await kv.hgetall<User>(`user:${email}`)
|
||||
return user
|
||||
}
|
||||
|
||||
interface Result {
|
||||
type: string
|
||||
resultCode: ResultCode
|
||||
}
|
||||
|
||||
export async function authenticate(
|
||||
_prevState: Result | undefined,
|
||||
formData: FormData
|
||||
): Promise<Result | undefined> {
|
||||
try {
|
||||
const email = formData.get('email')
|
||||
const password = formData.get('password')
|
||||
|
||||
const parsedCredentials = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6)
|
||||
})
|
||||
.safeParse({
|
||||
email,
|
||||
password
|
||||
})
|
||||
|
||||
if (parsedCredentials.success) {
|
||||
await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
resultCode: ResultCode.UserLoggedIn
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'error',
|
||||
resultCode: ResultCode.InvalidCredentials
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
switch (error.type) {
|
||||
case 'CredentialsSignin':
|
||||
return {
|
||||
type: 'error',
|
||||
resultCode: ResultCode.InvalidCredentials
|
||||
}
|
||||
default:
|
||||
return {
|
||||
type: 'error',
|
||||
resultCode: ResultCode.UnknownError
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { auth } from '@/auth'
|
||||
import LoginForm from '@/components/login-form'
|
||||
import { Session } from '@/lib/types'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function LoginPage() {
|
||||
const session = (await auth()) as Session
|
||||
|
||||
if (session) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col p-4">
|
||||
<LoginForm />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function NewPage() {
|
||||
redirect('/')
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 424 KiB |
|
|
@ -1,58 +0,0 @@
|
|||
import { type Metadata } from 'next'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getSharedChat } from '@/app/actions'
|
||||
import { ChatList } from '@/components/chat-list'
|
||||
import { FooterText } from '@/components/footer'
|
||||
import { AI, UIState, getUIStateFromAIState } from '@/lib/chat/actions'
|
||||
|
||||
export const runtime = 'edge'
|
||||
export const preferredRegion = 'home'
|
||||
|
||||
interface SharePageProps {
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: SharePageProps): Promise<Metadata> {
|
||||
const chat = await getSharedChat(params.id)
|
||||
|
||||
return {
|
||||
title: chat?.title.slice(0, 50) ?? 'Chat'
|
||||
}
|
||||
}
|
||||
|
||||
export default async function SharePage({ params }: SharePageProps) {
|
||||
const chat = await getSharedChat(params.id)
|
||||
|
||||
if (!chat || !chat?.sharePath) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const uiState: UIState = getUIStateFromAIState(chat)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="border-b bg-background px-4 py-6 md:px-6 md:py-8">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="space-y-1 md:-mx-8">
|
||||
<h1 className="text-2xl font-bold">{chat.title}</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(chat.createdAt)} · {chat.messages.length} messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AI>
|
||||
<ChatList messages={uiState} isShared={true} />
|
||||
</AI>
|
||||
</div>
|
||||
<FooterText className="py-8" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
'use server'
|
||||
|
||||
import { signIn } from '@/auth'
|
||||
import { ResultCode, getStringFromBuffer } from '@/lib/utils'
|
||||
import { z } from 'zod'
|
||||
import { kv } from '@vercel/kv'
|
||||
import { getUser } from '../login/actions'
|
||||
import { AuthError } from 'next-auth'
|
||||
|
||||
export async function createUser(
|
||||
email: string,
|
||||
hashedPassword: string,
|
||||
salt: string
|
||||
) {
|
||||
const existingUser = await getUser(email)
|
||||
|
||||
if (existingUser) {
|
||||
return {
|
||||
type: 'error',
|
||||
resultCode: ResultCode.UserAlreadyExists
|
||||
}
|
||||
} else {
|
||||
const user = {
|
||||
id: crypto.randomUUID(),
|
||||
email,
|
||||
password: hashedPassword,
|
||||
salt
|
||||
}
|
||||
|
||||
await kv.hmset(`user:${email}`, user)
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
resultCode: ResultCode.UserCreated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Result {
|
||||
type: string
|
||||
resultCode: ResultCode
|
||||
}
|
||||
|
||||
export async function signup(
|
||||
_prevState: Result | undefined,
|
||||
formData: FormData
|
||||
): Promise<Result | undefined> {
|
||||
const email = formData.get('email') as string
|
||||
const password = formData.get('password') as string
|
||||
|
||||
const parsedCredentials = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6)
|
||||
})
|
||||
.safeParse({
|
||||
email,
|
||||
password
|
||||
})
|
||||
|
||||
if (parsedCredentials.success) {
|
||||
const salt = crypto.randomUUID()
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const saltedPassword = encoder.encode(password + salt)
|
||||
const hashedPasswordBuffer = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
saltedPassword
|
||||
)
|
||||
const hashedPassword = getStringFromBuffer(hashedPasswordBuffer)
|
||||
|
||||
try {
|
||||
const result = await createUser(email, hashedPassword, salt)
|
||||
|
||||
if (result.resultCode === ResultCode.UserCreated) {
|
||||
await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
switch (error.type) {
|
||||
case 'CredentialsSignin':
|
||||
return {
|
||||
type: 'error',
|
||||
resultCode: ResultCode.InvalidCredentials
|
||||
}
|
||||
default:
|
||||
return {
|
||||
type: 'error',
|
||||
resultCode: ResultCode.UnknownError
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'error',
|
||||
resultCode: ResultCode.UnknownError
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'error',
|
||||
resultCode: ResultCode.InvalidCredentials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { auth } from '@/auth'
|
||||
import SignupForm from '@/components/signup-form'
|
||||
import { Session } from '@/lib/types'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function SignupPage() {
|
||||
const session = (await auth()) as Session
|
||||
|
||||
if (session) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col p-4">
|
||||
<SignupForm />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 424 KiB |
|
|
@ -1,42 +0,0 @@
|
|||
import type { NextAuthConfig } from 'next-auth'
|
||||
|
||||
export const authConfig = {
|
||||
secret: process.env.AUTH_SECRET,
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
newUser: '/signup'
|
||||
},
|
||||
callbacks: {
|
||||
async authorized({ auth, request: { nextUrl } }) {
|
||||
const isLoggedIn = !!auth?.user
|
||||
const isOnLoginPage = nextUrl.pathname.startsWith('/login')
|
||||
const isOnSignupPage = nextUrl.pathname.startsWith('/signup')
|
||||
|
||||
if (isLoggedIn) {
|
||||
if (isOnLoginPage || isOnSignupPage) {
|
||||
return Response.redirect(new URL('/', nextUrl))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token = { ...token, id: user.id }
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token) {
|
||||
const { id } = token as { id: string }
|
||||
const { user } = session
|
||||
|
||||
session = { ...session, user: { ...user, id } }
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
},
|
||||
providers: []
|
||||
} satisfies NextAuthConfig
|
||||
45
auth.ts
45
auth.ts
|
|
@ -1,45 +0,0 @@
|
|||
import NextAuth from 'next-auth'
|
||||
import Credentials from 'next-auth/providers/credentials'
|
||||
import { authConfig } from './auth.config'
|
||||
import { z } from 'zod'
|
||||
import { getStringFromBuffer } from './lib/utils'
|
||||
import { getUser } from './app/login/actions'
|
||||
|
||||
export const { auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
providers: [
|
||||
Credentials({
|
||||
async authorize(credentials) {
|
||||
const parsedCredentials = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6)
|
||||
})
|
||||
.safeParse(credentials)
|
||||
|
||||
if (parsedCredentials.success) {
|
||||
const { email, password } = parsedCredentials.data
|
||||
const user = await getUser(email)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const saltedPassword = encoder.encode(password + user.salt)
|
||||
const hashedPasswordBuffer = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
saltedPassword
|
||||
)
|
||||
const hashedPassword = getStringFromBuffer(hashedPasswordBuffer)
|
||||
|
||||
if (hashedPassword === user.password) {
|
||||
return user
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
|
@ -12,6 +12,9 @@
|
|||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||
import { IconArrowDown } from '@/components/ui/icons'
|
||||
|
||||
interface ButtonScrollToBottomProps extends ButtonProps {
|
||||
isAtBottom: boolean
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export function ButtonScrollToBottom({
|
||||
className,
|
||||
isAtBottom,
|
||||
scrollToBottom,
|
||||
...props
|
||||
}: ButtonScrollToBottomProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'absolute right-4 top-1 z-10 bg-background transition-opacity duration-300 sm:right-8 md:top-2',
|
||||
isAtBottom ? 'opacity-0' : 'opacity-100',
|
||||
className
|
||||
)}
|
||||
onClick={() => scrollToBottom()}
|
||||
{...props}
|
||||
>
|
||||
<IconArrowDown />
|
||||
<span className="sr-only">Scroll to bottom</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SidebarList } from '@/components/sidebar-list'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { IconPlus } from '@/components/ui/icons'
|
||||
|
||||
interface ChatHistoryProps {
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export async function ChatHistory({ userId }: ChatHistoryProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h4 className="text-sm font-medium">Chat History</h4>
|
||||
</div>
|
||||
<div className="mb-2 px-2">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-10 w-full justify-start bg-zinc-50 px-4 shadow-none transition-colors hover:bg-zinc-200/40 dark:bg-zinc-900 dark:hover:bg-zinc-300/10'
|
||||
)}
|
||||
>
|
||||
<IconPlus className="-translate-x-2 stroke-2" />
|
||||
New Chat
|
||||
</Link>
|
||||
</div>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col flex-1 px-4 space-y-4 overflow-auto">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-full h-6 rounded-md shrink-0 animate-pulse bg-zinc-200 dark:bg-zinc-800"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<SidebarList userId={userId} />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { Separator } from '@/components/ui/separator'
|
||||
import { UIState } from '@/lib/chat/actions'
|
||||
import { Session } from '@/lib/types'
|
||||
import Link from 'next/link'
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons'
|
||||
|
||||
export interface ChatList {
|
||||
messages: UIState
|
||||
session?: Session
|
||||
isShared: boolean
|
||||
}
|
||||
|
||||
export function ChatList({ messages, session, isShared }: ChatList) {
|
||||
if (!messages.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto max-w-2xl px-4">
|
||||
{!isShared && !session ? (
|
||||
<>
|
||||
<div className="group relative mb-4 flex items-start md:-ml-12">
|
||||
<div className="bg-background flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border shadow-sm">
|
||||
<ExclamationTriangleIcon />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
<p className="text-muted-foreground leading-normal">
|
||||
Please{' '}
|
||||
<Link href="/login" className="underline">
|
||||
log in
|
||||
</Link>{' '}
|
||||
or{' '}
|
||||
<Link href="/signup" className="underline">
|
||||
sign up
|
||||
</Link>{' '}
|
||||
to save and revisit your chat history!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<div key={message.id}>
|
||||
{message.display}
|
||||
{index < messages.length - 1 && <Separator className="my-4" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { type Message } from 'ai'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IconCheck, IconCopy } from '@/components/ui/icons'
|
||||
import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChatMessageActionsProps extends React.ComponentProps<'div'> {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export function ChatMessageActions({
|
||||
message,
|
||||
className,
|
||||
...props
|
||||
}: ChatMessageActionsProps) {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
|
||||
|
||||
const onCopy = () => {
|
||||
if (isCopied) return
|
||||
copyToClipboard(message.content)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end transition-opacity group-hover:opacity-100 md:absolute md:-right-10 md:-top-2 md:opacity-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={onCopy}>
|
||||
{isCopied ? <IconCheck /> : <IconCopy />}
|
||||
<span className="sr-only">Copy message</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
// Inspired by Chatbot-UI and modified to fit the needs of this project
|
||||
// @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Chat/ChatMessage.tsx
|
||||
|
||||
import { Message } from 'ai'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeBlock } from '@/components/ui/codeblock'
|
||||
import { MemoizedReactMarkdown } from '@/components/markdown'
|
||||
import { IconOpenAI, IconUser } from '@/components/ui/icons'
|
||||
import { ChatMessageActions } from '@/components/chat-message-actions'
|
||||
|
||||
export interface ChatMessageProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, ...props }: ChatMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('group relative mb-4 flex items-start md:-ml-12')}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow',
|
||||
message.role === 'user'
|
||||
? 'bg-background'
|
||||
: 'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{message.role === 'user' ? <IconUser /> : <IconOpenAI />}
|
||||
</div>
|
||||
<div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden">
|
||||
<MemoizedReactMarkdown
|
||||
className="prose break-words dark:prose-invert prose-p:leading-relaxed prose-pre:p-0"
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={{
|
||||
p({ children }) {
|
||||
return <p className="mb-2 last:mb-0">{children}</p>
|
||||
},
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
if (children.length) {
|
||||
if (children[0] == '▍') {
|
||||
return (
|
||||
<span className="mt-1 cursor-default animate-pulse">▍</span>
|
||||
)
|
||||
}
|
||||
|
||||
children[0] = (children[0] as string).replace('`▍`', '▍')
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={(match && match[1]) || ''}
|
||||
value={String(children).replace(/\n$/, '')}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</MemoizedReactMarkdown>
|
||||
<ChatMessageActions message={message} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { shareChat } from '@/app/actions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PromptForm } from '@/components/prompt-form'
|
||||
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
|
||||
import { IconShare } from '@/components/ui/icons'
|
||||
import { FooterText } from '@/components/footer'
|
||||
import { ChatShareDialog } from '@/components/chat-share-dialog'
|
||||
import { useAIState, useActions, useUIState } from 'ai/rsc'
|
||||
import type { AI } from '@/lib/chat/actions'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { UserMessage } from './stocks/message'
|
||||
|
||||
export interface ChatPanelProps {
|
||||
id?: string
|
||||
title?: string
|
||||
input: string
|
||||
setInput: (value: string) => void
|
||||
isAtBottom: boolean
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
id,
|
||||
title,
|
||||
input,
|
||||
setInput,
|
||||
isAtBottom,
|
||||
scrollToBottom
|
||||
}: ChatPanelProps) {
|
||||
const [aiState] = useAIState()
|
||||
const [messages, setMessages] = useUIState<typeof AI>()
|
||||
const { submitUserMessage } = useActions()
|
||||
const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
|
||||
|
||||
const exampleMessages = [
|
||||
{
|
||||
heading: 'What are the',
|
||||
subheading: 'trending memecoins today?',
|
||||
message: `What are the trending memecoins today?`
|
||||
},
|
||||
{
|
||||
heading: 'What is the price of',
|
||||
subheading: '$DOGE right now?',
|
||||
message: 'What is the price of $DOGE right now?'
|
||||
},
|
||||
{
|
||||
heading: 'I would like to buy',
|
||||
subheading: '42 $DOGE',
|
||||
message: `I would like to buy 42 $DOGE`
|
||||
},
|
||||
{
|
||||
heading: 'What are some',
|
||||
subheading: `recent events about $DOGE?`,
|
||||
message: `What are some recent events about $DOGE?`
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% duration-300 ease-in-out animate-in dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
|
||||
<ButtonScrollToBottom
|
||||
isAtBottom={isAtBottom}
|
||||
scrollToBottom={scrollToBottom}
|
||||
/>
|
||||
|
||||
<div className="mx-auto sm:max-w-2xl sm:px-4">
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 px-4 sm:px-0">
|
||||
{messages.length === 0 &&
|
||||
exampleMessages.map((example, index) => (
|
||||
<div
|
||||
key={example.heading}
|
||||
className={`cursor-pointer rounded-lg border bg-white p-4 hover:bg-zinc-50 dark:bg-zinc-950 dark:hover:bg-zinc-900 ${
|
||||
index > 1 && 'hidden md:block'
|
||||
}`}
|
||||
onClick={async () => {
|
||||
setMessages(currentMessages => [
|
||||
...currentMessages,
|
||||
{
|
||||
id: nanoid(),
|
||||
display: <UserMessage>{example.message}</UserMessage>
|
||||
}
|
||||
])
|
||||
|
||||
const responseMessage = await submitUserMessage(
|
||||
example.message
|
||||
)
|
||||
|
||||
setMessages(currentMessages => [
|
||||
...currentMessages,
|
||||
responseMessage
|
||||
])
|
||||
}}
|
||||
>
|
||||
<div className="text-sm font-semibold">{example.heading}</div>
|
||||
<div className="text-sm text-zinc-600">
|
||||
{example.subheading}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{messages?.length >= 2 ? (
|
||||
<div className="flex h-12 items-center justify-center">
|
||||
<div className="flex space-x-2">
|
||||
{id && title ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShareDialogOpen(true)}
|
||||
>
|
||||
<IconShare className="mr-2" />
|
||||
Share
|
||||
</Button>
|
||||
<ChatShareDialog
|
||||
open={shareDialogOpen}
|
||||
onOpenChange={setShareDialogOpen}
|
||||
onCopy={() => setShareDialogOpen(false)}
|
||||
shareChat={shareChat}
|
||||
chat={{
|
||||
id,
|
||||
title,
|
||||
messages: aiState.messages
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4 border-t bg-background px-4 py-2 shadow-lg sm:rounded-t-xl sm:border md:py-4">
|
||||
<PromptForm input={input} setInput={setInput} />
|
||||
<FooterText className="hidden sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { type DialogProps } from '@radix-ui/react-dialog'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ServerActionResult, type Chat } from '@/lib/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { IconSpinner } from '@/components/ui/icons'
|
||||
import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
|
||||
|
||||
interface ChatShareDialogProps extends DialogProps {
|
||||
chat: Pick<Chat, 'id' | 'title' | 'messages'>
|
||||
shareChat: (id: string) => ServerActionResult<Chat>
|
||||
onCopy: () => void
|
||||
}
|
||||
|
||||
export function ChatShareDialog({
|
||||
chat,
|
||||
shareChat,
|
||||
onCopy,
|
||||
...props
|
||||
}: ChatShareDialogProps) {
|
||||
const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 })
|
||||
const [isSharePending, startShareTransition] = React.useTransition()
|
||||
|
||||
const copyShareLink = React.useCallback(
|
||||
async (chat: Chat) => {
|
||||
if (!chat.sharePath) {
|
||||
return toast.error('Could not copy share link to clipboard')
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
url.pathname = chat.sharePath
|
||||
copyToClipboard(url.toString())
|
||||
onCopy()
|
||||
toast.success('Share link copied to clipboard')
|
||||
},
|
||||
[copyToClipboard, onCopy]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share link to chat</DialogTitle>
|
||||
<DialogDescription>
|
||||
Anyone with the URL will be able to view the shared chat.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="p-4 space-y-1 text-sm border rounded-md">
|
||||
<div className="font-medium">{chat.title}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{chat.messages.length} messages
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="items-center">
|
||||
<Button
|
||||
disabled={isSharePending}
|
||||
onClick={() => {
|
||||
// @ts-ignore
|
||||
startShareTransition(async () => {
|
||||
const result = await shareChat(chat.id)
|
||||
|
||||
if (result && 'error' in result) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
copyShareLink(result)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isSharePending ? (
|
||||
<>
|
||||
<IconSpinner className="mr-2 animate-spin" />
|
||||
Copying...
|
||||
</>
|
||||
) : (
|
||||
<>Copy link</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChatList } from '@/components/chat-list'
|
||||
import { ChatPanel } from '@/components/chat-panel'
|
||||
import { EmptyScreen } from '@/components/empty-screen'
|
||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUIState, useAIState } from 'ai/rsc'
|
||||
import { Message, Session } from '@/lib/types'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useScrollAnchor } from '@/lib/hooks/use-scroll-anchor'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface ChatProps extends React.ComponentProps<'div'> {
|
||||
initialMessages?: Message[]
|
||||
id?: string
|
||||
session?: Session
|
||||
missingKeys: string[]
|
||||
}
|
||||
|
||||
export function Chat({ id, className, session, missingKeys }: ChatProps) {
|
||||
const router = useRouter()
|
||||
const path = usePathname()
|
||||
const [input, setInput] = useState('')
|
||||
const [messages] = useUIState()
|
||||
const [aiState] = useAIState()
|
||||
|
||||
const [_, setNewChatId] = useLocalStorage('newChatId', id)
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
if (!path.includes('chat') && messages.length === 1) {
|
||||
window.history.replaceState({}, '', `/chat/${id}`)
|
||||
}
|
||||
}
|
||||
}, [id, path, session?.user, messages])
|
||||
|
||||
useEffect(() => {
|
||||
const messagesLength = aiState.messages?.length
|
||||
if (messagesLength === 2) {
|
||||
router.refresh()
|
||||
}
|
||||
}, [aiState.messages, router])
|
||||
|
||||
useEffect(() => {
|
||||
setNewChatId(id)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
missingKeys.map(key => {
|
||||
toast.error(`Missing ${key} environment variable!`)
|
||||
})
|
||||
}, [missingKeys])
|
||||
|
||||
const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
|
||||
useScrollAnchor()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group w-full overflow-auto pl-0 peer-[[data-state=open]]:lg:pl-[250px] peer-[[data-state=open]]:xl:pl-[300px]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div
|
||||
className={cn('pb-[200px] pt-4 md:pt-10', className)}
|
||||
ref={messagesRef}
|
||||
>
|
||||
{messages.length ? (
|
||||
<ChatList messages={messages} isShared={false} session={session} />
|
||||
) : (
|
||||
<EmptyScreen />
|
||||
)}
|
||||
<div className="w-full h-px" ref={visibilityRef} />
|
||||
</div>
|
||||
<ChatPanel
|
||||
id={id}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
isAtBottom={isAtBottom}
|
||||
scrollToBottom={scrollToBottom}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ServerActionResult } from '@/lib/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { IconSpinner } from '@/components/ui/icons'
|
||||
|
||||
interface ClearHistoryProps {
|
||||
isEnabled: boolean
|
||||
clearChats: () => ServerActionResult<void>
|
||||
}
|
||||
|
||||
export function ClearHistory({
|
||||
isEnabled = false,
|
||||
clearChats
|
||||
}: ClearHistoryProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isPending, startTransition] = React.useTransition()
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" disabled={!isEnabled || isPending}>
|
||||
{isPending && <IconSpinner className="mr-2" />}
|
||||
Clear history
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your chat history and remove your data
|
||||
from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isPending}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
startTransition(async () => {
|
||||
const result = await clearChats()
|
||||
if (result && 'error' in result) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isPending && <IconSpinner className="mr-2 animate-spin" />}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
53
components/custom/auth-form.tsx
Normal file
53
components/custom/auth-form.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
export function AuthForm({
|
||||
action,
|
||||
children,
|
||||
defaultEmail = "",
|
||||
}: {
|
||||
action: any;
|
||||
children: React.ReactNode;
|
||||
defaultEmail?: string;
|
||||
}) {
|
||||
return (
|
||||
<form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className="text-zinc-600 font-normal dark:text-zinc-400"
|
||||
>
|
||||
Email Address
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
className="bg-muted text-md md:text-sm"
|
||||
type="email"
|
||||
placeholder="user@acme.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
defaultValue={defaultEmail}
|
||||
/>
|
||||
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-zinc-600 font-normal dark:text-zinc-400"
|
||||
>
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
className="bg-muted text-md md:text-sm"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
74
components/custom/chat.tsx
Normal file
74
components/custom/chat.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { Attachment, Message } from "ai";
|
||||
import { useChat } from "ai/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Message as PreviewMessage } from "@/components/custom/message";
|
||||
import { useScrollToBottom } from "@/components/custom/use-scroll-to-bottom";
|
||||
|
||||
import { MultimodalInput } from "./multimodal-input";
|
||||
import { Overview } from "./overview";
|
||||
|
||||
export function Chat({
|
||||
id,
|
||||
initialMessages,
|
||||
}: {
|
||||
id: string;
|
||||
initialMessages: Array<Message>;
|
||||
}) {
|
||||
const { messages, handleSubmit, input, setInput, append, isLoading, stop } =
|
||||
useChat({
|
||||
body: { id },
|
||||
initialMessages,
|
||||
onFinish: () => {
|
||||
window.history.replaceState({}, "", `/chat/${id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const [messagesContainerRef, messagesEndRef] =
|
||||
useScrollToBottom<HTMLDivElement>();
|
||||
|
||||
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center pb-4 md:pb-8 h-dvh bg-background">
|
||||
<div className="flex flex-col justify-between items-center gap-4">
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex flex-col gap-4 h-full w-dvw items-center overflow-y-scroll"
|
||||
>
|
||||
{messages.length === 0 && <Overview />}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<PreviewMessage
|
||||
key={`${id}-${index}`}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
attachments={message.experimental_attachments}
|
||||
toolInvocations={message.toolInvocations}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
ref={messagesEndRef}
|
||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-row gap-2 relative items-end w-full md:max-w-[500px] max-w-[calc(100dvw-32px) px-4 md:px-0">
|
||||
<MultimodalInput
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
attachments={attachments}
|
||||
setAttachments={setAttachments}
|
||||
messages={messages}
|
||||
append={append}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
components/custom/history.tsx
Normal file
241
components/custom/history.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"use client";
|
||||
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import cx from "classnames";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { User } from "next-auth";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Chat } from "@/db/schema";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
InfoIcon,
|
||||
MenuIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilEditIcon,
|
||||
TrashIcon,
|
||||
} from "./icons";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "../ui/sheet";
|
||||
|
||||
export const History = ({ user }: { user: User | undefined }) => {
|
||||
const { id } = useParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [isHistoryVisible, setIsHistoryVisible] = useState(false);
|
||||
const {
|
||||
data: history,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<Array<Chat>>(user ? "/api/history" : null, fetcher, {
|
||||
fallbackData: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mutate();
|
||||
}, [pathname, mutate]);
|
||||
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const deletePromise = fetch(`/api/chat?id=${deleteId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
toast.promise(deletePromise, {
|
||||
loading: "Deleting chat...",
|
||||
success: () => {
|
||||
mutate((history) => {
|
||||
if (history) {
|
||||
return history.filter((h) => h.id !== id);
|
||||
}
|
||||
});
|
||||
return "Chat deleted successfully";
|
||||
},
|
||||
error: "Failed to delete chat",
|
||||
});
|
||||
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="p-1.5 h-fit"
|
||||
onClick={() => {
|
||||
setIsHistoryVisible(true);
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</Button>
|
||||
|
||||
<Sheet
|
||||
open={isHistoryVisible}
|
||||
onOpenChange={(state) => {
|
||||
setIsHistoryVisible(state);
|
||||
}}
|
||||
>
|
||||
<SheetContent side="left" className="p-3 w-80 bg-muted">
|
||||
<SheetHeader>
|
||||
<VisuallyHidden.Root>
|
||||
<SheetTitle className="text-left">History</SheetTitle>
|
||||
<SheetDescription className="text-left">
|
||||
{history === undefined ? "loading" : history.length} chats
|
||||
</SheetDescription>
|
||||
</VisuallyHidden.Root>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="text-sm flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="dark:text-zinc-300">History</div>
|
||||
|
||||
<div className="dark:text-zinc-400 text-zinc-500">
|
||||
{history === undefined ? "loading" : history.length} chats
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col">
|
||||
{user && (
|
||||
<Button
|
||||
className="font-normal text-sm flex flex-row justify-between"
|
||||
asChild
|
||||
>
|
||||
<Link href="/">
|
||||
<div>Start a new chat</div>
|
||||
<PencilEditIcon size={14} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col overflow-y-scroll p-1 h-[calc(100dvh-124px)]">
|
||||
{!user ? (
|
||||
<div className="text-zinc-500 h-dvh w-full flex flex-row justify-center items-center text-sm gap-2">
|
||||
<InfoIcon />
|
||||
<div>Login to save and revisit previous chats!</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && history?.length === 0 && user ? (
|
||||
<div className="text-zinc-500 h-dvh w-full flex flex-row justify-center items-center text-sm gap-2">
|
||||
<InfoIcon />
|
||||
<div>No chats found</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading && user ? (
|
||||
<div className="flex flex-col">
|
||||
{[44, 32, 28, 52].map((item) => (
|
||||
<div key={item} className="p-2 my-[2px]">
|
||||
<div
|
||||
className={`w-${item} h-[20px] rounded-md bg-zinc-200 dark:bg-zinc-600 animate-pulse`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{history &&
|
||||
history.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={cx(
|
||||
"flex flex-row items-center gap-6 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-md pr-2",
|
||||
{ "bg-zinc-200 dark:bg-zinc-700": chat.id === id },
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cx(
|
||||
"hover:bg-zinc-200 dark:hover:bg-zinc-700 justify-between p-0 text-sm font-normal flex flex-row items-center gap-2 pr-2 w-full transition-none",
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/chat/${chat.id}`}
|
||||
className="text-ellipsis overflow-hidden text-left py-2 pl-2 rounded-lg outline-zinc-900"
|
||||
>
|
||||
{chat.messages[0].content as string}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu modal={true}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="p-0 h-fit font-normal text-zinc-500 transition-none hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="left" className="z-[60]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
className="flex flex-row gap-2 items-center justify-start w-full h-fit font-normal p-1.5 rounded-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDeleteId(chat.id);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
<div>Delete</div>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your
|
||||
chat and remove it from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
611
components/custom/icons.tsx
Normal file
611
components/custom/icons.tsx
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
export const BotIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
height="16"
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.75 2.79933C9.19835 2.53997 9.5 2.05521 9.5 1.5C9.5 0.671573 8.82843 0 8 0C7.17157 0 6.5 0.671573 6.5 1.5C6.5 2.05521 6.80165 2.53997 7.25 2.79933V5H7C4.027 5 1.55904 7.16229 1.08296 10H0V13H1V14.5V16H2.5H13.5H15V14.5V13H16V10H14.917C14.441 7.16229 11.973 5 9 5H8.75V2.79933ZM7 6.5C4.51472 6.5 2.5 8.51472 2.5 11V14.5H13.5V11C13.5 8.51472 11.4853 6.5 9 6.5H7ZM7.25 11.25C7.25 12.2165 6.4665 13 5.5 13C4.5335 13 3.75 12.2165 3.75 11.25C3.75 10.2835 4.5335 9.5 5.5 9.5C6.4665 9.5 7.25 10.2835 7.25 11.25ZM10.5 13C11.4665 13 12.25 12.2165 12.25 11.25C12.25 10.2835 11.4665 9.5 10.5 9.5C9.5335 9.5 8.75 10.2835 8.75 11.25C8.75 12.2165 9.5335 13 10.5 13Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="16"
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.75 0C5.95507 0 4.5 1.45507 4.5 3.25V3.75C4.5 5.54493 5.95507 7 7.75 7H8.25C10.0449 7 11.5 5.54493 11.5 3.75V3.25C11.5 1.45507 10.0449 0 8.25 0H7.75ZM6 3.25C6 2.2835 6.7835 1.5 7.75 1.5H8.25C9.2165 1.5 10 2.2835 10 3.25V3.75C10 4.7165 9.2165 5.5 8.25 5.5H7.75C6.7835 5.5 6 4.7165 6 3.75V3.25ZM2.5 14.5V13.1709C3.31958 11.5377 4.99308 10.5 6.82945 10.5H9.17055C11.0069 10.5 12.6804 11.5377 13.5 13.1709V14.5H2.5ZM6.82945 9C4.35483 9 2.10604 10.4388 1.06903 12.6857L1 12.8353V13V15.25V16H1.75H14.25H15V15.25V13V12.8353L14.931 12.6857C13.894 10.4388 11.6452 9 9.17055 9H6.82945Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
height="16"
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M14.5 6.5V13.5C14.5 14.8807 13.3807 16 12 16H4C2.61929 16 1.5 14.8807 1.5 13.5V1.5V0H3H8H9.08579C9.351 0 9.60536 0.105357 9.79289 0.292893L14.2071 4.70711C14.3946 4.89464 14.5 5.149 14.5 5.41421V6.5ZM13 6.5V13.5C13 14.0523 12.5523 14.5 12 14.5H4C3.44772 14.5 3 14.0523 3 13.5V1.5H8V5V6.5H9.5H13ZM9.5 2.12132V5H12.3787L9.5 2.12132Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const VercelIcon = ({ size = 17 }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 1L16 15H0L8 1Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const GitIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
height="16"
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<g clipPath="url(#clip0_872_3147)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 0C3.58 0 0 3.57879 0 7.99729C0 11.5361 2.29 14.5251 5.47 15.5847C5.87 15.6547 6.02 15.4148 6.02 15.2049C6.02 15.0149 6.01 14.3851 6.01 13.7154C4 14.0852 3.48 13.2255 3.32 12.7757C3.23 12.5458 2.84 11.836 2.5 11.6461C2.22 11.4961 1.82 11.1262 2.49 11.1162C3.12 11.1062 3.57 11.696 3.72 11.936C4.44 13.1455 5.59 12.8057 6.05 12.5957C6.12 12.0759 6.33 11.726 6.56 11.5261C4.78 11.3262 2.92 10.6364 2.92 7.57743C2.92 6.70773 3.23 5.98797 3.74 5.42816C3.66 5.22823 3.38 4.40851 3.82 3.30888C3.82 3.30888 4.49 3.09895 6.02 4.1286C6.66 3.94866 7.34 3.85869 8.02 3.85869C8.7 3.85869 9.38 3.94866 10.02 4.1286C11.55 3.08895 12.22 3.30888 12.22 3.30888C12.66 4.40851 12.38 5.22823 12.3 5.42816C12.81 5.98797 13.12 6.69773 13.12 7.57743C13.12 10.6464 11.25 11.3262 9.47 11.5261C9.76 11.776 10.01 12.2558 10.01 13.0056C10.01 14.0752 10 14.9349 10 15.2049C10 15.4148 10.15 15.6647 10.55 15.5847C12.1381 15.0488 13.5182 14.0284 14.4958 12.6673C15.4735 11.3062 15.9996 9.67293 16 7.99729C16 3.57879 12.42 0 8 0Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_872_3147">
|
||||
<rect width="16" height="16" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const BoxIcon = ({ size = 16 }: { size: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 0.154663L8.34601 0.334591L14.596 3.58459L15 3.79466V4.25V11.75V12.2053L14.596 12.4154L8.34601 15.6654L8 15.8453L7.65399 15.6654L1.40399 12.4154L1 12.2053V11.75V4.25V3.79466L1.40399 3.58459L7.65399 0.334591L8 0.154663ZM2.5 11.2947V5.44058L7.25 7.81559V13.7647L2.5 11.2947ZM8.75 13.7647L13.5 11.2947V5.44056L8.75 7.81556V13.7647ZM8 1.84534L12.5766 4.22519L7.99998 6.51352L3.42335 4.2252L8 1.84534Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const HomeIcon = ({ size = 16 }: { size: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.5 6.56062L8.00001 2.06062L3.50001 6.56062V13.5L6.00001 13.5V11C6.00001 9.89539 6.89544 8.99996 8.00001 8.99996C9.10458 8.99996 10 9.89539 10 11V13.5L12.5 13.5V6.56062ZM13.78 5.71933L8.70711 0.646409C8.31659 0.255886 7.68342 0.255883 7.2929 0.646409L2.21987 5.71944C2.21974 5.71957 2.21961 5.7197 2.21949 5.71982L0.469676 7.46963L-0.0606537 7.99996L1.00001 9.06062L1.53034 8.53029L2.00001 8.06062V14.25V15H2.75001L6.00001 15H7.50001H8.50001H10L13.25 15H14V14.25V8.06062L14.4697 8.53029L15 9.06062L16.0607 7.99996L15.5303 7.46963L13.7806 5.71993C13.7804 5.71973 13.7802 5.71953 13.78 5.71933ZM8.50001 11V13.5H7.50001V11C7.50001 10.7238 7.72386 10.5 8.00001 10.5C8.27615 10.5 8.50001 10.7238 8.50001 11Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const GPSIcon = ({ size = 16 }: { size: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
d="M1 6L15 1L10 15L7.65955 8.91482C7.55797 8.65073 7.34927 8.44203 7.08518 8.34045L1 6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="bevel"
|
||||
fill="transparent"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const InvoiceIcon = ({ size = 16 }: { size: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M13 15.1L12 14.5L10.1524 15.8857C10.0621 15.9534 9.93791 15.9534 9.8476 15.8857L8 14.5L6.14377 15.8922C6.05761 15.9568 5.94008 15.9601 5.85047 15.9003L3.75 14.5L3 15L2.83257 15.1116L1.83633 15.7758L1.68656 15.8756C1.60682 15.9288 1.5 15.8716 1.5 15.7758V15.5958V14.3985V14.1972V1.5V0H3H8H9.08579C9.351 0 9.60536 0.105357 9.79289 0.292893L14.2071 4.70711C14.3946 4.89464 14.5 5.149 14.5 5.41421V6.5V14.2507V14.411V15.5881V15.7881C14.5 15.8813 14.3982 15.9389 14.3183 15.891L14.1468 15.7881L13.1375 15.1825L13 15.1ZM12.3787 5L9.5 2.12132V5H12.3787ZM8 1.5V5V6.5H9.5H13V13.3507L12.7717 13.2138L11.9069 12.6948L11.1 13.3L10 14.125L8.9 13.3L8 12.625L7.1 13.3L5.94902 14.1632L4.58205 13.2519L3.75 12.6972L3 13.1972V1.5H8Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogoOpenAI = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
d="M14.9449 6.54871C15.3128 5.45919 15.1861 4.26567 14.5978 3.27464C13.7131 1.75461 11.9345 0.972595 10.1974 1.3406C9.42464 0.481584 8.3144 -0.00692594 7.15045 7.42132e-05C5.37487 -0.00392587 3.79946 1.1241 3.2532 2.79113C2.11256 3.02164 1.12799 3.72615 0.551837 4.72468C-0.339497 6.24071 -0.1363 8.15175 1.05451 9.45178C0.686626 10.5413 0.813308 11.7348 1.40162 12.7258C2.28637 14.2459 4.06498 15.0279 5.80204 14.6599C6.5743 15.5189 7.68504 16.0074 8.849 15.9999C10.6256 16.0044 12.2015 14.8754 12.7478 13.2069C13.8884 12.9764 14.873 12.2718 15.4491 11.2733C16.3394 9.75728 16.1357 7.84774 14.9454 6.54771L14.9449 6.54871ZM8.85001 14.9544C8.13907 14.9554 7.45043 14.7099 6.90468 14.2604C6.92951 14.2474 6.97259 14.2239 7.00046 14.2069L10.2293 12.3668C10.3945 12.2743 10.4959 12.1008 10.4949 11.9133V7.42173L11.8595 8.19925C11.8742 8.20625 11.8838 8.22025 11.8858 8.23625V11.9558C11.8838 13.6099 10.5263 14.9509 8.85001 14.9544ZM2.32133 12.2028C1.9651 11.5958 1.8369 10.8843 1.95902 10.1938C1.98284 10.2078 2.02489 10.2333 2.05479 10.2503L5.28366 12.0903C5.44733 12.1848 5.65003 12.1848 5.81421 12.0903L9.75604 9.84429V11.3993C9.75705 11.4153 9.74945 11.4308 9.73678 11.4408L6.47295 13.3004C5.01915 14.1264 3.1625 13.6354 2.32184 12.2028H2.32133ZM1.47155 5.24819C1.82626 4.64017 2.38619 4.17516 3.05305 3.93366C3.05305 3.96116 3.05152 4.00966 3.05152 4.04366V7.72424C3.05051 7.91124 3.15186 8.08475 3.31654 8.17725L7.25838 10.4228L5.89376 11.2003C5.88008 11.2093 5.86285 11.2108 5.84765 11.2043L2.58331 9.34327C1.13255 8.51426 0.63494 6.68272 1.47104 5.24869L1.47155 5.24819ZM12.6834 7.82274L8.74157 5.57669L10.1062 4.79968C10.1199 4.79068 10.1371 4.78918 10.1523 4.79568L13.4166 6.65522C14.8699 7.48373 15.3681 9.31827 14.5284 10.7523C14.1732 11.3593 13.6138 11.8243 12.9474 12.0663V8.27575C12.9489 8.08875 12.8481 7.91574 12.6839 7.82274H12.6834ZM14.0414 5.8057C14.0176 5.7912 13.9756 5.7662 13.9457 5.7492L10.7168 3.90916C10.5531 3.81466 10.3504 3.81466 10.1863 3.90916L6.24442 6.15521V4.60017C6.2434 4.58417 6.251 4.56867 6.26367 4.55867L9.52751 2.70063C10.9813 1.87311 12.84 2.36563 13.6781 3.80066C14.0323 4.40667 14.1605 5.11618 14.0404 5.8057H14.0414ZM5.50257 8.57726L4.13744 7.79974C4.12275 7.79274 4.11312 7.77874 4.11109 7.76274V4.04316C4.11211 2.38713 5.47368 1.0451 7.15197 1.0461C7.86189 1.0461 8.54902 1.2921 9.09476 1.74011C9.06993 1.75311 9.02737 1.77661 8.99899 1.79361L5.77012 3.63365C5.60493 3.72615 5.50358 3.89916 5.50459 4.08666L5.50257 8.57626V8.57726ZM6.24391 7.00022L7.99972 5.9997L9.75553 6.99972V9.00027L7.99972 10.0003L6.24391 9.00027V7.00022Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogoGoogle = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
d="M8.15991 6.54543V9.64362H12.4654C12.2763 10.64 11.709 11.4837 10.8581 12.0509L13.4544 14.0655C14.9671 12.6692 15.8399 10.6182 15.8399 8.18188C15.8399 7.61461 15.789 7.06911 15.6944 6.54552L8.15991 6.54543Z"
|
||||
fill="#4285F4"
|
||||
></path>
|
||||
<path
|
||||
d="M3.6764 9.52268L3.09083 9.97093L1.01807 11.5855C2.33443 14.1963 5.03241 16 8.15966 16C10.3196 16 12.1305 15.2873 13.4542 14.0655L10.8578 12.0509C10.1451 12.5309 9.23598 12.8219 8.15966 12.8219C6.07967 12.8219 4.31245 11.4182 3.67967 9.5273L3.6764 9.52268Z"
|
||||
fill="#34A853"
|
||||
></path>
|
||||
<path
|
||||
d="M1.01803 4.41455C0.472607 5.49087 0.159912 6.70543 0.159912 7.99995C0.159912 9.29447 0.472607 10.509 1.01803 11.5854C1.01803 11.5926 3.6799 9.51991 3.6799 9.51991C3.5199 9.03991 3.42532 8.53085 3.42532 7.99987C3.42532 7.46889 3.5199 6.95983 3.6799 6.47983L1.01803 4.41455Z"
|
||||
fill="#FBBC05"
|
||||
></path>
|
||||
<path
|
||||
d="M8.15982 3.18545C9.33802 3.18545 10.3853 3.59271 11.2216 4.37818L13.5125 2.0873C12.1234 0.792777 10.3199 0 8.15982 0C5.03257 0 2.33443 1.79636 1.01807 4.41455L3.67985 6.48001C4.31254 4.58908 6.07983 3.18545 8.15982 3.18545Z"
|
||||
fill="#EA4335"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogoAnthropic = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 92.2 65"
|
||||
style={{ color: "currentcolor", fill: "currentcolor" }}
|
||||
width="18px"
|
||||
height="18px"
|
||||
>
|
||||
<path
|
||||
d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
|
||||
M24.3,39.3l8.8-22.8l8.8,22.8H24.3z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const RouteIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.53033 0.719661L7 0.189331L5.93934 1.24999L6.46967 1.78032L6.68934 1.99999H3.375C1.51104 1.99999 0 3.51103 0 5.37499C0 7.23895 1.51104 8.74999 3.375 8.74999H12.625C13.6605 8.74999 14.5 9.58946 14.5 10.625C14.5 11.6605 13.6605 12.5 12.625 12.5H4.88555C4.56698 11.4857 3.61941 10.75 2.5 10.75C1.11929 10.75 0 11.8693 0 13.25C0 14.6307 1.11929 15.75 2.5 15.75C3.61941 15.75 4.56698 15.0143 4.88555 14H12.625C14.489 14 16 12.489 16 10.625C16 8.76103 14.489 7.24999 12.625 7.24999H3.375C2.33947 7.24999 1.5 6.41052 1.5 5.37499C1.5 4.33946 2.33947 3.49999 3.375 3.49999H6.68934L6.46967 3.71966L5.93934 4.24999L7 5.31065L7.53033 4.78032L8.85355 3.4571C9.24408 3.06657 9.24408 2.43341 8.85355 2.04288L7.53033 0.719661ZM2.5 14.25C3.05228 14.25 3.5 13.8023 3.5 13.25C3.5 12.6977 3.05228 12.25 2.5 12.25C1.94772 12.25 1.5 12.6977 1.5 13.25C1.5 13.8023 1.94772 14.25 2.5 14.25ZM14.5 2.74999C14.5 3.30228 14.0523 3.74999 13.5 3.74999C12.9477 3.74999 12.5 3.30228 12.5 2.74999C12.5 2.19771 12.9477 1.74999 13.5 1.74999C14.0523 1.74999 14.5 2.19771 14.5 2.74999ZM16 2.74999C16 4.1307 14.8807 5.24999 13.5 5.24999C12.1193 5.24999 11 4.1307 11 2.74999C11 1.36928 12.1193 0.249991 13.5 0.249991C14.8807 0.249991 16 1.36928 16 2.74999Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M14.5 13.5V6.5V5.41421C14.5 5.149 14.3946 4.89464 14.2071 4.70711L9.79289 0.292893C9.60536 0.105357 9.351 0 9.08579 0H8H3H1.5V1.5V13.5C1.5 14.8807 2.61929 16 4 16H12C13.3807 16 14.5 14.8807 14.5 13.5ZM13 13.5V6.5H9.5H8V5V1.5H3V13.5C3 14.0523 3.44772 14.5 4 14.5H12C12.5523 14.5 13 14.0523 13 13.5ZM9.5 5V2.12132L12.3787 5H9.5ZM5.13 5.00062H4.505V6.25062H5.13H6H6.625V5.00062H6H5.13ZM4.505 8H5.13H11H11.625V9.25H11H5.13H4.505V8ZM5.13 11H4.505V12.25H5.13H11H11.625V11H11H5.13Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoaderIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<g clipPath="url(#clip0_2393_1490)">
|
||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5"></path>
|
||||
<path
|
||||
opacity="0.5"
|
||||
d="M8 16V12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
></path>
|
||||
<path
|
||||
opacity="0.9"
|
||||
d="M3.29773 1.52783L5.64887 4.7639"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
></path>
|
||||
<path
|
||||
opacity="0.1"
|
||||
d="M12.7023 1.52783L10.3511 4.7639"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
></path>
|
||||
<path
|
||||
opacity="0.4"
|
||||
d="M12.7023 14.472L10.3511 11.236"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
></path>
|
||||
<path
|
||||
opacity="0.6"
|
||||
d="M3.29773 14.472L5.64887 11.236"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
></path>
|
||||
<path
|
||||
opacity="0.2"
|
||||
d="M15.6085 5.52783L11.8043 6.7639"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
></path>
|
||||
<path
|
||||
opacity="0.7"
|
||||
d="M0.391602 10.472L4.19583 9.23598"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
></path>
|
||||
<path
|
||||
opacity="0.3"
|
||||
d="M15.6085 10.4722L11.8043 9.2361"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
></path>
|
||||
<path
|
||||
opacity="0.8"
|
||||
d="M0.391602 5.52783L4.19583 6.7639"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2393_1490">
|
||||
<rect width="16" height="16" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const UploadIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1.5 4.875C1.5 3.01104 3.01104 1.5 4.875 1.5C6.20018 1.5 7.34838 2.26364 7.901 3.37829C8.1902 3.96162 8.79547 4.5 9.60112 4.5H12.25C13.4926 4.5 14.5 5.50736 14.5 6.75C14.5 7.42688 14.202 8.03329 13.7276 8.44689L13.1622 8.93972L14.1479 10.0704L14.7133 9.57758C15.5006 8.89123 16 7.8785 16 6.75C16 4.67893 14.3211 3 12.25 3H9.60112C9.51183 3 9.35322 2.93049 9.2449 2.71201C8.44888 1.1064 6.79184 0 4.875 0C2.18261 0 0 2.18261 0 4.875V6.40385C0 7.69502 0.598275 8.84699 1.52982 9.59656L2.11415 10.0667L3.0545 8.89808L2.47018 8.42791C1.87727 7.95083 1.5 7.22166 1.5 6.40385V4.875ZM7.29289 7.39645C7.68342 7.00592 8.31658 7.00592 8.70711 7.39645L11.7803 10.4697L12.3107 11L11.25 12.0607L10.7197 11.5303L8.75 9.56066V15.25V16H7.25V15.25V9.56066L5.28033 11.5303L4.75 12.0607L3.68934 11L4.21967 10.4697L7.29289 7.39645Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const MenuIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1 2H1.75H14.25H15V3.5H14.25H1.75H1V2ZM1 12.5H1.75H14.25H15V14H14.25H1.75H1V12.5ZM1.75 7.25H1V8.75H1.75H14.25H15V7.25H14.25H1.75Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const PencilEditIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.75 0.189331L12.2803 0.719661L15.2803 3.71966L15.8107 4.24999L15.2803 4.78032L5.15901 14.9016C4.45575 15.6049 3.50192 16 2.50736 16H0.75H0V15.25V13.4926C0 12.4981 0.395088 11.5442 1.09835 10.841L11.2197 0.719661L11.75 0.189331ZM11.75 2.31065L9.81066 4.24999L11.75 6.18933L13.6893 4.24999L11.75 2.31065ZM2.15901 11.9016L8.75 5.31065L10.6893 7.24999L4.09835 13.841C3.67639 14.2629 3.1041 14.5 2.50736 14.5H1.5V13.4926C1.5 12.8959 1.73705 12.3236 2.15901 11.9016ZM9 16H16V14.5H9V16Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const CheckedSquare = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15 16H1C0.447715 16 0 15.5523 0 15V1C0 0.447715 0.447716 0 1 0L15 8.17435e-06C15.5523 8.47532e-06 16 0.447724 16 1.00001V15C16 15.5523 15.5523 16 15 16ZM11.7803 6.28033L12.3107 5.75L11.25 4.68934L10.7197 5.21967L6.5 9.43935L5.28033 8.21967L4.75001 7.68934L3.68934 8.74999L4.21967 9.28033L5.96967 11.0303C6.11032 11.171 6.30109 11.25 6.5 11.25C6.69891 11.25 6.88968 11.171 7.03033 11.0303L11.7803 6.28033Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const UncheckedSquare = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="14"
|
||||
height="14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const MoreIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 4C7.17157 4 6.5 3.32843 6.5 2.5C6.5 1.67157 7.17157 1 8 1C8.82843 1 9.5 1.67157 9.5 2.5C9.5 3.32843 8.82843 4 8 4ZM8 9.5C7.17157 9.5 6.5 8.82843 6.5 8C6.5 7.17157 7.17157 6.5 8 6.5C8.82843 6.5 9.5 7.17157 9.5 8C9.5 8.82843 8.82843 9.5 8 9.5ZM6.5 13.5C6.5 14.3284 7.17157 15 8 15C8.82843 15 9.5 14.3284 9.5 13.5C9.5 12.6716 8.82843 12 8 12C7.17157 12 6.5 12.6716 6.5 13.5Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const TrashIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.75 2.75C6.75 2.05964 7.30964 1.5 8 1.5C8.69036 1.5 9.25 2.05964 9.25 2.75V3H6.75V2.75ZM5.25 3V2.75C5.25 1.23122 6.48122 0 8 0C9.51878 0 10.75 1.23122 10.75 2.75V3H12.9201H14.25H15V4.5H14.25H13.8846L13.1776 13.6917C13.0774 14.9942 11.9913 16 10.6849 16H5.31508C4.00874 16 2.92263 14.9942 2.82244 13.6917L2.11538 4.5H1.75H1V3H1.75H3.07988H5.25ZM4.31802 13.5767L3.61982 4.5H12.3802L11.682 13.5767C11.6419 14.0977 11.2075 14.5 10.6849 14.5H5.31508C4.79254 14.5 4.3581 14.0977 4.31802 13.5767Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const InfoIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM6.25002 7H7.00002H7.75C8.30229 7 8.75 7.44772 8.75 8V11.5V12.25H7.25V11.5V8.5H7.00002H6.25002V7ZM8 6C8.55229 6 9 5.55228 9 5C9 4.44772 8.55229 4 8 4C7.44772 4 7 4.44772 7 5C7 5.55228 7.44772 6 8 6Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ArrowUpIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.70711 1.39644C8.31659 1.00592 7.68342 1.00592 7.2929 1.39644L2.21968 6.46966L1.68935 6.99999L2.75001 8.06065L3.28034 7.53032L7.25001 3.56065V14.25V15H8.75001V14.25V3.56065L12.7197 7.53032L13.25 8.06065L14.3107 6.99999L13.7803 6.46966L8.70711 1.39644Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const StopIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3 3H13V13H3V3Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaperclipIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
className="-rotate-45"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.8591 1.70735C10.3257 1.70735 9.81417 1.91925 9.437 2.29643L3.19455 8.53886C2.56246 9.17095 2.20735 10.0282 2.20735 10.9222C2.20735 11.8161 2.56246 12.6734 3.19455 13.3055C3.82665 13.9376 4.68395 14.2927 5.57786 14.2927C6.47178 14.2927 7.32908 13.9376 7.96117 13.3055L14.2036 7.06304L14.7038 6.56287L15.7041 7.56321L15.204 8.06337L8.96151 14.3058C8.06411 15.2032 6.84698 15.7074 5.57786 15.7074C4.30875 15.7074 3.09162 15.2032 2.19422 14.3058C1.29682 13.4084 0.792664 12.1913 0.792664 10.9222C0.792664 9.65305 1.29682 8.43592 2.19422 7.53852L8.43666 1.29609C9.07914 0.653606 9.95054 0.292664 10.8591 0.292664C11.7678 0.292664 12.6392 0.653606 13.2816 1.29609C13.9241 1.93857 14.2851 2.80997 14.2851 3.71857C14.2851 4.62718 13.9241 5.49858 13.2816 6.14106L13.2814 6.14133L7.0324 12.3835C7.03231 12.3836 7.03222 12.3837 7.03213 12.3838C6.64459 12.7712 6.11905 12.9888 5.57107 12.9888C5.02297 12.9888 4.49731 12.7711 4.10974 12.3835C3.72217 11.9959 3.50444 11.4703 3.50444 10.9222C3.50444 10.3741 3.72217 9.8484 4.10974 9.46084L4.11004 9.46054L9.877 3.70039L10.3775 3.20051L11.3772 4.20144L10.8767 4.70131L5.11008 10.4612C5.11005 10.4612 5.11003 10.4612 5.11 10.4613C4.98779 10.5835 4.91913 10.7493 4.91913 10.9222C4.91913 11.0951 4.98782 11.2609 5.11008 11.3832C5.23234 11.5054 5.39817 11.5741 5.57107 11.5741C5.74398 11.5741 5.9098 11.5054 6.03206 11.3832L6.03233 11.3829L12.2813 5.14072C12.2814 5.14063 12.2815 5.14054 12.2816 5.14045C12.6586 4.7633 12.8704 4.25185 12.8704 3.71857C12.8704 3.18516 12.6585 2.6736 12.2813 2.29643C11.9041 1.91925 11.3926 1.70735 10.8591 1.70735Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const MoreHorizontalIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4 8C4 8.82843 3.32843 9.5 2.5 9.5C1.67157 9.5 1 8.82843 1 8C1 7.17157 1.67157 6.5 2.5 6.5C3.32843 6.5 4 7.17157 4 8ZM9.5 8C9.5 8.82843 8.82843 9.5 8 9.5C7.17157 9.5 6.5 8.82843 6.5 8C6.5 7.17157 7.17157 6.5 8 6.5C8.82843 6.5 9.5 7.17157 9.5 8ZM13.5 9.5C14.3284 9.5 15 8.82843 15 8C15 7.17157 14.3284 6.5 13.5 6.5C12.6716 6.5 12 7.17157 12 8C12 8.82843 12.6716 9.5 13.5 9.5Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const MessageIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.8914 10.4028L2.98327 10.6318C3.22909 11.2445 3.5 12.1045 3.5 13C3.5 13.3588 3.4564 13.7131 3.38773 14.0495C3.69637 13.9446 4.01409 13.8159 4.32918 13.6584C4.87888 13.3835 5.33961 13.0611 5.70994 12.7521L6.22471 12.3226L6.88809 12.4196C7.24851 12.4724 7.61994 12.5 8 12.5C11.7843 12.5 14.5 9.85569 14.5 7C14.5 4.14431 11.7843 1.5 8 1.5C4.21574 1.5 1.5 4.14431 1.5 7C1.5 8.18175 1.94229 9.29322 2.73103 10.2153L2.8914 10.4028ZM2.8135 15.7653C1.76096 16 1 16 1 16C1 16 1.43322 15.3097 1.72937 14.4367C1.88317 13.9834 2 13.4808 2 13C2 12.3826 1.80733 11.7292 1.59114 11.1903C0.591845 10.0221 0 8.57152 0 7C0 3.13401 3.58172 0 8 0C12.4183 0 16 3.13401 16 7C16 10.866 12.4183 14 8 14C7.54721 14 7.10321 13.9671 6.67094 13.9038C6.22579 14.2753 5.66881 14.6656 5 15C4.23366 15.3832 3.46733 15.6195 2.8135 15.7653Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
78
components/custom/markdown.tsx
Normal file
78
components/custom/markdown.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import Link from "next/link";
|
||||
import React, { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
const NonMemoizedMarkdown = ({ children }: { children: string }) => {
|
||||
const components = {
|
||||
code: ({ node, inline, className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<pre
|
||||
{...props}
|
||||
className={`${className} text-sm w-[80dvw] md:max-w-[500px] overflow-x-scroll bg-zinc-100 p-3 rounded-lg mt-2 dark:bg-zinc-800`}
|
||||
>
|
||||
<code className={match[1]}>{children}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<code
|
||||
className={`${className} text-sm bg-zinc-100 dark:bg-zinc-800 py-0.5 px-1 rounded-md`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ol: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<ol className="list-decimal list-outside ml-4" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<li className="py-1" {...props}>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
ul: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<ul className="list-decimal list-outside ml-4" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
strong: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<span className="font-semibold" {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
a: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<Link
|
||||
className="text-blue-500 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
export const Markdown = memo(
|
||||
NonMemoizedMarkdown,
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children,
|
||||
);
|
||||
76
components/custom/message.tsx
Normal file
76
components/custom/message.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { Attachment, ToolInvocation } from "ai";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { BotIcon, UserIcon } from "./icons";
|
||||
import { Markdown } from "./markdown";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import { Weather } from "./weather";
|
||||
|
||||
export const Message = ({
|
||||
role,
|
||||
content,
|
||||
toolInvocations,
|
||||
attachments,
|
||||
}: {
|
||||
role: string;
|
||||
content: string | ReactNode;
|
||||
toolInvocations: Array<ToolInvocation> | undefined;
|
||||
attachments?: Array<Attachment>;
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
className={`flex flex-row gap-4 px-4 w-full md:w-[500px] md:px-0 first-of-type:pt-20`}
|
||||
initial={{ y: 5, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
>
|
||||
<div className="size-[24px] flex flex-col justify-center items-center shrink-0 text-zinc-400">
|
||||
{role === "assistant" ? <BotIcon /> : <UserIcon />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{content && (
|
||||
<div className="text-zinc-800 dark:text-zinc-300 flex flex-col gap-4">
|
||||
<Markdown>{content as string}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolInvocations && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{toolInvocations.map((toolInvocation) => {
|
||||
const { toolName, toolCallId, state } = toolInvocation;
|
||||
|
||||
if (state === "result") {
|
||||
const { result } = toolInvocation;
|
||||
|
||||
return (
|
||||
<div key={toolCallId}>
|
||||
{toolName === "getWeather" ? (
|
||||
<Weather weatherAtLocation={result} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={toolCallId} className="skeleton">
|
||||
{toolName === "getWeather" ? <Weather /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments && (
|
||||
<div className="flex flex-row gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<PreviewAttachment key={attachment.url} attachment={attachment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
266
components/custom/multimodal-input.tsx
Normal file
266
components/custom/multimodal-input.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"use client";
|
||||
|
||||
import { Attachment, ChatRequestOptions, CreateMessage, Message } from "ai";
|
||||
import { motion } from "framer-motion";
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
ChangeEvent,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import { Button } from "../ui/button";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
const suggestedActions = [
|
||||
{
|
||||
title: "What is",
|
||||
label: "the meaning of life?",
|
||||
action: "what is the meaning of life?",
|
||||
},
|
||||
{
|
||||
title: "Why do",
|
||||
label: "developers use Next.js?",
|
||||
action: "why do developers use Next.js?",
|
||||
},
|
||||
];
|
||||
|
||||
export function MultimodalInput({
|
||||
input,
|
||||
setInput,
|
||||
isLoading,
|
||||
stop,
|
||||
attachments,
|
||||
setAttachments,
|
||||
messages,
|
||||
append,
|
||||
handleSubmit,
|
||||
}: {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
isLoading: boolean;
|
||||
stop: () => void;
|
||||
attachments: Array<Attachment>;
|
||||
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
|
||||
messages: Array<Message>;
|
||||
append: (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: ChatRequestOptions,
|
||||
) => Promise<string | null | undefined>;
|
||||
handleSubmit: (
|
||||
event?: {
|
||||
preventDefault?: () => void;
|
||||
},
|
||||
chatRequestOptions?: ChatRequestOptions,
|
||||
) => void;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
adjustHeight();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const adjustHeight = () => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(event.target.value);
|
||||
adjustHeight();
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);
|
||||
|
||||
const submitForm = useCallback(() => {
|
||||
handleSubmit(undefined, {
|
||||
experimental_attachments: attachments,
|
||||
});
|
||||
|
||||
setAttachments([]);
|
||||
}, [attachments, handleSubmit, setAttachments]);
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const { url, pathname, contentType } = data;
|
||||
|
||||
return {
|
||||
url,
|
||||
name: pathname,
|
||||
contentType: contentType,
|
||||
};
|
||||
} else {
|
||||
const { error } = await response.json();
|
||||
toast.error(error);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to upload file, please try again!");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
|
||||
setUploadQueue(files.map((file) => file.name));
|
||||
|
||||
try {
|
||||
const uploadPromises = files.map((file) => uploadFile(file));
|
||||
const uploadedAttachments = await Promise.all(uploadPromises);
|
||||
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
||||
(attachment) => attachment !== undefined,
|
||||
);
|
||||
|
||||
setAttachments((currentAttachments) => [
|
||||
...currentAttachments,
|
||||
...successfullyUploadedAttachments,
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading files!", error);
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
}
|
||||
},
|
||||
[setAttachments],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full flex flex-col gap-4">
|
||||
{messages.length === 0 &&
|
||||
attachments.length === 0 &&
|
||||
uploadQueue.length === 0 && (
|
||||
<div className="grid sm:grid-cols-2 gap-2 w-full md:px-0 mx-auto md:max-w-[500px]">
|
||||
{suggestedActions.map((suggestedAction, index) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ delay: 0.05 * index }}
|
||||
key={index}
|
||||
className={index > 1 ? "hidden sm:block" : "block"}
|
||||
>
|
||||
<button
|
||||
onClick={async () => {
|
||||
append({
|
||||
role: "user",
|
||||
content: suggestedAction.action,
|
||||
});
|
||||
}}
|
||||
className="w-full text-left border border-zinc-200 dark:border-zinc-800 text-zinc-800 dark:text-zinc-300 rounded-lg p-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors flex flex-col"
|
||||
>
|
||||
<span className="font-medium">{suggestedAction.title}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">
|
||||
{suggestedAction.label}
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
className="fixed -top-4 -left-4 size-0.5 opacity-0 pointer-events-none"
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
||||
<div className="flex flex-row gap-2 overflow-x-scroll">
|
||||
{attachments.map((attachment) => (
|
||||
<PreviewAttachment key={attachment.url} attachment={attachment} />
|
||||
))}
|
||||
|
||||
{uploadQueue.map((filename) => (
|
||||
<PreviewAttachment
|
||||
key={filename}
|
||||
attachment={{
|
||||
url: "",
|
||||
name: filename,
|
||||
contentType: "",
|
||||
}}
|
||||
isUploading={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder="Send a message..."
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
className="min-h-[24px] overflow-hidden resize-none rounded-lg text-base bg-muted"
|
||||
rows={3}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isLoading) {
|
||||
toast.error("Please wait for the model to finish its response!");
|
||||
} else {
|
||||
submitForm();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Button
|
||||
className="rounded-full p-1.5 h-fit absolute bottom-2 right-2 m-0.5"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
stop();
|
||||
}}
|
||||
>
|
||||
<StopIcon size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="rounded-full p-1.5 h-fit absolute bottom-2 right-2 m-0.5"
|
||||
onClick={(event) => {
|
||||
handleSubmit(event);
|
||||
}}
|
||||
disabled={input.length === 0 || uploadQueue.length > 0}
|
||||
>
|
||||
<ArrowUpIcon size={14} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="rounded-full p-1.5 h-fit absolute bottom-2 right-10 m-0.5 dark:border-zinc-700"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<PaperclipIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
components/custom/navbar.tsx
Normal file
71
components/custom/navbar.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { auth, signOut } from "@/app/(auth)/auth";
|
||||
|
||||
import { History } from "./history";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
export const Navbar = async () => {
|
||||
let session = await auth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-background absolute top-0 left-0 w-dvw py-2 px-3 justify-between flex flex-row items-center z-30">
|
||||
<div className="flex flex-row gap-3 items-center">
|
||||
<History user={session?.user} />
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="text-sm dark:text-zinc-300">Next.js Chatbot</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="py-1.5 px-2 h-fit font-normal"
|
||||
variant="secondary"
|
||||
>
|
||||
{session.user?.email}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<ThemeToggle />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="p-1 z-50">
|
||||
<form
|
||||
className="w-full"
|
||||
action={async () => {
|
||||
"use server";
|
||||
|
||||
await signOut({
|
||||
redirectTo: "/",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full text-left px-1 py-0.5 text-red-500"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button className="py-1.5 px-2 h-fit font-normal" asChild>
|
||||
<Link href="/login">Login</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
45
components/custom/overview.tsx
Normal file
45
components/custom/overview.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
|
||||
import { LogoOpenAI, MessageIcon, VercelIcon } from "./icons";
|
||||
|
||||
export const Overview = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="overview"
|
||||
className="max-w-[500px] mt-20 mx-4 md:mx-0"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<div className="border rounded-lg p-6 flex flex-col gap-4 text-zinc-500 text-sm dark:text-zinc-400 dark:border-zinc-700">
|
||||
<p className="flex flex-row justify-center gap-4 items-center text-zinc-900 dark:text-zinc-50">
|
||||
<VercelIcon />
|
||||
<span>+</span>
|
||||
<MessageIcon />
|
||||
</p>
|
||||
<p>
|
||||
This is an open source Chatbot template built with Next.js and the AI
|
||||
SDK by Vercel. It uses the{" "}
|
||||
<code className="rounded-md bg-muted px-1 py-0.5">streamText</code>{" "}
|
||||
function in the server and the{" "}
|
||||
<code className="rounded-md bg-muted px-1 py-0.5">useChat</code> hook
|
||||
on the client to create a seamless chat experience.
|
||||
</p>
|
||||
<p>
|
||||
{" "}
|
||||
You can learn more about the AI SDK by visiting the{" "}
|
||||
<Link
|
||||
className="text-blue-500 dark:text-blue-400"
|
||||
href="https://sdk.vercel.ai/docs"
|
||||
target="_blank"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
44
components/custom/preview-attachment.tsx
Normal file
44
components/custom/preview-attachment.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Attachment } from "ai";
|
||||
|
||||
import { LoaderIcon } from "./icons";
|
||||
|
||||
export const PreviewAttachment = ({
|
||||
attachment,
|
||||
isUploading = false,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
isUploading?: boolean;
|
||||
}) => {
|
||||
const { name, url, contentType } = attachment;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 max-w-16">
|
||||
<div className="h-20 w-16 bg-muted rounded-md relative flex flex-col items-center justify-center">
|
||||
{contentType ? (
|
||||
contentType.startsWith("image") ? (
|
||||
// NOTE: it is recommended to use next/image for images
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={url}
|
||||
src={url}
|
||||
alt={name ?? "An image attachment"}
|
||||
className="rounded-md size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className=""></div>
|
||||
)
|
||||
) : (
|
||||
<div className=""></div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="animate-spin absolute text-zinc-500">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500 max-w-16 truncate">{name}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
components/custom/submit-button.tsx
Normal file
29
components/custom/submit-button.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import { LoaderIcon } from "@/components/custom/icons";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export function SubmitButton({ children }: { children: React.ReactNode }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type={pending ? "button" : "submit"}
|
||||
aria-disabled={pending}
|
||||
className="relative"
|
||||
>
|
||||
{children}
|
||||
{pending && (
|
||||
<span className="animate-spin absolute right-4">
|
||||
<LoaderIcon />
|
||||
</span>
|
||||
)}
|
||||
<span aria-live="polite" className="sr-only" role="status">
|
||||
{pending ? "Loading" : "Submit form"}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
9
components/custom/theme-provider.tsx
Normal file
9
components/custom/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import * as React from "react";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
28
components/custom/theme-toggle.tsx
Normal file
28
components/custom/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}}
|
||||
>
|
||||
{`Toggle ${theme === "light" ? "dark" : "light"} mode`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
components/custom/use-scroll-to-bottom.ts
Normal file
29
components/custom/use-scroll-to-bottom.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useEffect, useRef, RefObject } from "react";
|
||||
|
||||
export function useScrollToBottom<T extends HTMLElement>(): [
|
||||
RefObject<T>,
|
||||
RefObject<T>,
|
||||
] {
|
||||
const containerRef = useRef<T>(null);
|
||||
const endRef = useRef<T>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const end = endRef.current;
|
||||
|
||||
if (container && end) {
|
||||
const observer = new MutationObserver(() => {
|
||||
end.scrollIntoView({ behavior: "instant", block: "end" });
|
||||
});
|
||||
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [containerRef, endRef];
|
||||
}
|
||||
307
components/custom/weather.tsx
Normal file
307
components/custom/weather.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"use client";
|
||||
|
||||
import cx from "classnames";
|
||||
import { addHours, format, isWithinInterval } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface WeatherAtLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
generationtime_ms: number;
|
||||
utc_offset_seconds: number;
|
||||
timezone: string;
|
||||
timezone_abbreviation: string;
|
||||
elevation: number;
|
||||
current_units: {
|
||||
time: string;
|
||||
interval: string;
|
||||
temperature_2m: string;
|
||||
};
|
||||
current: {
|
||||
time: string;
|
||||
interval: number;
|
||||
temperature_2m: number;
|
||||
};
|
||||
hourly_units: {
|
||||
time: string;
|
||||
temperature_2m: string;
|
||||
};
|
||||
hourly: {
|
||||
time: string[];
|
||||
temperature_2m: number[];
|
||||
};
|
||||
daily_units: {
|
||||
time: string;
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
};
|
||||
daily: {
|
||||
time: string[];
|
||||
sunrise: string[];
|
||||
sunset: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE = {
|
||||
latitude: 37.763283,
|
||||
longitude: -122.41286,
|
||||
generationtime_ms: 0.027894973754882812,
|
||||
utc_offset_seconds: 0,
|
||||
timezone: "GMT",
|
||||
timezone_abbreviation: "GMT",
|
||||
elevation: 18,
|
||||
current_units: { time: "iso8601", interval: "seconds", temperature_2m: "°C" },
|
||||
current: { time: "2024-10-07T19:30", interval: 900, temperature_2m: 29.3 },
|
||||
hourly_units: { time: "iso8601", temperature_2m: "°C" },
|
||||
hourly: {
|
||||
time: [
|
||||
"2024-10-07T00:00",
|
||||
"2024-10-07T01:00",
|
||||
"2024-10-07T02:00",
|
||||
"2024-10-07T03:00",
|
||||
"2024-10-07T04:00",
|
||||
"2024-10-07T05:00",
|
||||
"2024-10-07T06:00",
|
||||
"2024-10-07T07:00",
|
||||
"2024-10-07T08:00",
|
||||
"2024-10-07T09:00",
|
||||
"2024-10-07T10:00",
|
||||
"2024-10-07T11:00",
|
||||
"2024-10-07T12:00",
|
||||
"2024-10-07T13:00",
|
||||
"2024-10-07T14:00",
|
||||
"2024-10-07T15:00",
|
||||
"2024-10-07T16:00",
|
||||
"2024-10-07T17:00",
|
||||
"2024-10-07T18:00",
|
||||
"2024-10-07T19:00",
|
||||
"2024-10-07T20:00",
|
||||
"2024-10-07T21:00",
|
||||
"2024-10-07T22:00",
|
||||
"2024-10-07T23:00",
|
||||
"2024-10-08T00:00",
|
||||
"2024-10-08T01:00",
|
||||
"2024-10-08T02:00",
|
||||
"2024-10-08T03:00",
|
||||
"2024-10-08T04:00",
|
||||
"2024-10-08T05:00",
|
||||
"2024-10-08T06:00",
|
||||
"2024-10-08T07:00",
|
||||
"2024-10-08T08:00",
|
||||
"2024-10-08T09:00",
|
||||
"2024-10-08T10:00",
|
||||
"2024-10-08T11:00",
|
||||
"2024-10-08T12:00",
|
||||
"2024-10-08T13:00",
|
||||
"2024-10-08T14:00",
|
||||
"2024-10-08T15:00",
|
||||
"2024-10-08T16:00",
|
||||
"2024-10-08T17:00",
|
||||
"2024-10-08T18:00",
|
||||
"2024-10-08T19:00",
|
||||
"2024-10-08T20:00",
|
||||
"2024-10-08T21:00",
|
||||
"2024-10-08T22:00",
|
||||
"2024-10-08T23:00",
|
||||
"2024-10-09T00:00",
|
||||
"2024-10-09T01:00",
|
||||
"2024-10-09T02:00",
|
||||
"2024-10-09T03:00",
|
||||
"2024-10-09T04:00",
|
||||
"2024-10-09T05:00",
|
||||
"2024-10-09T06:00",
|
||||
"2024-10-09T07:00",
|
||||
"2024-10-09T08:00",
|
||||
"2024-10-09T09:00",
|
||||
"2024-10-09T10:00",
|
||||
"2024-10-09T11:00",
|
||||
"2024-10-09T12:00",
|
||||
"2024-10-09T13:00",
|
||||
"2024-10-09T14:00",
|
||||
"2024-10-09T15:00",
|
||||
"2024-10-09T16:00",
|
||||
"2024-10-09T17:00",
|
||||
"2024-10-09T18:00",
|
||||
"2024-10-09T19:00",
|
||||
"2024-10-09T20:00",
|
||||
"2024-10-09T21:00",
|
||||
"2024-10-09T22:00",
|
||||
"2024-10-09T23:00",
|
||||
"2024-10-10T00:00",
|
||||
"2024-10-10T01:00",
|
||||
"2024-10-10T02:00",
|
||||
"2024-10-10T03:00",
|
||||
"2024-10-10T04:00",
|
||||
"2024-10-10T05:00",
|
||||
"2024-10-10T06:00",
|
||||
"2024-10-10T07:00",
|
||||
"2024-10-10T08:00",
|
||||
"2024-10-10T09:00",
|
||||
"2024-10-10T10:00",
|
||||
"2024-10-10T11:00",
|
||||
"2024-10-10T12:00",
|
||||
"2024-10-10T13:00",
|
||||
"2024-10-10T14:00",
|
||||
"2024-10-10T15:00",
|
||||
"2024-10-10T16:00",
|
||||
"2024-10-10T17:00",
|
||||
"2024-10-10T18:00",
|
||||
"2024-10-10T19:00",
|
||||
"2024-10-10T20:00",
|
||||
"2024-10-10T21:00",
|
||||
"2024-10-10T22:00",
|
||||
"2024-10-10T23:00",
|
||||
"2024-10-11T00:00",
|
||||
"2024-10-11T01:00",
|
||||
"2024-10-11T02:00",
|
||||
"2024-10-11T03:00",
|
||||
],
|
||||
temperature_2m: [
|
||||
36.6, 32.8, 29.5, 28.6, 29.2, 28.2, 27.5, 26.6, 26.5, 26, 25, 23.5, 23.9,
|
||||
24.2, 22.9, 21, 24, 28.1, 31.4, 33.9, 32.1, 28.9, 26.9, 25.2, 23, 21.1,
|
||||
19.6, 18.6, 17.7, 16.8, 16.2, 15.5, 14.9, 14.4, 14.2, 13.7, 13.3, 12.9,
|
||||
12.5, 13.5, 15.8, 17.7, 19.6, 21, 21.9, 22.3, 22, 20.7, 18.9, 17.9, 17.3,
|
||||
17, 16.7, 16.2, 15.6, 15.2, 15, 15, 15.1, 14.8, 14.8, 14.9, 14.7, 14.8,
|
||||
15.3, 16.2, 17.9, 19.6, 20.5, 21.6, 21, 20.7, 19.3, 18.7, 18.4, 17.9,
|
||||
17.3, 17, 17, 16.8, 16.4, 16.2, 16, 15.8, 15.7, 15.4, 15.4, 16.1, 16.7,
|
||||
17, 18.6, 19, 19.5, 19.4, 18.5, 17.9, 17.5, 16.7, 16.3, 16.1,
|
||||
],
|
||||
},
|
||||
daily_units: {
|
||||
time: "iso8601",
|
||||
sunrise: "iso8601",
|
||||
sunset: "iso8601",
|
||||
},
|
||||
daily: {
|
||||
time: [
|
||||
"2024-10-07",
|
||||
"2024-10-08",
|
||||
"2024-10-09",
|
||||
"2024-10-10",
|
||||
"2024-10-11",
|
||||
],
|
||||
sunrise: [
|
||||
"2024-10-07T07:15",
|
||||
"2024-10-08T07:16",
|
||||
"2024-10-09T07:17",
|
||||
"2024-10-10T07:18",
|
||||
"2024-10-11T07:19",
|
||||
],
|
||||
sunset: [
|
||||
"2024-10-07T19:00",
|
||||
"2024-10-08T18:58",
|
||||
"2024-10-09T18:57",
|
||||
"2024-10-10T18:55",
|
||||
"2024-10-11T18:54",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function Weather({
|
||||
weatherAtLocation = SAMPLE,
|
||||
}: {
|
||||
weatherAtLocation?: WeatherAtLocation;
|
||||
}) {
|
||||
const currentHigh = Math.max(
|
||||
...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
|
||||
);
|
||||
const currentLow = Math.min(
|
||||
...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
|
||||
);
|
||||
|
||||
const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), {
|
||||
start: new Date(weatherAtLocation.daily.sunrise[0]),
|
||||
end: new Date(weatherAtLocation.daily.sunset[0]),
|
||||
});
|
||||
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const hoursToShow = isMobile ? 5 : 6;
|
||||
|
||||
// Find the index of the current time or the next closest time
|
||||
const currentTimeIndex = weatherAtLocation.hourly.time.findIndex(
|
||||
(time) => new Date(time) >= new Date(weatherAtLocation.current.time),
|
||||
);
|
||||
|
||||
// Slice the arrays to get the desired number of items
|
||||
const displayTimes = weatherAtLocation.hourly.time.slice(
|
||||
currentTimeIndex,
|
||||
currentTimeIndex + hoursToShow,
|
||||
);
|
||||
const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice(
|
||||
currentTimeIndex,
|
||||
currentTimeIndex + hoursToShow,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"flex flex-col gap-4 rounded-2xl p-4",
|
||||
{
|
||||
"bg-blue-400": isDay,
|
||||
},
|
||||
{
|
||||
"bg-indigo-900": !isDay,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div
|
||||
className={cx(
|
||||
"size-10 rounded-full",
|
||||
{
|
||||
"bg-yellow-300": isDay,
|
||||
},
|
||||
{
|
||||
"bg-indigo-100": !isDay,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<div className="text-4xl font-medium text-blue-50">
|
||||
{weatherAtLocation.current.temperature_2m}
|
||||
{weatherAtLocation.current_units.temperature_2m}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-blue-50">{`H:${currentHigh}° L:${currentLow}°`}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-between">
|
||||
{displayTimes.map((time, index) => (
|
||||
<div key={time} className="flex flex-col items-center gap-1">
|
||||
<div className="text-blue-100 text-xs">
|
||||
{format(new Date(time), "ha")}
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"size-6 rounded-full",
|
||||
{
|
||||
"bg-yellow-300": isDay,
|
||||
},
|
||||
{
|
||||
"bg-indigo-200": !isDay,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<div className="text-blue-50 text-sm">
|
||||
{displayTemperatures[index]}
|
||||
{weatherAtLocation.hourly_units.temperature_2m}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { UseChatHelpers } from 'ai/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ExternalLink } from '@/components/external-link'
|
||||
import { IconArrowRight } from '@/components/ui/icons'
|
||||
|
||||
export function EmptyScreen() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4">
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-background p-8">
|
||||
<h1 className="text-lg font-semibold">
|
||||
Welcome to Next.js AI Chatbot!
|
||||
</h1>
|
||||
<p className="leading-normal text-muted-foreground">
|
||||
This is an open source AI chatbot app template built with{' '}
|
||||
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink>, the{' '}
|
||||
<ExternalLink href="https://sdk.vercel.ai">
|
||||
Vercel AI SDK
|
||||
</ExternalLink>
|
||||
, and{' '}
|
||||
<ExternalLink href="https://vercel.com/storage/kv">
|
||||
Vercel KV
|
||||
</ExternalLink>
|
||||
.
|
||||
</p>
|
||||
<p className="leading-normal text-muted-foreground">
|
||||
It uses{' '}
|
||||
<ExternalLink href="https://vercel.com/blog/ai-sdk-3-generative-ui">
|
||||
React Server Components
|
||||
</ExternalLink>{' '}
|
||||
to combine text with generative UI as output of the LLM. The UI state
|
||||
is synced through the SDK so the model is aware of your interactions
|
||||
as they happen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
export function ExternalLink({
|
||||
href,
|
||||
children
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
className="inline-flex flex-1 justify-center gap-1 leading-4 hover:underline"
|
||||
>
|
||||
<span>{children}</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
height="7"
|
||||
viewBox="0 0 6 6"
|
||||
width="7"
|
||||
className="opacity-70"
|
||||
>
|
||||
<path
|
||||
d="M1.25215 5.54731L0.622742 4.9179L3.78169 1.75597H1.3834L1.38936 0.890915H5.27615V4.78069H4.40513L4.41109 2.38538L1.25215 5.54731Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ExternalLink } from '@/components/external-link'
|
||||
|
||||
export function FooterText({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'px-2 text-center text-xs leading-normal text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
Open source AI chatbot built with{' '}
|
||||
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '}
|
||||
<ExternalLink href="https://github.com/vercel/ai">
|
||||
Vercel AI SDK
|
||||
</ExternalLink>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { auth } from '@/auth'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
IconGitHub,
|
||||
IconNextChat,
|
||||
IconSeparator,
|
||||
IconVercel
|
||||
} from '@/components/ui/icons'
|
||||
import { UserMenu } from '@/components/user-menu'
|
||||
import { SidebarMobile } from './sidebar-mobile'
|
||||
import { SidebarToggle } from './sidebar-toggle'
|
||||
import { ChatHistory } from './chat-history'
|
||||
import { Session } from '@/lib/types'
|
||||
|
||||
async function UserOrLogin() {
|
||||
const session = (await auth()) as Session
|
||||
return (
|
||||
<>
|
||||
{session?.user ? (
|
||||
<>
|
||||
<SidebarMobile>
|
||||
<ChatHistory userId={session.user.id} />
|
||||
</SidebarMobile>
|
||||
<SidebarToggle />
|
||||
</>
|
||||
) : (
|
||||
<Link href="/new" rel="nofollow">
|
||||
<IconNextChat className="size-6 mr-2 dark:hidden" inverted />
|
||||
<IconNextChat className="hidden size-6 mr-2 dark:block" />
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<IconSeparator className="size-6 text-muted-foreground/50" />
|
||||
{session?.user ? (
|
||||
<UserMenu user={session.user} />
|
||||
) : (
|
||||
<Button variant="link" asChild className="-ml-2">
|
||||
<Link href="/login">Login</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between w-full h-16 px-4 border-b shrink-0 bg-gradient-to-b from-background/10 via-background/50 to-background/80 backdrop-blur-xl">
|
||||
<div className="flex items-center">
|
||||
<React.Suspense fallback={<div className="flex-1 overflow-auto" />}>
|
||||
<UserOrLogin />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/vercel/nextjs-ai-chatbot/"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: 'outline' }))}
|
||||
>
|
||||
<IconGitHub />
|
||||
<span className="hidden ml-2 md:flex">GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://vercel.com/templates/Next.js/nextjs-ai-chatbot"
|
||||
target="_blank"
|
||||
className={cn(buttonVariants())}
|
||||
>
|
||||
<IconVercel className="mr-2" />
|
||||
<span className="hidden sm:block">Deploy to Vercel</span>
|
||||
<span className="sm:hidden">Deploy</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||
import { IconGitHub, IconSpinner } from '@/components/ui/icons'
|
||||
|
||||
interface LoginButtonProps extends ButtonProps {
|
||||
showGithubIcon?: boolean
|
||||
text?: string
|
||||
}
|
||||
|
||||
export function LoginButton({
|
||||
text = 'Login with GitHub',
|
||||
showGithubIcon = true,
|
||||
className,
|
||||
...props
|
||||
}: LoginButtonProps) {
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsLoading(true)
|
||||
// next-auth signIn() function doesn't work yet at Edge Runtime due to usage of BroadcastChannel
|
||||
signIn('github', { callbackUrl: `/` })
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<IconSpinner className="mr-2 animate-spin" />
|
||||
) : showGithubIcon ? (
|
||||
<IconGitHub className="mr-2" />
|
||||
) : null}
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useFormState, useFormStatus } from 'react-dom'
|
||||
import { authenticate } from '@/app/login/actions'
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconSpinner } from './ui/icons'
|
||||
import { getMessageFromCode } from '@/lib/utils'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter()
|
||||
const [result, dispatch] = useFormState(authenticate, undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
if (result.type === 'error') {
|
||||
toast.error(getMessageFromCode(result.resultCode))
|
||||
} else {
|
||||
toast.success(getMessageFromCode(result.resultCode))
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
}, [result, router])
|
||||
|
||||
return (
|
||||
<form
|
||||
action={dispatch}
|
||||
className="flex flex-col items-center gap-4 space-y-3"
|
||||
>
|
||||
<div className="w-full flex-1 rounded-lg border bg-white px-6 pb-4 pt-8 shadow-md md:w-96 dark:bg-zinc-950">
|
||||
<h1 className="mb-3 text-2xl font-bold">Please log in to continue.</h1>
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-zinc-400"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="peer block w-full rounded-md border bg-zinc-50 px-2 py-[9px] text-sm outline-none placeholder:text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-zinc-400"
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="peer block w-full rounded-md border bg-zinc-50 px-2 py-[9px] text-sm outline-none placeholder:text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Enter password"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LoginButton />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/signup"
|
||||
className="flex flex-row gap-1 text-sm text-zinc-400"
|
||||
>
|
||||
No account yet? <div className="font-semibold underline">Sign up</div>
|
||||
</Link>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginButton() {
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
return (
|
||||
<button
|
||||
className="my-4 flex h-10 w-full flex-row items-center justify-center rounded-md bg-zinc-900 p-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
aria-disabled={pending}
|
||||
>
|
||||
{pending ? <IconSpinner /> : 'Log in'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { FC, memo } from 'react'
|
||||
import ReactMarkdown, { Options } from 'react-markdown'
|
||||
|
||||
export const MemoizedReactMarkdown: FC<Options> = memo(
|
||||
ReactMarkdown,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.className === nextProps.className
|
||||
)
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
|
||||
import { useActions, useUIState } from 'ai/rsc'
|
||||
|
||||
import { UserMessage } from './stocks/message'
|
||||
import { type AI } from '@/lib/chat/actions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IconArrowElbow, IconPlus } from '@/components/ui/icons'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export function PromptForm({
|
||||
input,
|
||||
setInput
|
||||
}: {
|
||||
input: string
|
||||
setInput: (value: string) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { formRef, onKeyDown } = useEnterSubmit()
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
const { submitUserMessage } = useActions()
|
||||
const [_, setMessages] = useUIState<typeof AI>()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={async (e: any) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Blur focus on mobile
|
||||
if (window.innerWidth < 600) {
|
||||
e.target['message']?.blur()
|
||||
}
|
||||
|
||||
const value = input.trim()
|
||||
setInput('')
|
||||
if (!value) return
|
||||
|
||||
// Optimistically add user message UI
|
||||
setMessages(currentMessages => [
|
||||
...currentMessages,
|
||||
{
|
||||
id: nanoid(),
|
||||
display: <UserMessage>{value}</UserMessage>
|
||||
}
|
||||
])
|
||||
|
||||
// Submit and get response message
|
||||
const responseMessage = await submitUserMessage(value)
|
||||
setMessages(currentMessages => [...currentMessages, responseMessage])
|
||||
}}
|
||||
>
|
||||
<div className="relative flex max-h-60 w-full grow flex-col overflow-hidden bg-background px-8 sm:rounded-md sm:border sm:px-12">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute left-0 top-[14px] size-8 rounded-full bg-background p-0 sm:left-4"
|
||||
onClick={() => {
|
||||
router.push('/new')
|
||||
}}
|
||||
>
|
||||
<IconPlus />
|
||||
<span className="sr-only">New Chat</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New Chat</TooltipContent>
|
||||
</Tooltip>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Send a message."
|
||||
className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
name="message"
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
/>
|
||||
<div className="absolute right-0 top-[13px] sm:right-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="submit" size="icon" disabled={input === ''}>
|
||||
<IconArrowElbow />
|
||||
<span className="sr-only">Send message</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Send message</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { ThemeProviderProps } from 'next-themes/dist/types'
|
||||
import { SidebarProvider } from '@/lib/hooks/use-sidebar'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider {...props}>
|
||||
<SidebarProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ServerActionResult, type Chat } from '@/lib/types'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IconShare, IconSpinner, IconTrash } from '@/components/ui/icons'
|
||||
import { ChatShareDialog } from '@/components/chat-share-dialog'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface SidebarActionsProps {
|
||||
chat: Chat
|
||||
removeChat: (args: { id: string; path: string }) => ServerActionResult<void>
|
||||
shareChat: (id: string) => ServerActionResult<Chat>
|
||||
}
|
||||
|
||||
export function SidebarActions({
|
||||
chat,
|
||||
removeChat,
|
||||
shareChat
|
||||
}: SidebarActionsProps) {
|
||||
const router = useRouter()
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
|
||||
const [shareDialogOpen, setShareDialogOpen] = React.useState(false)
|
||||
const [isRemovePending, startRemoveTransition] = React.useTransition()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-7 p-0 hover:bg-background"
|
||||
onClick={() => setShareDialogOpen(true)}
|
||||
>
|
||||
<IconShare />
|
||||
<span className="sr-only">Share</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Share chat</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-7 p-0 hover:bg-background"
|
||||
disabled={isRemovePending}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<IconTrash />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete chat</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ChatShareDialog
|
||||
chat={chat}
|
||||
shareChat={shareChat}
|
||||
open={shareDialogOpen}
|
||||
onOpenChange={setShareDialogOpen}
|
||||
onCopy={() => setShareDialogOpen(false)}
|
||||
/>
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your chat message and remove your
|
||||
data from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRemovePending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isRemovePending}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
// @ts-ignore
|
||||
startRemoveTransition(async () => {
|
||||
const result = await removeChat({
|
||||
id: chat.id,
|
||||
path: chat.path
|
||||
})
|
||||
|
||||
if (result && 'error' in result) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
setDeleteDialogOpen(false)
|
||||
router.refresh()
|
||||
router.push('/')
|
||||
toast.success('Chat deleted')
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isRemovePending && <IconSpinner className="mr-2 animate-spin" />}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { Sidebar } from '@/components/sidebar'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { ChatHistory } from '@/components/chat-history'
|
||||
|
||||
export async function SidebarDesktop() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar className="peer absolute inset-y-0 z-30 hidden -translate-x-full border-r bg-muted duration-300 ease-in-out data-[state=open]:translate-x-0 lg:flex lg:w-[250px] xl:w-[300px]">
|
||||
{/* @ts-ignore */}
|
||||
<ChatHistory userId={session.user.id} />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SidebarFooter({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-between p-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { IconMessage, IconUsers } from '@/components/ui/icons'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||
import { type Chat } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SidebarItemProps {
|
||||
index: number
|
||||
chat: Chat
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function SidebarItem({ index, chat, children }: SidebarItemProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
const isActive = pathname === chat.path
|
||||
const [newChatId, setNewChatId] = useLocalStorage('newChatId', null)
|
||||
const shouldAnimate = index === 0 && isActive && newChatId
|
||||
|
||||
if (!chat?.id) return null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative h-8"
|
||||
variants={{
|
||||
initial: {
|
||||
height: 0,
|
||||
opacity: 0
|
||||
},
|
||||
animate: {
|
||||
height: 'auto',
|
||||
opacity: 1
|
||||
}
|
||||
}}
|
||||
initial={shouldAnimate ? 'initial' : undefined}
|
||||
animate={shouldAnimate ? 'animate' : undefined}
|
||||
transition={{
|
||||
duration: 0.25,
|
||||
ease: 'easeIn'
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-2 top-1 flex size-6 items-center justify-center">
|
||||
{chat.sharePath ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger
|
||||
tabIndex={-1}
|
||||
className="focus:bg-muted focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<IconUsers className="mr-2 mt-1 text-zinc-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>This is a shared chat.</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<IconMessage className="mr-2 mt-1 text-zinc-500" />
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={chat.path}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'group w-full px-8 transition-colors hover:bg-zinc-200/40 dark:hover:bg-zinc-300/10',
|
||||
isActive && 'bg-zinc-200 pr-16 font-semibold dark:bg-zinc-800'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative max-h-5 flex-1 select-none overflow-hidden text-ellipsis break-all"
|
||||
title={chat.title}
|
||||
>
|
||||
<span className="whitespace-nowrap">
|
||||
{shouldAnimate ? (
|
||||
chat.title.split('').map((character, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
variants={{
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: -100
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0
|
||||
}
|
||||
}}
|
||||
initial={shouldAnimate ? 'initial' : undefined}
|
||||
animate={shouldAnimate ? 'animate' : undefined}
|
||||
transition={{
|
||||
duration: 0.25,
|
||||
ease: 'easeIn',
|
||||
delay: index * 0.05,
|
||||
staggerChildren: 0.05
|
||||
}}
|
||||
onAnimationComplete={() => {
|
||||
if (index === chat.title.length - 1) {
|
||||
setNewChatId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{character}
|
||||
</motion.span>
|
||||
))
|
||||
) : (
|
||||
<span>{chat.title}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
{isActive && <div className="absolute right-2 top-1">{children}</div>}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { Chat } from '@/lib/types'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
import { removeChat, shareChat } from '@/app/actions'
|
||||
|
||||
import { SidebarActions } from '@/components/sidebar-actions'
|
||||
import { SidebarItem } from '@/components/sidebar-item'
|
||||
|
||||
interface SidebarItemsProps {
|
||||
chats?: Chat[]
|
||||
}
|
||||
|
||||
export function SidebarItems({ chats }: SidebarItemsProps) {
|
||||
if (!chats?.length) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{chats.map(
|
||||
(chat, index) =>
|
||||
chat && (
|
||||
<motion.div
|
||||
key={chat?.id}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
height: 0
|
||||
}}
|
||||
>
|
||||
<SidebarItem index={index} chat={chat}>
|
||||
<SidebarActions
|
||||
chat={chat}
|
||||
removeChat={removeChat}
|
||||
shareChat={shareChat}
|
||||
/>
|
||||
</SidebarItem>
|
||||
</motion.div>
|
||||
)
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { clearChats, getChats } from '@/app/actions'
|
||||
import { ClearHistory } from '@/components/clear-history'
|
||||
import { SidebarItems } from '@/components/sidebar-items'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { cache } from 'react'
|
||||
|
||||
interface SidebarListProps {
|
||||
userId?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const loadChats = cache(async (userId?: string) => {
|
||||
return await getChats(userId)
|
||||
})
|
||||
|
||||
export async function SidebarList({ userId }: SidebarListProps) {
|
||||
const chats = await loadChats(userId)
|
||||
|
||||
if (!chats || 'error' in chats) {
|
||||
redirect('/')
|
||||
} else {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{chats?.length ? (
|
||||
<div className="space-y-2 px-2">
|
||||
<SidebarItems chats={chats} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No chat history</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<ThemeToggle />
|
||||
<ClearHistory clearChats={clearChats} isEnabled={chats?.length > 0} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
|
||||
|
||||
import { Sidebar } from '@/components/sidebar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { IconSidebar } from '@/components/ui/icons'
|
||||
|
||||
interface SidebarMobileProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function SidebarMobile({ children }: SidebarMobileProps) {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" className="-ml-2 flex size-9 p-0 lg:hidden">
|
||||
<IconSidebar className="size-6" />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="inset-y-0 flex h-auto w-[300px] flex-col p-0"
|
||||
>
|
||||
<Sidebar className="flex">{children}</Sidebar>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { useSidebar } from '@/lib/hooks/use-sidebar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IconSidebar } from '@/components/ui/icons'
|
||||
|
||||
export function SidebarToggle() {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-2 hidden size-9 p-0 lg:flex"
|
||||
onClick={() => {
|
||||
toggleSidebar()
|
||||
}}
|
||||
>
|
||||
<IconSidebar className="size-6" />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { useSidebar } from '@/lib/hooks/use-sidebar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SidebarProps extends React.ComponentProps<'div'> {}
|
||||
|
||||
export function Sidebar({ className, children }: SidebarProps) {
|
||||
const { isSidebarOpen, isLoading } = useSidebar()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={isSidebarOpen && !isLoading ? 'open' : 'closed'}
|
||||
className={cn(className, 'h-full flex-col dark:bg-zinc-950')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useFormState, useFormStatus } from 'react-dom'
|
||||
import { signup } from '@/app/signup/actions'
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconSpinner } from './ui/icons'
|
||||
import { getMessageFromCode } from '@/lib/utils'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function SignupForm() {
|
||||
const router = useRouter()
|
||||
const [result, dispatch] = useFormState(signup, undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
if (result.type === 'error') {
|
||||
toast.error(getMessageFromCode(result.resultCode))
|
||||
} else {
|
||||
toast.success(getMessageFromCode(result.resultCode))
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
}, [result, router])
|
||||
|
||||
return (
|
||||
<form
|
||||
action={dispatch}
|
||||
className="flex flex-col items-center gap-4 space-y-3"
|
||||
>
|
||||
<div className="w-full flex-1 rounded-lg border bg-white px-6 pb-4 pt-8 shadow-md md:w-96 dark:bg-zinc-950">
|
||||
<h1 className="mb-3 text-2xl font-bold">Sign up for an account!</h1>
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-zinc-400"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="peer block w-full rounded-md border bg-zinc-50 px-2 py-[9px] text-sm outline-none placeholder:text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-zinc-400"
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="peer block w-full rounded-md border bg-zinc-50 px-2 py-[9px] text-sm outline-none placeholder:text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Enter password"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LoginButton />
|
||||
</div>
|
||||
|
||||
<Link href="/login" className="flex flex-row gap-1 text-sm text-zinc-400">
|
||||
Already have an account?
|
||||
<div className="font-semibold underline">Log in</div>
|
||||
</Link>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginButton() {
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
return (
|
||||
<button
|
||||
className="my-4 flex h-10 w-full flex-row items-center justify-center rounded-md bg-zinc-900 p-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
aria-disabled={pending}
|
||||
>
|
||||
{pending ? <IconSpinner /> : 'Create account'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
const placeholderEvents = [
|
||||
{
|
||||
date: '2022-10-01',
|
||||
headline: 'NVIDIA releases new AI-powered graphics card',
|
||||
description:
|
||||
'NVIDIA unveils the latest graphics card infused with AI capabilities, revolutionizing gaming and rendering experiences.'
|
||||
}
|
||||
]
|
||||
|
||||
export const EventsSkeleton = () => {
|
||||
return (
|
||||
<div className="-mt-2 flex w-full flex-col gap-2 py-4">
|
||||
{placeholderEvents.map(event => (
|
||||
<div
|
||||
key={event.date}
|
||||
className="flex shrink-0 flex-col gap-1 rounded-lg bg-zinc-800 p-4"
|
||||
>
|
||||
<div className="w-fit rounded-md bg-zinc-700 text-sm text-transparent">
|
||||
{event.date}
|
||||
</div>
|
||||
<div className="w-fit rounded-md bg-zinc-700 text-transparent">
|
||||
{event.headline}
|
||||
</div>
|
||||
<div className="w-auto rounded-md bg-zinc-700 text-transparent">
|
||||
{event.description.slice(0, 70)}...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { format, parseISO } from 'lib/utils'
|
||||
|
||||
interface Event {
|
||||
date: string
|
||||
headline: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function Events({ props: events }: { props: Event[] }) {
|
||||
return (
|
||||
<div className="-mt-2 flex w-full flex-col gap-2 py-4">
|
||||
{events.map(event => (
|
||||
<div
|
||||
key={event.date}
|
||||
className="flex shrink-0 flex-col gap-1 rounded-lg bg-zinc-800 p-4"
|
||||
>
|
||||
<div className="text-sm text-zinc-400">
|
||||
{format(parseISO(event.date), 'dd LLL, yyyy')}
|
||||
</div>
|
||||
<div className="text-base font-bold text-zinc-200">
|
||||
{event.headline}
|
||||
</div>
|
||||
<div className="text-zinc-500">
|
||||
{event.description.slice(0, 70)}...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { StockSkeleton } from './stock-skeleton'
|
||||
import { StocksSkeleton } from './stocks-skeleton'
|
||||
import { EventsSkeleton } from './events-skeleton'
|
||||
|
||||
export { spinner } from './spinner'
|
||||
export { BotCard, BotMessage, SystemMessage } from './message'
|
||||
|
||||
const Stock = dynamic(() => import('./stock').then(mod => mod.Stock), {
|
||||
ssr: false,
|
||||
loading: () => <StockSkeleton />
|
||||
})
|
||||
|
||||
const Purchase = dynamic(
|
||||
() => import('./stock-purchase').then(mod => mod.Purchase),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-[375px] rounded-xl border bg-zinc-950 p-4 text-green-400 sm:h-[314px]" />
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const Stocks = dynamic(() => import('./stocks').then(mod => mod.Stocks), {
|
||||
ssr: false,
|
||||
loading: () => <StocksSkeleton />
|
||||
})
|
||||
|
||||
const Events = dynamic(() => import('./events').then(mod => mod.Events), {
|
||||
ssr: false,
|
||||
loading: () => <EventsSkeleton />
|
||||
})
|
||||
|
||||
export { Stock, Purchase, Stocks, Events }
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { IconOpenAI, IconUser } from '@/components/ui/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { spinner } from './spinner'
|
||||
import { CodeBlock } from '../ui/codeblock'
|
||||
import { MemoizedReactMarkdown } from '../markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { StreamableValue } from 'ai/rsc'
|
||||
import { useStreamableText } from '@/lib/hooks/use-streamable-text'
|
||||
|
||||
// Different types of message bubbles.
|
||||
|
||||
export function UserMessage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="group relative flex items-start md:-ml-12">
|
||||
<div className="flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border bg-background shadow-sm">
|
||||
<IconUser />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden pl-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BotMessage({
|
||||
content,
|
||||
className
|
||||
}: {
|
||||
content: string | StreamableValue<string>
|
||||
className?: string
|
||||
}) {
|
||||
const text = useStreamableText(content)
|
||||
|
||||
return (
|
||||
<div className={cn('group relative flex items-start md:-ml-12', className)}>
|
||||
<div className="flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm">
|
||||
<IconOpenAI />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
<MemoizedReactMarkdown
|
||||
className="prose break-words dark:prose-invert prose-p:leading-relaxed prose-pre:p-0"
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={{
|
||||
p({ children }) {
|
||||
return <p className="mb-2 last:mb-0">{children}</p>
|
||||
},
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
if (children.length) {
|
||||
if (children[0] == '▍') {
|
||||
return (
|
||||
<span className="mt-1 animate-pulse cursor-default">▍</span>
|
||||
)
|
||||
}
|
||||
|
||||
children[0] = (children[0] as string).replace('`▍`', '▍')
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={(match && match[1]) || ''}
|
||||
value={String(children).replace(/\n$/, '')}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</MemoizedReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BotCard({
|
||||
children,
|
||||
showAvatar = true
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
showAvatar?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative flex items-start md:-ml-12">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm',
|
||||
!showAvatar && 'invisible'
|
||||
)}
|
||||
>
|
||||
<IconOpenAI />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 pl-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SystemMessage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'mt-2 flex items-center justify-center gap-2 text-xs text-gray-500'
|
||||
}
|
||||
>
|
||||
<div className={'max-w-[600px] flex-initial p-2'}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpinnerMessage() {
|
||||
return (
|
||||
<div className="group relative flex items-start md:-ml-12">
|
||||
<div className="flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm">
|
||||
<IconOpenAI />
|
||||
</div>
|
||||
<div className="ml-4 h-[24px] flex flex-row items-center flex-1 space-y-2 overflow-hidden px-1">
|
||||
{spinner}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
'use client'
|
||||
|
||||
export const spinner = (
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-5 animate-spin stroke-zinc-400"
|
||||
>
|
||||
<path d="M12 3v3m6.366-.366-2.12 2.12M21 12h-3m.366 6.366-2.12-2.12M12 21v-3m-6.366.366 2.12-2.12M3 12h3m-.366-6.366 2.12 2.12"></path>
|
||||
</svg>
|
||||
)
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import { useActions, useAIState, useUIState } from 'ai/rsc'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
|
||||
import type { AI } from '@/lib/chat/actions'
|
||||
|
||||
interface Purchase {
|
||||
numberOfShares?: number
|
||||
symbol: string
|
||||
price: number
|
||||
status: 'requires_action' | 'completed' | 'expired'
|
||||
}
|
||||
|
||||
export function Purchase({
|
||||
props: { numberOfShares, symbol, price, status = 'expired' }
|
||||
}: {
|
||||
props: Purchase
|
||||
}) {
|
||||
const [value, setValue] = useState(numberOfShares || 100)
|
||||
const [purchasingUI, setPurchasingUI] = useState<null | React.ReactNode>(null)
|
||||
const [aiState, setAIState] = useAIState<typeof AI>()
|
||||
const [, setMessages] = useUIState<typeof AI>()
|
||||
const { confirmPurchase } = useActions()
|
||||
|
||||
// Unique identifier for this UI component.
|
||||
const id = useId()
|
||||
|
||||
// Whenever the slider changes, we need to update the local value state and the history
|
||||
// so LLM also knows what's going on.
|
||||
function onSliderChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const newValue = Number(e.target.value)
|
||||
setValue(newValue)
|
||||
|
||||
// Insert a hidden history info to the list.
|
||||
const message = {
|
||||
role: 'system' as const,
|
||||
content: `[User has changed to purchase ${newValue} shares of ${name}. Total cost: $${(
|
||||
newValue * price
|
||||
).toFixed(2)}]`,
|
||||
|
||||
// Identifier of this UI component, so we don't insert it many times.
|
||||
id
|
||||
}
|
||||
|
||||
// If last history state is already this info, update it. This is to avoid
|
||||
// adding every slider change to the history.
|
||||
if (aiState.messages[aiState.messages.length - 1]?.id === id) {
|
||||
setAIState({
|
||||
...aiState,
|
||||
messages: [...aiState.messages.slice(0, -1), message]
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If it doesn't exist, append it to history.
|
||||
setAIState({ ...aiState, messages: [...aiState.messages, message] })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 text-green-400 border rounded-xl bg-zinc-950">
|
||||
<div className="inline-block float-right px-2 py-1 text-xs rounded-full bg-white/10">
|
||||
+1.23% ↑
|
||||
</div>
|
||||
<div className="text-lg text-zinc-300">{symbol}</div>
|
||||
<div className="text-3xl font-bold">${price}</div>
|
||||
{purchasingUI ? (
|
||||
<div className="mt-4 text-zinc-200">{purchasingUI}</div>
|
||||
) : status === 'requires_action' ? (
|
||||
<>
|
||||
<div className="relative pb-6 mt-6">
|
||||
<p>Shares to purchase</p>
|
||||
<input
|
||||
id="labels-range-input"
|
||||
type="range"
|
||||
value={value}
|
||||
onChange={onSliderChange}
|
||||
min="10"
|
||||
max="1000"
|
||||
className="w-full h-1 rounded-lg appearance-none cursor-pointer bg-zinc-600 accent-green-500 dark:bg-zinc-700"
|
||||
/>
|
||||
<span className="absolute text-xs bottom-1 start-0 text-zinc-400">
|
||||
10
|
||||
</span>
|
||||
<span className="absolute text-xs -translate-x-1/2 bottom-1 start-1/3 text-zinc-400 rtl:translate-x-1/2">
|
||||
100
|
||||
</span>
|
||||
<span className="absolute text-xs -translate-x-1/2 bottom-1 start-2/3 text-zinc-400 rtl:translate-x-1/2">
|
||||
500
|
||||
</span>
|
||||
<span className="absolute text-xs bottom-1 end-0 text-zinc-400">
|
||||
1000
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p>Total cost</p>
|
||||
<div className="flex flex-wrap items-center text-xl font-bold sm:items-end sm:gap-2 sm:text-3xl">
|
||||
<div className="flex flex-col basis-1/3 tabular-nums sm:basis-auto sm:flex-row sm:items-center sm:gap-2">
|
||||
{value}
|
||||
<span className="mb-1 text-sm font-normal text-zinc-600 sm:mb-0 dark:text-zinc-400">
|
||||
shares
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-center basis-1/3 sm:basis-auto">×</div>
|
||||
<span className="flex flex-col basis-1/3 tabular-nums sm:basis-auto sm:flex-row sm:items-center sm:gap-2">
|
||||
${price}
|
||||
<span className="mb-1 ml-1 text-sm font-normal text-zinc-600 sm:mb-0 dark:text-zinc-400">
|
||||
per share
|
||||
</span>
|
||||
</span>
|
||||
<div className="pt-2 mt-2 text-center border-t basis-full border-t-zinc-700 sm:mt-0 sm:basis-auto sm:border-0 sm:pt-0 sm:text-left">
|
||||
= <span>{formatNumber(value * price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full px-4 py-2 mt-6 font-bold bg-green-400 rounded-lg text-zinc-900 hover:bg-green-500"
|
||||
onClick={async () => {
|
||||
const response = await confirmPurchase(symbol, price, value)
|
||||
setPurchasingUI(response.purchasingUI)
|
||||
|
||||
// Insert a new system message to the UI.
|
||||
setMessages((currentMessages: any) => [
|
||||
...currentMessages,
|
||||
response.newMessage
|
||||
])
|
||||
}}
|
||||
>
|
||||
Purchase
|
||||
</button>
|
||||
</>
|
||||
) : status === 'completed' ? (
|
||||
<p className="mb-2 text-white">
|
||||
You have successfully purchased {value} ${symbol}. Total cost:{' '}
|
||||
{formatNumber(value * price)}
|
||||
</p>
|
||||
) : status === 'expired' ? (
|
||||
<p className="mb-2 text-white">Your checkout session has expired!</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
export const StockSkeleton = () => {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-950 p-4 text-green-400">
|
||||
<div className="float-right inline-block w-fit rounded-full bg-zinc-700 px-2 py-1 text-xs text-transparent">
|
||||
xxxxxxx
|
||||
</div>
|
||||
<div className="mb-1 w-fit rounded-md bg-zinc-700 text-lg text-transparent">
|
||||
xxxx
|
||||
</div>
|
||||
<div className="w-fit rounded-md bg-zinc-700 text-3xl font-bold text-transparent">
|
||||
xxxx
|
||||
</div>
|
||||
<div className="text mt-1 w-fit rounded-md bg-zinc-700 text-xs text-transparent">
|
||||
xxxxxx xxx xx xxxx xx xxx
|
||||
</div>
|
||||
|
||||
<div className="relative -mx-4 cursor-col-resize">
|
||||
<div style={{ height: 146 }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useId } from 'react'
|
||||
import { subMonths, format } from 'lib/utils'
|
||||
import { useAIState } from 'ai/rsc'
|
||||
|
||||
interface Stock {
|
||||
symbol: string
|
||||
price: number
|
||||
delta: number
|
||||
}
|
||||
|
||||
function scaleLinear(domain: [number, number], range: [number, number]) {
|
||||
const [d0, d1] = domain
|
||||
const [r0, r1] = range
|
||||
|
||||
return function scale(value: number): number {
|
||||
return r0 + ((value - d0) / (d1 - d0)) * (r1 - r0)
|
||||
}
|
||||
}
|
||||
|
||||
function useResizeObserver<T extends HTMLElement = HTMLElement>(
|
||||
ref: React.RefObject<T>
|
||||
) {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
||||
setSize({
|
||||
width: entry.borderBoxSize[0].inlineSize,
|
||||
height: entry.borderBoxSize[0].blockSize
|
||||
})
|
||||
} else {
|
||||
// Fallback for browsers that don't support borderBoxSize
|
||||
const { width, height } = entry.target.getBoundingClientRect()
|
||||
setSize({ width, height })
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(ref.current)
|
||||
return () => observer.disconnect()
|
||||
}, [ref])
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
export function Stock({ props: { symbol, price, delta } }: { props: Stock }) {
|
||||
const [aiState, setAIState] = useAIState()
|
||||
const id = useId()
|
||||
|
||||
const [priceAtTime, setPriceAtTime] = useState({
|
||||
time: '00:00',
|
||||
value: price.toFixed(2),
|
||||
x: 0
|
||||
})
|
||||
|
||||
const [startHighlight, setStartHighlight] = useState(0)
|
||||
const [endHighlight, setEndHighlight] = useState(0)
|
||||
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const { width = 0 } = useResizeObserver(chartRef)
|
||||
|
||||
const xToDate = scaleLinear(
|
||||
[0, width],
|
||||
[subMonths(new Date(), 6).getTime(), new Date().getTime()]
|
||||
)
|
||||
|
||||
const xToValue = scaleLinear(
|
||||
[0, width],
|
||||
[price - price / 2, price + price / 2]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (startHighlight && endHighlight) {
|
||||
const message = {
|
||||
id,
|
||||
role: 'system' as const,
|
||||
content: `[User has highlighted dates between between ${format(
|
||||
new Date(xToDate(startHighlight)),
|
||||
'd LLL'
|
||||
)} and ${format(new Date(xToDate(endHighlight)), 'd LLL, yyyy')}`
|
||||
}
|
||||
|
||||
if (aiState.messages[aiState.messages.length - 1]?.id === id) {
|
||||
setAIState({
|
||||
...aiState,
|
||||
messages: [...aiState.messages.slice(0, -1), message]
|
||||
})
|
||||
} else {
|
||||
setAIState({
|
||||
...aiState,
|
||||
messages: [...aiState.messages, message]
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [startHighlight, endHighlight])
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-zinc-950 p-4 text-green-400">
|
||||
<div className="float-right inline-block rounded-full bg-white/10 px-2 py-1 text-xs">
|
||||
{`${delta > 0 ? '+' : ''}${((delta / price) * 100).toFixed(2)}% ${
|
||||
delta > 0 ? '↑' : '↓'
|
||||
}`}
|
||||
</div>
|
||||
<div className="text-lg text-zinc-300">{symbol}</div>
|
||||
<div className="text-3xl font-bold">${price}</div>
|
||||
<div className="text mt-1 text-xs text-zinc-500">
|
||||
Closed: Feb 27, 4:59 PM EST
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative -mx-4 cursor-col-resize"
|
||||
onPointerDown={event => {
|
||||
if (chartRef.current) {
|
||||
const { clientX } = event
|
||||
const { left } = chartRef.current.getBoundingClientRect()
|
||||
|
||||
setStartHighlight(clientX - left)
|
||||
setEndHighlight(0)
|
||||
|
||||
setPriceAtTime({
|
||||
time: format(new Date(xToDate(clientX)), 'dd LLL yy'),
|
||||
value: xToValue(clientX).toFixed(2),
|
||||
x: clientX - left
|
||||
})
|
||||
}
|
||||
}}
|
||||
onPointerUp={event => {
|
||||
if (chartRef.current) {
|
||||
const { clientX } = event
|
||||
const { left } = chartRef.current.getBoundingClientRect()
|
||||
|
||||
setEndHighlight(clientX - left)
|
||||
}
|
||||
}}
|
||||
onPointerMove={event => {
|
||||
if (chartRef.current) {
|
||||
const { clientX } = event
|
||||
const { left } = chartRef.current.getBoundingClientRect()
|
||||
|
||||
setPriceAtTime({
|
||||
time: format(new Date(xToDate(clientX)), 'dd LLL yy'),
|
||||
value: xToValue(clientX).toFixed(2),
|
||||
x: clientX - left
|
||||
})
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setPriceAtTime({
|
||||
time: '00:00',
|
||||
value: price.toFixed(2),
|
||||
x: 0
|
||||
})
|
||||
}}
|
||||
ref={chartRef}
|
||||
>
|
||||
{priceAtTime.x > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 flex w-fit select-none gap-2 rounded-md bg-zinc-800 p-2"
|
||||
style={{
|
||||
left: priceAtTime.x - 124 / 2,
|
||||
top: 30
|
||||
}}
|
||||
>
|
||||
<div className="text-xs tabular-nums">${priceAtTime.value}</div>
|
||||
<div className="text-xs tabular-nums text-zinc-400">
|
||||
{priceAtTime.time}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{startHighlight ? (
|
||||
<div
|
||||
className="pointer-events-none absolute h-32 w-5 select-none rounded-md border border-zinc-500 bg-zinc-500/20"
|
||||
style={{
|
||||
left: startHighlight,
|
||||
width: endHighlight
|
||||
? endHighlight - startHighlight
|
||||
: priceAtTime.x - startHighlight,
|
||||
bottom: 0
|
||||
}}
|
||||
></div>
|
||||
) : null}
|
||||
|
||||
<svg
|
||||
viewBox="0 0 250.0 168.0"
|
||||
height="150"
|
||||
width="100%"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="fill-id-tsuid_31"
|
||||
x1="0%"
|
||||
x2="0%"
|
||||
y1="0%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="#34a853" stopOpacity="0.38"></stop>
|
||||
<stop offset="13%" stopColor="#e6f4ea" stopOpacity="0"></stop>
|
||||
</linearGradient>
|
||||
<clipPath id="range-id-tsuid_31">
|
||||
<rect height="100%" width="0" x="0" y="0"></rect>
|
||||
</clipPath>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="chart-grad-_f1bJZYLUHqWpxc8Prs2meA_33"
|
||||
x1="0%"
|
||||
x2="0%"
|
||||
y1="0%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="#34a853" stopOpacity="0.38"></stop>
|
||||
<stop offset="13%" stopColor="#e6f4ea" stopOpacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<clipPath id="mask-_f1bJZYLUHqWpxc8Prs2meA_32">
|
||||
<rect height="218" width="250" x="0" y="-5"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path
|
||||
d="M 0 42.86 L 0.89 46.26 L 1.78 44.3 L 2.68 44.24 L 3.57 42 L 4.46 43.42 L 5.35 43.62 L 6.25 47 L 7.14 47.65 L 8.03 47.69 L 8.92 45.55 L 9.82 43.19 L 10.71 43.9 L 11.6 42.83 L 12.49 42.81 L 13.39 46.75 L 14.28 43.06 L 15.17 40.8 L 16.06 39.72 L 16.96 39.77 L 17.85 45.77 L 18.74 44.93 L 19.63 44.35 L 20.53 40.29 L 21.42 42.77 L 22.31 42.12 L 23.2 43.4 L 24.1 47.95 L 24.99 50.15 L 25.88 48.59 L 26.77 42.18 L 27.67 44.1 L 28.56 39.91 L 29.45 44.92 L 30.34 47.62 L 31.24 48.06 L 32.13 47.67 L 33.02 56.47 L 33.91 57.74 L 34.8 65.48 L 35.7 64.47 L 36.59 47.25 L 37.48 58.26 L 38.37 52.04 L 39.27 55.8 L 40.16 92.92 L 41.05 105.2 L 41.94 102 L 42.84 106.14 L 43.73 78.71 L 44.62 104.6 L 45.51 96.58 L 46.41 67.56 L 47.3 69.53 L 48.19 69.99 L 49.08 66.75 L 49.98 69.72 L 50.87 70.13 L 51.76 71.3 L 52.65 70.03 L 53.55 67.92 L 54.44 66.41 L 55.33 97.12 L 56.22 95.93 L 57.12 95.03 L 58.01 95.09 L 58.9 65.56 L 59.79 65.12 L 60.69 82.42 L 61.58 74.7 L 62.47 71.13 L 63.36 82.43 L 64.26 96.02 L 65.15 100.36 L 66.04 98.6 L 66.93 103.37 L 67.82 102.12 L 68.72 97.08 L 69.61 89.74 L 70.5 90.7 L 71.39 93.46 L 72.29 94.24 L 73.18 97.8 L 74.07 97.88 L 74.96 96.63 L 75.86 96.27 L 76.75 97.15 L 77.64 100.12 L 78.53 100.51 L 79.43 106.59 L 80.32 104.54 L 81.21 100.31 L 82.1 118.76 L 83 106.24 L 83.89 114.8 L 84.78 174.89 L 85.67 122.28 L 86.57 149.25 L 87.46 151.47 L 88.35 153.38 L 89.24 153.5 L 90.14 149.24 L 91.03 122.44 L 91.92 122.08 L 92.81 147.16 L 93.71 147.46 L 94.6 119.13 L 95.49 117.97 L 96.38 122.22 L 97.28 116.38 L 98.17 119.53 L 99.06 119.65 L 99.95 120.15 L 100.84 120.22 L 101.74 121.28 L 102.63 121.4 L 103.52 122.97 L 104.41 122.15 L 105.31 120.6 L 106.2 116.55 L 107.09 122.23 L 107.98 120.96 L 108.88 119.54 L 109.77 120.19 L 110.66 120.99 L 111.55 119.92 L 112.45 115.69 L 113.34 116.33 L 114.23 116.07 L 115.12 115.34 L 116.02 111.34 L 116.91 107.23 L 117.8 113.21 L 118.69 98.77 L 119.59 97.04 L 120.48 96.56 L 121.37 96.36 L 122.26 99.7 L 123.16 103.33 L 124.05 100.38 L 124.94 99.68 L 125.83 99.02 L 126.73 102.56 L 127.62 103.25 L 128.51 103.38 L 129.4 104.89 L 130.3 118.07 L 131.19 100.82 L 132.08 103.06 L 132.97 103.47 L 133.86 99.8 L 134.76 111.28 L 135.65 107.73 L 136.54 107.46 L 137.43 108.08 L 138.33 109.82 L 139.22 110.94 L 140.11 111.3 L 141 108.14 L 141.9 109.35 L 142.79 108.38 L 143.68 99.08 L 144.57 99.02 L 145.47 98.61 L 146.36 99.07 L 147.25 99.26 L 148.14 95.1 L 149.04 92.08 L 149.93 92.76 L 150.82 92.87 L 151.71 83.31 L 152.61 82.93 L 153.5 84.86 L 154.39 84.12 L 155.28 94.08 L 156.18 93.31 L 157.07 94.23 L 157.96 94.58 L 158.85 99.33 L 159.75 80 L 160.64 90.28 L 161.53 84.07 L 162.42 68.37 L 163.32 76.88 L 164.21 81.78 L 165.1 80.72 L 165.99 73.89 L 166.88 77.14 L 167.78 67.58 L 168.67 59.82 L 169.56 61.91 L 170.45 61.07 L 171.35 73.74 L 172.24 77.02 L 173.13 78.61 L 174.02 71.59 L 174.92 68.24 L 175.81 72.14 L 176.7 65.37 L 177.59 76.73 L 178.49 88.02 L 179.38 88.01 L 180.27 88.27 L 181.16 86.23 L 182.06 86.14 L 182.95 89.54 L 183.84 94.16 L 184.73 97.72 L 185.63 81.52 L 186.52 92.85 L 187.41 94.14 L 188.3 93.06 L 189.2 92.64 L 190.09 92.44 L 190.98 91.75 L 191.87 90.53 L 192.77 88.27 L 193.66 85.44 L 194.55 82.26 L 195.44 85.08 L 196.34 85.65 L 197.23 53.43 L 198.12 72.01 L 199.01 38.37 L 199.9 69.43 L 200.8 74.46 L 201.69 74.22 L 202.58 82.46 L 203.47 77.01 L 204.37 87.8 L 205.26 91.56 L 206.15 76.69 L 207.04 76.46 L 207.94 78.13 L 208.83 80.06 L 209.72 92.79 L 210.61 87.74 L 211.51 88.21 L 212.4 88.47 L 213.29 87.35 L 214.18 89.69 L 215.08 77.37 L 215.97 87.95 L 216.86 75.16 L 217.75 70.47 L 218.65 85.11 L 219.54 88.1 L 220.43 88.06 L 221.32 86.34 L 222.22 76.91 L 223.11 75.33 L 224 73.6 L 224.89 25.31 L 225.79 44.14 L 226.68 43.93 L 227.57 45.13 L 228.46 44.03 L 229.36 35.73 L 230.25 33.65 L 231.14 34.81 L 232.03 17.64 L 232.92 21.13 L 233.82 19.37 L 234.71 24.66 L 235.6 23.87 L 236.49 22.56 L 237.39 28.48 L 238.28 25.33 L 239.17 28.51 L 240.06 30.83 L 240.96 35.79 L 241.85 34.6 L 242.74 31.2 L 243.63 32.97 L 244.53 33.01 L 245.42 31.38 L 246.31 30.21 L 247.2 27.75 L 248.1 25.27 L 248.99 23 L 249.88 23 L 250 23 L 2000 0 L 2000 1000 L -1000 1000"
|
||||
clipPath="url(#range-id-tsuid_31)"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
stroke="none"
|
||||
strokeWidth={2}
|
||||
fill='url("#fill-id-tsuid_31")'
|
||||
></path>
|
||||
<path
|
||||
clipPath="url(#mask-_f1bJZYLUHqWpxc8Prs2meA_32)"
|
||||
d="M 0 42.86 L 0.89 46.26 L 1.78 44.3 L 2.68 44.24 L 3.57 42 L 4.46 43.42 L 5.35 43.62 L 6.25 47 L 7.14 47.65 L 8.03 47.69 L 8.92 45.55 L 9.82 43.19 L 10.71 43.9 L 11.6 42.83 L 12.49 42.81 L 13.39 46.75 L 14.28 43.06 L 15.17 40.8 L 16.06 39.72 L 16.96 39.77 L 17.85 45.77 L 18.74 44.93 L 19.63 44.35 L 20.53 40.29 L 21.42 42.77 L 22.31 42.12 L 23.2 43.4 L 24.1 47.95 L 24.99 50.15 L 25.88 48.59 L 26.77 42.18 L 27.67 44.1 L 28.56 39.91 L 29.45 44.92 L 30.34 47.62 L 31.24 48.06 L 32.13 47.67 L 33.02 56.47 L 33.91 57.74 L 34.8 65.48 L 35.7 64.47 L 36.59 47.25 L 37.48 58.26 L 38.37 52.04 L 39.27 55.8 L 40.16 92.92 L 41.05 105.2 L 41.94 102 L 42.84 106.14 L 43.73 78.71 L 44.62 104.6 L 45.51 96.58 L 46.41 67.56 L 47.3 69.53 L 48.19 69.99 L 49.08 66.75 L 49.98 69.72 L 50.87 70.13 L 51.76 71.3 L 52.65 70.03 L 53.55 67.92 L 54.44 66.41 L 55.33 97.12 L 56.22 95.93 L 57.12 95.03 L 58.01 95.09 L 58.9 65.56 L 59.79 65.12 L 60.69 82.42 L 61.58 74.7 L 62.47 71.13 L 63.36 82.43 L 64.26 96.02 L 65.15 100.36 L 66.04 98.6 L 66.93 103.37 L 67.82 102.12 L 68.72 97.08 L 69.61 89.74 L 70.5 90.7 L 71.39 93.46 L 72.29 94.24 L 73.18 97.8 L 74.07 97.88 L 74.96 96.63 L 75.86 96.27 L 76.75 97.15 L 77.64 100.12 L 78.53 100.51 L 79.43 106.59 L 80.32 104.54 L 81.21 100.31 L 82.1 118.76 L 83 106.24 L 83.89 114.8 L 84.78 174.89 L 85.67 122.28 L 86.57 149.25 L 87.46 151.47 L 88.35 153.38 L 89.24 153.5 L 90.14 149.24 L 91.03 122.44 L 91.92 122.08 L 92.81 147.16 L 93.71 147.46 L 94.6 119.13 L 95.49 117.97 L 96.38 122.22 L 97.28 116.38 L 98.17 119.53 L 99.06 119.65 L 99.95 120.15 L 100.84 120.22 L 101.74 121.28 L 102.63 121.4 L 103.52 122.97 L 104.41 122.15 L 105.31 120.6 L 106.2 116.55 L 107.09 122.23 L 107.98 120.96 L 108.88 119.54 L 109.77 120.19 L 110.66 120.99 L 111.55 119.92 L 112.45 115.69 L 113.34 116.33 L 114.23 116.07 L 115.12 115.34 L 116.02 111.34 L 116.91 107.23 L 117.8 113.21 L 118.69 98.77 L 119.59 97.04 L 120.48 96.56 L 121.37 96.36 L 122.26 99.7 L 123.16 103.33 L 124.05 100.38 L 124.94 99.68 L 125.83 99.02 L 126.73 102.56 L 127.62 103.25 L 128.51 103.38 L 129.4 104.89 L 130.3 118.07 L 131.19 100.82 L 132.08 103.06 L 132.97 103.47 L 133.86 99.8 L 134.76 111.28 L 135.65 107.73 L 136.54 107.46 L 137.43 108.08 L 138.33 109.82 L 139.22 110.94 L 140.11 111.3 L 141 108.14 L 141.9 109.35 L 142.79 108.38 L 143.68 99.08 L 144.57 99.02 L 145.47 98.61 L 146.36 99.07 L 147.25 99.26 L 148.14 95.1 L 149.04 92.08 L 149.93 92.76 L 150.82 92.87 L 151.71 83.31 L 152.61 82.93 L 153.5 84.86 L 154.39 84.12 L 155.28 94.08 L 156.18 93.31 L 157.07 94.23 L 157.96 94.58 L 158.85 99.33 L 159.75 80 L 160.64 90.28 L 161.53 84.07 L 162.42 68.37 L 163.32 76.88 L 164.21 81.78 L 165.1 80.72 L 165.99 73.89 L 166.88 77.14 L 167.78 67.58 L 168.67 59.82 L 169.56 61.91 L 170.45 61.07 L 171.35 73.74 L 172.24 77.02 L 173.13 78.61 L 174.02 71.59 L 174.92 68.24 L 175.81 72.14 L 176.7 65.37 L 177.59 76.73 L 178.49 88.02 L 179.38 88.01 L 180.27 88.27 L 181.16 86.23 L 182.06 86.14 L 182.95 89.54 L 183.84 94.16 L 184.73 97.72 L 185.63 81.52 L 186.52 92.85 L 187.41 94.14 L 188.3 93.06 L 189.2 92.64 L 190.09 92.44 L 190.98 91.75 L 191.87 90.53 L 192.77 88.27 L 193.66 85.44 L 194.55 82.26 L 195.44 85.08 L 196.34 85.65 L 197.23 53.43 L 198.12 72.01 L 199.01 38.37 L 199.9 69.43 L 200.8 74.46 L 201.69 74.22 L 202.58 82.46 L 203.47 77.01 L 204.37 87.8 L 205.26 91.56 L 206.15 76.69 L 207.04 76.46 L 207.94 78.13 L 208.83 80.06 L 209.72 92.79 L 210.61 87.74 L 211.51 88.21 L 212.4 88.47 L 213.29 87.35 L 214.18 89.69 L 215.08 77.37 L 215.97 87.95 L 216.86 75.16 L 217.75 70.47 L 218.65 85.11 L 219.54 88.1 L 220.43 88.06 L 221.32 86.34 L 222.22 76.91 L 223.11 75.33 L 224 73.6 L 224.89 25.31 L 225.79 44.14 L 226.68 43.93 L 227.57 45.13 L 228.46 44.03 L 229.36 35.73 L 230.25 33.65 L 231.14 34.81 L 232.03 17.64 L 232.92 21.13 L 233.82 19.37 L 234.71 24.66 L 235.6 23.87 L 236.49 22.56 L 237.39 28.48 L 238.28 25.33 L 239.17 28.51 L 240.06 30.83 L 240.96 35.79 L 241.85 34.6 L 242.74 31.2 L 243.63 32.97 L 244.53 33.01 L 245.42 31.38 L 246.31 30.21 L 247.2 27.75 L 248.1 25.27 L 248.99 23 L 249.88 23 L 250 23 L 2000 0 L 2000 1000 L -1000 1000"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
stroke="#34a853"
|
||||
style={{ fill: 'url(#chart-grad-_f1bJZYLUHqWpxc8Prs2meA_33)' }}
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export const StocksSkeleton = () => {
|
||||
return (
|
||||
<div className="mb-4 flex flex-col gap-2 overflow-y-scroll pb-4 text-sm sm:flex-row">
|
||||
<div className="flex h-[60px] w-full cursor-pointer flex-row gap-2 rounded-lg bg-zinc-800 p-2 text-left hover:bg-zinc-800 sm:w-[208px]"></div>
|
||||
<div className="flex h-[60px] w-full cursor-pointer flex-row gap-2 rounded-lg bg-zinc-800 p-2 text-left hover:bg-zinc-800 sm:w-[208px]"></div>
|
||||
<div className="flex h-[60px] w-full cursor-pointer flex-row gap-2 rounded-lg bg-zinc-800 p-2 text-left hover:bg-zinc-800 sm:w-[208px]"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useActions, useUIState } from 'ai/rsc'
|
||||
|
||||
import type { AI } from '@/lib/chat/actions'
|
||||
|
||||
interface Stock {
|
||||
symbol: string
|
||||
price: number
|
||||
delta: number
|
||||
}
|
||||
|
||||
export function Stocks({ props: stocks }: { props: Stock[] }) {
|
||||
const [, setMessages] = useUIState<typeof AI>()
|
||||
const { submitUserMessage } = useActions()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-2 overflow-y-scroll pb-4 text-sm sm:flex-row">
|
||||
{stocks.map(stock => (
|
||||
<button
|
||||
key={stock.symbol}
|
||||
className="flex cursor-pointer flex-row gap-2 rounded-lg bg-zinc-800 p-2 text-left hover:bg-zinc-700 sm:w-52"
|
||||
onClick={async () => {
|
||||
const response = await submitUserMessage(`View ${stock.symbol}`)
|
||||
setMessages(currentMessages => [...currentMessages, response])
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`text-xl ${
|
||||
stock.delta > 0 ? 'text-green-600' : 'text-red-600'
|
||||
} flex w-11 flex-row justify-center rounded-md bg-white/10 p-2`}
|
||||
>
|
||||
{stock.delta > 0 ? '↑' : '↓'}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="bold uppercase text-zinc-300">{stock.symbol}</div>
|
||||
<div className="text-base text-zinc-500">
|
||||
${stock.price.toExponential(1)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col">
|
||||
<div
|
||||
className={`${
|
||||
stock.delta > 0 ? 'text-green-600' : 'text-red-600'
|
||||
} bold text-right uppercase`}
|
||||
>
|
||||
{` ${((stock.delta / stock.price) * 100).toExponential(1)}%`}
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
stock.delta > 0 ? 'text-green-700' : 'text-red-700'
|
||||
} text-right text-base`}
|
||||
>
|
||||
{stock.delta.toExponential(1)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 text-center text-sm text-zinc-500">
|
||||
Note: Data and latency are simulated for illustrative purposes and
|
||||
should not be considered as financial advice.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
export function TailwindIndicator() {
|
||||
if (process.env.NODE_ENV === 'production') return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-1 left-1 z-50 flex size-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white">
|
||||
<div className="block sm:hidden">xs</div>
|
||||
<div className="hidden sm:block md:hidden">sm</div>
|
||||
<div className="hidden md:block lg:hidden">md</div>
|
||||
<div className="hidden lg:block xl:hidden">lg</div>
|
||||
<div className="hidden xl:block 2xl:hidden">xl</div>
|
||||
<div className="hidden 2xl:block">2xl</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IconMoon, IconSun } from '@/components/ui/icons'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
const [_, startTransition] = React.useTransition()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
})
|
||||
}}
|
||||
>
|
||||
{!theme ? null : theme === 'dark' ? (
|
||||
<IconMoon className="transition-all" />
|
||||
) : (
|
||||
<IconSun className="transition-all" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
|
|
@ -19,13 +19,13 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
|
|
@ -37,13 +37,13 @@ const AlertDialogContent = React.forwardRef<
|
|||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
|
|
@ -52,12 +52,12 @@ const AlertDialogHeader = ({
|
|||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
|
|
@ -66,12 +66,12 @@ const AlertDialogFooter = ({
|
|||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
|
|
@ -82,8 +82,8 @@ const AlertDialogTitle = React.forwardRef<
|
|||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
|
|
@ -94,9 +94,9 @@ const AlertDialogDescription = React.forwardRef<
|
|||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
|
|
@ -107,8 +107,8 @@ const AlertDialogAction = React.forwardRef<
|
|||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
|
|
@ -119,12 +119,12 @@ const AlertDialogCancel = React.forwardRef<
|
|||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
|
|
@ -138,4 +138,4 @@ export {
|
|||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
@ -1,36 +1,36 @@
|
|||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'size-9'
|
||||
}
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ export interface ButtonProps
|
|||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
|
|
@ -52,6 +52,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
// Inspired by Chatbot-UI and modified to fit the needs of this project
|
||||
// @see https://github.com/mckaywrigley/chatbot-ui/blob/main/components/Markdown/CodeBlock.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { FC, memo } from 'react'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { coldarkDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
|
||||
|
||||
import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
|
||||
import { IconCheck, IconCopy, IconDownload } from '@/components/ui/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Props {
|
||||
language: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface languageMap {
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
export const programmingLanguages: languageMap = {
|
||||
javascript: '.js',
|
||||
python: '.py',
|
||||
java: '.java',
|
||||
c: '.c',
|
||||
cpp: '.cpp',
|
||||
'c++': '.cpp',
|
||||
'c#': '.cs',
|
||||
ruby: '.rb',
|
||||
php: '.php',
|
||||
swift: '.swift',
|
||||
'objective-c': '.m',
|
||||
kotlin: '.kt',
|
||||
typescript: '.ts',
|
||||
go: '.go',
|
||||
perl: '.pl',
|
||||
rust: '.rs',
|
||||
scala: '.scala',
|
||||
haskell: '.hs',
|
||||
lua: '.lua',
|
||||
shell: '.sh',
|
||||
sql: '.sql',
|
||||
html: '.html',
|
||||
css: '.css'
|
||||
// add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
|
||||
}
|
||||
|
||||
export const generateRandomString = (length: number, lowercase = false) => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXY3456789' // excluding similar looking characters like Z, 2, I, 1, O, 0
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return lowercase ? result.toLowerCase() : result
|
||||
}
|
||||
|
||||
const CodeBlock: FC<Props> = memo(({ language, value }) => {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 })
|
||||
|
||||
const downloadAsFile = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
const fileExtension = programmingLanguages[language] || '.file'
|
||||
const suggestedFileName = `file-${generateRandomString(
|
||||
3,
|
||||
true
|
||||
)}${fileExtension}`
|
||||
const fileName = window.prompt('Enter file name' || '', suggestedFileName)
|
||||
|
||||
if (!fileName) {
|
||||
// User pressed cancel on prompt.
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([value], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.download = fileName
|
||||
link.href = url
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const onCopy = () => {
|
||||
if (isCopied) return
|
||||
copyToClipboard(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full font-sans codeblock bg-zinc-950">
|
||||
<div className="flex items-center justify-between w-full px-6 py-2 pr-4 bg-zinc-800 text-zinc-100">
|
||||
<span className="text-xs lowercase">{language}</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-zinc-800 focus-visible:ring-1 focus-visible:ring-slate-700 focus-visible:ring-offset-0"
|
||||
onClick={downloadAsFile}
|
||||
size="icon"
|
||||
>
|
||||
<IconDownload />
|
||||
<span className="sr-only">Download</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-xs hover:bg-zinc-800 focus-visible:ring-1 focus-visible:ring-slate-700 focus-visible:ring-offset-0"
|
||||
onClick={onCopy}
|
||||
>
|
||||
{isCopied ? <IconCheck /> : <IconCopy />}
|
||||
<span className="sr-only">Copy code</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={coldarkDark}
|
||||
PreTag="div"
|
||||
showLineNumbers
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
padding: '1.5rem 1rem'
|
||||
}}
|
||||
lineNumberStyle={{
|
||||
userSelect: 'none'
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'var(--font-mono)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CodeBlock.displayName = 'CodeBlock'
|
||||
|
||||
export { CodeBlock }
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
|
|
@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
|
|
@ -133,9 +133,9 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DotFilledIcon className="size-4 fill-current" />
|
||||
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,507 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function IconNextChat({
|
||||
className,
|
||||
inverted,
|
||||
...props
|
||||
}: React.ComponentProps<'svg'> & { inverted?: boolean }) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 17 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`gradient-${id}-1`}
|
||||
x1="10.6889"
|
||||
y1="10.3556"
|
||||
x2="13.8445"
|
||||
y2="14.2667"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor={inverted ? 'white' : 'black'} />
|
||||
<stop
|
||||
offset={1}
|
||||
stopColor={inverted ? 'white' : 'black'}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={`gradient-${id}-2`}
|
||||
x1="11.7555"
|
||||
y1="4.8"
|
||||
x2="11.7376"
|
||||
y2="9.50002"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor={inverted ? 'white' : 'black'} />
|
||||
<stop
|
||||
offset={1}
|
||||
stopColor={inverted ? 'white' : 'black'}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M1 16L2.58314 11.2506C1.83084 9.74642 1.63835 8.02363 2.04013 6.39052C2.4419 4.75741 3.41171 3.32057 4.776 2.33712C6.1403 1.35367 7.81003 0.887808 9.4864 1.02289C11.1628 1.15798 12.7364 1.8852 13.9256 3.07442C15.1148 4.26363 15.842 5.83723 15.9771 7.5136C16.1122 9.18997 15.6463 10.8597 14.6629 12.224C13.6794 13.5883 12.2426 14.5581 10.6095 14.9599C8.97637 15.3616 7.25358 15.1692 5.74942 14.4169L1 16Z"
|
||||
fill={inverted ? 'black' : 'white'}
|
||||
stroke={inverted ? 'black' : 'white'}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<mask
|
||||
id="mask0_91_2047"
|
||||
style={{ maskType: 'alpha' }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x={1}
|
||||
y={0}
|
||||
width={16}
|
||||
height={16}
|
||||
>
|
||||
<circle cx={9} cy={8} r={8} fill={inverted ? 'black' : 'white'} />
|
||||
</mask>
|
||||
<g mask="url(#mask0_91_2047)">
|
||||
<circle cx={9} cy={8} r={8} fill={inverted ? 'black' : 'white'} />
|
||||
<path
|
||||
d="M14.2896 14.0018L7.146 4.8H5.80005V11.1973H6.87681V6.16743L13.4444 14.6529C13.7407 14.4545 14.0231 14.2369 14.2896 14.0018Z"
|
||||
fill={`url(#gradient-${id}-1)`}
|
||||
/>
|
||||
<rect
|
||||
x="11.2222"
|
||||
y="4.8"
|
||||
width="1.06667"
|
||||
height="6.4"
|
||||
fill={`url(#gradient-${id}-2)`}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconOpenAI({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<title>OpenAI icon</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconVercel({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
aria-label="Vercel logomark"
|
||||
role="img"
|
||||
viewBox="0 0 74 64"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M37.5896 0.25L74.5396 64.25H0.639648L37.5896 0.25Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconGitHub({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconSeparator({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M16.88 3.549L7.12 20.451"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconArrowDown({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="m205.66 149.66-72 72a8 8 0 0 1-11.32 0l-72-72a8 8 0 0 1 11.32-11.32L120 196.69V40a8 8 0 0 1 16 0v156.69l58.34-58.35a8 8 0 0 1 11.32 11.32Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconArrowRight({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="m221.66 133.66-72 72a8 8 0 0 1-11.32-11.32L196.69 136H40a8 8 0 0 1 0-16h156.69l-58.35-58.34a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 0 11.32Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconUser({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8ZM72 96a56 56 0 1 1 56 56 56.06 56.06 0 0 1-56-56Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconPlus({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconArrowElbow({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M200 32v144a8 8 0 0 1-8 8H67.31l34.35 34.34a8 8 0 0 1-11.32 11.32l-48-48a8 8 0 0 1 0-11.32l48-48a8 8 0 0 1 11.32 11.32L67.31 168H184V32a8 8 0 0 1 16 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconSpinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4 animate-spin', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconMessage({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M216 48H40a16 16 0 0 0-16 16v160a15.84 15.84 0 0 0 9.25 14.5A16.05 16.05 0 0 0 40 240a15.89 15.89 0 0 0 10.25-3.78.69.69 0 0 0 .13-.11L82.5 208H216a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16ZM40 224Zm176-32H82.5a16 16 0 0 0-10.3 3.75l-.12.11L40 224V64h176Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconTrash({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16ZM96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0Zm48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconRefresh({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M197.67 186.37a8 8 0 0 1 0 11.29C196.58 198.73 170.82 224 128 224c-37.39 0-64.53-22.4-80-39.85V208a8 8 0 0 1-16 0v-48a8 8 0 0 1 8-8h48a8 8 0 0 1 0 16H55.44C67.76 183.35 93 208 128 208c36 0 58.14-21.46 58.36-21.68a8 8 0 0 1 11.31.05ZM216 40a8 8 0 0 0-8 8v23.85C192.53 54.4 165.39 32 128 32c-42.82 0-68.58 25.27-69.66 26.34a8 8 0 0 0 11.3 11.34C69.86 69.46 92 48 128 48c35 0 60.24 24.65 72.56 40H168a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconStop({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24Zm0 192a88 88 0 1 1 88-88 88.1 88.1 0 0 1-88 88Zm24-120h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 8 8h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8Zm-8 48h-32v-32h32Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconSidebar({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16ZM40 56h40v144H40Zm176 144H96V56h120v144Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconMoon({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M233.54 142.23a8 8 0 0 0-8-2 88.08 88.08 0 0 1-109.8-109.8 8 8 0 0 0-10-10 104.84 104.84 0 0 0-52.91 37A104 104 0 0 0 136 224a103.09 103.09 0 0 0 62.52-20.88 104.84 104.84 0 0 0 37-52.91 8 8 0 0 0-1.98-7.98Zm-44.64 48.11A88 88 0 0 1 65.66 67.11a89 89 0 0 1 31.4-26A106 106 0 0 0 96 56a104.11 104.11 0 0 0 104 104 106 106 0 0 0 14.92-1.06 89 89 0 0 1-26.02 31.4Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconSun({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M120 40V16a8 8 0 0 1 16 0v24a8 8 0 0 1-16 0Zm72 88a64 64 0 1 1-64-64 64.07 64.07 0 0 1 64 64Zm-16 0a48 48 0 1 0-48 48 48.05 48.05 0 0 0 48-48ZM58.34 69.66a8 8 0 0 0 11.32-11.32l-16-16a8 8 0 0 0-11.32 11.32Zm0 116.68-16 16a8 8 0 0 0 11.32 11.32l16-16a8 8 0 0 0-11.32-11.32ZM192 72a8 8 0 0 0 5.66-2.34l16-16a8 8 0 0 0-11.32-11.32l-16 16A8 8 0 0 0 192 72Zm5.66 114.34a8 8 0 0 0-11.32 11.32l16 16a8 8 0 0 0 11.32-11.32ZM48 128a8 8 0 0 0-8-8H16a8 8 0 0 0 0 16h24a8 8 0 0 0 8-8Zm80 80a8 8 0 0 0-8 8v24a8 8 0 0 0 16 0v-24a8 8 0 0 0-8-8Zm112-88h-24a8 8 0 0 0 0 16h24a8 8 0 0 0 0-16Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconCopy({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8Zm-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconCheck({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="m229.66 77.66-128 128a8 8 0 0 1-11.32 0l-56-56a8 8 0 0 1 11.32-11.32L96 188.69 218.34 66.34a8 8 0 0 1 11.32 11.32Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconDownload({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M224 152v56a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16v-56a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0Zm-101.66 5.66a8 8 0 0 0 11.32 0l40-40a8 8 0 0 0-11.32-11.32L136 132.69V40a8 8 0 0 0-16 0v92.69l-26.34-26.35a8 8 0 0 0-11.32 11.32Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconClose({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128 50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconEdit({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconShare({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
viewBox="0 0 256 256"
|
||||
{...props}
|
||||
>
|
||||
<path d="m237.66 106.35-80-80A8 8 0 0 0 144 32v40.35c-25.94 2.22-54.59 14.92-78.16 34.91-28.38 24.08-46.05 55.11-49.76 87.37a12 12 0 0 0 20.68 9.58c11-11.71 50.14-48.74 107.24-52V192a8 8 0 0 0 13.66 5.65l80-80a8 8 0 0 0 0-11.3ZM160 172.69V144a8 8 0 0 0-8-8c-28.08 0-55.43 7.33-81.29 21.8a196.17 196.17 0 0 0-36.57 26.52c5.8-23.84 20.42-46.51 42.05-64.86C99.41 99.77 127.75 88 152 88a8 8 0 0 0 8-8V51.32L220.69 112Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconUsers({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
viewBox="0 0 256 256"
|
||||
{...props}
|
||||
>
|
||||
<path d="M117.25 157.92a60 60 0 1 0-66.5 0 95.83 95.83 0 0 0-47.22 37.71 8 8 0 1 0 13.4 8.74 80 80 0 0 1 134.14 0 8 8 0 0 0 13.4-8.74 95.83 95.83 0 0 0-47.22-37.71ZM40 108a44 44 0 1 1 44 44 44.05 44.05 0 0 1-44-44Zm210.14 98.7a8 8 0 0 1-11.07-2.33A79.83 79.83 0 0 0 172 168a8 8 0 0 1 0-16 44 44 0 1 0-16.34-84.87 8 8 0 1 1-5.94-14.85 60 60 0 0 1 55.53 105.64 95.83 95.83 0 0 1 47.22 37.71 8 8 0 0 1-2.33 11.07Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconExternalLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
viewBox="0 0 256 256"
|
||||
{...props}
|
||||
>
|
||||
<path d="M224 104a8 8 0 0 1-16 0V59.32l-66.33 66.34a8 8 0 0 1-11.32-11.32L196.68 48H152a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8Zm-40 24a8 8 0 0 0-8 8v72H48V80h72a8 8 0 0 0 0-16H48a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-72a8 8 0 0 0-8-8Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconChevronUpDown({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className={cn('size-4', className)}
|
||||
viewBox="0 0 256 256"
|
||||
{...props}
|
||||
>
|
||||
<path d="M181.66 170.34a8 8 0 0 1 0 11.32l-48 48a8 8 0 0 1-11.32 0l-48-48a8 8 0 0 1 11.32-11.32L128 212.69l42.34-42.35a8 8 0 0 1 11.32 0Zm-96-84.68L128 43.31l42.34 42.35a8 8 0 0 0 11.32-11.32l-48-48a8 8 0 0 0-11.32 0l-48 48a8 8 0 0 0 11.32 11.32Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
IconEdit,
|
||||
IconNextChat,
|
||||
IconOpenAI,
|
||||
IconVercel,
|
||||
IconGitHub,
|
||||
IconSeparator,
|
||||
IconArrowDown,
|
||||
IconArrowRight,
|
||||
IconUser,
|
||||
IconPlus,
|
||||
IconArrowElbow,
|
||||
IconSpinner,
|
||||
IconMessage,
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconStop,
|
||||
IconSidebar,
|
||||
IconMoon,
|
||||
IconSun,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconDownload,
|
||||
IconClose,
|
||||
IconShare,
|
||||
IconUsers,
|
||||
IconExternalLink,
|
||||
IconChevronUpDown
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
CaretSortIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
|
|
@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
|||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
|
@ -64,11 +64,11 @@ const SheetContent = React.forwardRef<
|
|||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<Cross2Icon className="size-4" />
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
|
|
|
|||
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