feat: implement new ui (#9)
This commit is contained in:
commit
44f93d338e
62 changed files with 2655 additions and 1044 deletions
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
OPENAI_API_KEY=XXXXXXXX
|
||||
NEXTAUTH_SECRET=XXXXXXXX
|
||||
|
|
@ -1,5 +1,25 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/eslintrc",
|
||||
"root": true,
|
||||
"extends": ["next/core-web-vitals", "prettier"]
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"prettier",
|
||||
"plugin:tailwindcss/recommended"
|
||||
],
|
||||
"plugins": ["tailwindcss"],
|
||||
"rules": {
|
||||
"tailwindcss/no-custom-classname": "off"
|
||||
},
|
||||
"settings": {
|
||||
"tailwindcss": {
|
||||
"callees": ["cn", "cva"],
|
||||
"config": "tailwind.config.js"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -35,3 +35,4 @@ yarn-error.log*
|
|||
.contentlayer
|
||||
.env
|
||||
.vercel
|
||||
.vscode
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
}
|
||||
44
README.md
44
README.md
|
|
@ -1,3 +1,45 @@
|
|||
# Next.js AI Chatbot
|
||||
|
||||
A an AI-powered Chatbot built with Next.js built by Vercel Labs.
|
||||
A Next.js 13 and App Router-ready AI chatbot template featuring:
|
||||
|
||||
- [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, HuggingFace, or custom AI chat models and/or LangChain
|
||||
- Edge runtime-ready
|
||||
- [Shadcn UI](https://ui.shadcn.com)
|
||||
- Styling with [Tailwind CSS](https://tailwindcss.com)
|
||||
- [Radix UI](https://radix-ui.com) for headless component primitives
|
||||
- Chat History, rate limiting, and session storage with [Vercel KV](https://vercel.com/storage/kv)
|
||||
- [Next Auth](https://github.com/nextauthjs/next-auth) for 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), switching LLM providers to [Anthropic](https://anthropic.com), [HuggingFace](https://huggingface.co), or using [LangChain](https://js.langchain.com) with just a few lines of code.
|
||||
|
||||
## Running locally
|
||||
|
||||
You will need to use the environment variables [defined in `.env.example`](.env.example) to run Next.js AI Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/concepts/projects/environment-variables) for this, but a `.env` file is all that is necessary.
|
||||
|
||||
> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various OpenAI and authentication provider accounts.
|
||||
|
||||
1. Install Vercel CLI: `npm i -g vercel`
|
||||
2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link`
|
||||
3. Download your environment variables: `vercel env pull`
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Your app 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)) - [Contractor](https://shadcn.com)
|
||||
|
||||
[Contributors](https://github.com/vercel-labs/ai-chatbot/graphs/contributors)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,44 @@
|
|||
'use server'
|
||||
|
||||
import { kv } from '@vercel/kv'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { kv } from '@vercel/kv'
|
||||
|
||||
import { type Chat } from '@/lib/types'
|
||||
|
||||
export async function getChats(userId?: string | null) {
|
||||
if (!userId) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const pipeline = kv.pipeline()
|
||||
const chats: string[] = await kv.zrange(`user:chat:${userId}`, 0, -1)
|
||||
|
||||
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 chat = await kv.hgetall<Chat>(`chat:${id}`)
|
||||
|
||||
if (!chat) {
|
||||
throw new Error('Not found')
|
||||
}
|
||||
|
||||
if (userId && chat.userId !== userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
return chat
|
||||
}
|
||||
|
||||
export async function removeChat({
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { GET, POST } from '@/auth'
|
||||
export const runtime = 'edge'
|
||||
// export const runtime = 'edge'
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { auth } from '@/auth'
|
||||
import { kv } from '@vercel/kv'
|
||||
import { nanoid } from '@/lib/utils'
|
||||
import { OpenAIStream, StreamingTextResponse } from 'ai-connector'
|
||||
import { Configuration, OpenAIApi } from 'openai-edge'
|
||||
|
||||
export const runtime = 'edge'
|
||||
import { nanoid } from '@/lib/utils'
|
||||
|
||||
// export const runtime = 'edge'
|
||||
|
||||
const configuration = new Configuration({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { type Message } from 'ai-connector'
|
||||
|
||||
import { ChatMessage } from './chat-message'
|
||||
import { NextChatLogo } from '@/components/ui/nextchat-logo'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { EmptyScreen } from './empty'
|
||||
|
||||
export interface ChatList {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
export function ChatList({ messages }: ChatList) {
|
||||
return (
|
||||
<div className="relative h-full dark:bg-zinc-900">
|
||||
<div className="sticky top-0 border-b w-full bg-black md:hidden text-white font-semibold">
|
||||
<div className="px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex flex-row gap-2 whitespace-nowrap items-center">
|
||||
<NextChatLogo className="h-6 w-6" />
|
||||
<span className="select-none">Next.js Chatbot</span>
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-white p-2 rounded hover:bg-zinc-800 transition duration-100">
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full w-full overflow-auto">
|
||||
{messages.length > 0 ? (
|
||||
<div className="group w-full text-zinc-900 dark:text-white divide-y dark:divide-zinc-800">
|
||||
{messages.map(message => (
|
||||
<ChatMessage
|
||||
key={message.id || message.content}
|
||||
message={message}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyScreen />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import CodeBlock from '@/components/ui/codeblock'
|
||||
import { MemoizedReactMarkdown } from '@/components/markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { fontMessage } from '@/lib/fonts'
|
||||
import { Message } from 'ai-connector'
|
||||
export interface ChatMessageProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export function ChatMessage(props: ChatMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
{
|
||||
'bg-zinc-50': props.message.role === 'assistant'
|
||||
},
|
||||
'pr-0 lg:pr-[260px]',
|
||||
fontMessage.className
|
||||
)}
|
||||
>
|
||||
<div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-xl xl:max-w-3xl">
|
||||
<div className="flex w-full gap-4 items-start">
|
||||
{props.message.role === 'user' ? (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-r from-cyan-500 to-blue-500 p-2 select-none">
|
||||
<div
|
||||
className="font-medium uppercase text-zinc-100"
|
||||
style={{ fontSize: 6 }}
|
||||
>
|
||||
User
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center select-none bg-zinc-300 rounded-full">
|
||||
<IconOpenAI className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<MemoizedReactMarkdown
|
||||
className="prose prose-stone prose-base prose-pre:rounded-md w-full flex-1 leading-6 prose-p:leading-[1.8rem] prose-pre:bg-[#282c34] max-w-full"
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={{
|
||||
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 || '')
|
||||
|
||||
return !inline ? (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={(match && match[1]) || ''}
|
||||
value={String(children).replace(/\n$/, '')}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<table className="border-collapse border border-black px-3 py-1 ">
|
||||
{children}
|
||||
</table>
|
||||
)
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="break-words border border-black bg-gray-500 px-3 py-1 text-white ">
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
td({ children }) {
|
||||
return (
|
||||
<td className="break-words border border-black px-3 py-1">
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.message.content}
|
||||
</MemoizedReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ChatMessage.displayName = 'ChatMessage'
|
||||
|
||||
export function IconOpenAI(props: JSX.IntrinsicElements['svg']) {
|
||||
return (
|
||||
<svg
|
||||
fill="#000000"
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...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>
|
||||
)
|
||||
}
|
||||
50
app/chat.tsx
50
app/chat.tsx
|
|
@ -1,50 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Prompt } from './prompt'
|
||||
import { useChat, type Message } from 'ai-connector'
|
||||
import { ChatList } from './chat-list'
|
||||
|
||||
export interface ChatProps {
|
||||
// create?: (input: string) => Chat | undefined;
|
||||
initialMessages?: Message[]
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
id,
|
||||
// create,
|
||||
initialMessages
|
||||
}: ChatProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const { isLoading, messages, reload, append } = useChat({
|
||||
initialMessages: initialMessages as any[],
|
||||
id
|
||||
// onCreate: (id: string) => {
|
||||
// router.push(`/chat/${id}`);
|
||||
// },
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="transition-width relative min-h-full w-full flex-1 overflow-y-auto flex flex-col">
|
||||
<div className="flex-1">
|
||||
<ChatList messages={messages} />
|
||||
</div>
|
||||
<div className="sticky light-gradient dark:bg-gradient-to-b dark:from-zinc-900 dark:to-zinc-950 bottom-0 left-0 w-full border-t bg-white dark:bg-black md:border-t-0 py-4 md:border-transparent md:!bg-transparent md:dark:border-transparent pr-0 lg:pr-[260px] flex dark:border-transparent items-center justify-center">
|
||||
<Prompt
|
||||
onSubmit={value => {
|
||||
append({
|
||||
content: value,
|
||||
role: 'user'
|
||||
})
|
||||
}}
|
||||
onRefresh={messages.length ? reload : undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Chat.displayName = 'Chat'
|
||||
|
|
@ -1,15 +1,12 @@
|
|||
import { Sidebar } from '@/app/sidebar'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { type Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
import { Chat } from '@/app/chat'
|
||||
import { type Chat as ChatType } from '@/lib/types'
|
||||
import { kv } from '@vercel/kv'
|
||||
import { Message } from 'ai-connector'
|
||||
import { Chat } from '@/components/chat'
|
||||
import { getChat } from '@/app/actions'
|
||||
|
||||
export const runtime = 'edge'
|
||||
// export const runtime = 'edge'
|
||||
export const preferredRegion = 'home'
|
||||
|
||||
export interface ChatPageProps {
|
||||
params: {
|
||||
id: string
|
||||
|
|
@ -30,27 +27,5 @@ export default async function ChatPage({ params }: ChatPageProps) {
|
|||
const session = await auth()
|
||||
const chat = await getChat(params.id, session?.user?.email ?? '')
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
<Sidebar session={session} />
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<Chat id={chat.id} initialMessages={chat.messages as Message[]} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ChatPage.displayName = 'ChatPage'
|
||||
|
||||
async function getChat(id: string, userId: string) {
|
||||
const chat = await kv.hgetall<ChatType>(`chat:${id}`)
|
||||
if (!chat) {
|
||||
throw new Error('Not found')
|
||||
}
|
||||
|
||||
if (userId && chat.userId !== userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
return chat
|
||||
return <Chat id={chat.id} initialMessages={chat.messages} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { ExternalLink } from './external-link'
|
||||
import { fontMessage } from '@/lib/fonts'
|
||||
|
||||
function ExampleBubble({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex items-start justify-between p-4 rounded-lg relative bg-zinc-100 text-sm font-medium transition-all cursor-pointer select-none hover:drop-shadow-[0_1px_1px_rgba(0,0,0,0.08)] hover:shadow-[0rem_0rem_0rem_0.0625rem_rgba(0,0,0,.02),0rem_0.125rem_0.25rem_rgba(0,0,0,.08),0rem_0.45rem_1rem_rgba(0,0,0,.06)] hover:bg-white group duration-300',
|
||||
fontMessage.className
|
||||
)}
|
||||
style={{}}
|
||||
>
|
||||
<div
|
||||
className="bg-zinc-100 w-9 h-6 absolute left-0 top-full -mt-[1px] scale-75 origin-top-left group-hover:bg-white transition-all duration-300"
|
||||
style={{
|
||||
clipPath:
|
||||
'path("M31.1838 0C24.9593 10.7604 13.3251 18 0 18C9.94113 18 18 9.94113 18 0H31.1838Z")'
|
||||
}}
|
||||
></div>
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyScreen() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full h-full flex items-center justify-center pt-4 pb-8 pr-0 lg:pr-[260px]'
|
||||
)}
|
||||
>
|
||||
<div className="p-8 rounded-lg flex flex-col items-center justify-center gap-8 max-w-2xl">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="text-zinc-500 font-medium">
|
||||
<p>Welcome to Next.js Chatbot!</p>
|
||||
<p className="mt-2">
|
||||
This is an open source AI chatbot app built with{' '}
|
||||
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '}
|
||||
<ExternalLink href="https://vercel.com/storage/kv">
|
||||
Vercel KV
|
||||
</ExternalLink>
|
||||
. You can start a conversation here or try the following examples:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-x-4 gap-y-6 grid-cols-2 w-full">
|
||||
<ExampleBubble>Explain technical concepts</ExampleBubble>
|
||||
<ExampleBubble>Summarize article</ExampleBubble>
|
||||
<ExampleBubble>Get assistance</ExampleBubble>
|
||||
<ExampleBubble>Draft an email</ExampleBubble>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,17 +3,76 @@
|
|||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
#__next,
|
||||
#root {
|
||||
height: 100%;
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: ;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--ring: 240 5% 64.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: ;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
|
||||
--ring: 240 3.7% 15.9%;
|
||||
}
|
||||
}
|
||||
|
||||
pre:has(div.codeblock) {
|
||||
padding: 0;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import './globals.css'
|
||||
import { Metadata } from 'next'
|
||||
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import '@/app/globals.css'
|
||||
import { fontMono, fontSans } from '@/lib/fonts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Header } from '@/components/header'
|
||||
import { TailwindIndicator } from '@/components/tailwind-indicator'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
|
|
@ -28,7 +30,6 @@ interface RootLayoutProps {
|
|||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head />
|
||||
<body
|
||||
|
|
@ -39,11 +40,14 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
|||
)}
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
{/* <TailwindIndicator /> */}
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* @ts-ignore */}
|
||||
<Header />
|
||||
<main className="flex-1 bg-muted/50">{children}</main>
|
||||
</div>
|
||||
<TailwindIndicator />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
16
app/page.tsx
16
app/page.tsx
|
|
@ -1,18 +1,8 @@
|
|||
import { Chat } from './chat'
|
||||
import { Sidebar } from './sidebar'
|
||||
import { auth } from '@/auth'
|
||||
import { Chat } from '@/components/chat'
|
||||
|
||||
export const runtime = 'edge'
|
||||
// export const runtime = 'edge'
|
||||
export const preferredRegion = 'home'
|
||||
|
||||
export default async function IndexPage() {
|
||||
const session = await auth()
|
||||
return (
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
<Sidebar session={session} newChat />
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <Chat />
|
||||
}
|
||||
|
|
|
|||
114
app/prompt.tsx
114
app/prompt.tsx
|
|
@ -1,114 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { CornerDownLeft, RefreshCcw, StopCircle } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { fontMessage } from '@/lib/fonts'
|
||||
import { useCmdEnterSubmit } from '@/lib/hooks/use-command-enter-submit'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface PromptProps {
|
||||
onSubmit: (value: string) => void
|
||||
onRefresh?: () => void
|
||||
onAbort?: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function Prompt({
|
||||
onSubmit,
|
||||
onRefresh,
|
||||
onAbort,
|
||||
isLoading
|
||||
}: PromptProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const { formRef, onKeyDown } = useCmdEnterSubmit()
|
||||
return (
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault()
|
||||
setInput('')
|
||||
await onSubmit(input)
|
||||
}}
|
||||
ref={formRef}
|
||||
className="stretch flex w-full flex-row gap-3 md:max-w-2xl lg:max-w-xl xl:max-w-3xl mx-auto px-4"
|
||||
>
|
||||
<div className="relative flex h-full flex-1 flex-row-reverse items-stretch md:flex-col">
|
||||
<div>
|
||||
<div className="ml-1 flex h-full justify-center gap-0 md:m-auto md:mb-2 md:w-full md:gap-2">
|
||||
{onRefresh ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative border-0 h-full md:h-auto px-3 md:border bg-white dark:bg-zinc-900 dark:border-zinc-800 dark:text-zinc-400"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<div className="flex h-gull w-full items-center justify-center gap-2">
|
||||
<RefreshCcw className="h-4 w-4 text-zinc-500 md:h-3 md:w-3" />
|
||||
<span className="hidden md:block">Regenerate response</span>
|
||||
</div>
|
||||
</Button>
|
||||
) : null}
|
||||
{/* <button
|
||||
id="share-button"
|
||||
className="btn btn-neutral flex justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="h-3 w-3"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z"
|
||||
/>
|
||||
</svg>
|
||||
Share
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full grow flex-col rounded-md dark:focus:border-white border border-zinc/10 bg-white shadow-[0_10px_20px_-10px_rgba(0,0,0,0.20)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-white dark:shadow-[0_5px_15px_rgba(0,0,0,0.10)] focus-within:border-zinc-800 focus-within:shadow-[0_15px_10px_-12px_rgba(0,0,0,0.22)] transition-all duration-200 focus-within:duration-1000 ease-in-out">
|
||||
<Textarea
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
placeholder="Send a message."
|
||||
spellCheck={false}
|
||||
className={cn(
|
||||
'm-0 max-h-[200px] w-full resize-none border-0 bg-transparent p-0 py-[13px] pl-4 pr-7 outline-none ring-0 focus:ring-0 focus-visible:ring-0 dark:bg-transparent',
|
||||
fontMessage.className
|
||||
)}
|
||||
style={{
|
||||
height: 46,
|
||||
overflowY: 'hidden'
|
||||
}}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="absolute top-0 bottom-0 m-auto h-8 w-8 flex items-center justify-center right-2 rounded p-2 text-zinc-500 hover:bg-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent dark:hover:bg-zinc-900 enabled:dark:hover:text-zinc-400 dark:disabled:hover:bg-transparent"
|
||||
>
|
||||
<StopCircle className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute top-0 bottom-0 m-auto h-8 w-8 flex items-center justify-center right-2 rounded p-2 text-zinc-500 hover:bg-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent dark:hover:bg-zinc-900 enabled:dark:hover:text-zinc-400 dark:disabled:hover:bg-transparent"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
Prompt.displayName = 'Prompt'
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { MessageCircleIcon, Trash2Icon, Loader2Icon } from 'lucide-react'
|
||||
import { removeChat } from './actions'
|
||||
import { useTransition } from 'react'
|
||||
|
||||
export function SidebarItem({
|
||||
title,
|
||||
href,
|
||||
id,
|
||||
userId
|
||||
}: {
|
||||
title: string
|
||||
href: string
|
||||
id: string
|
||||
userId: string
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const active = pathname === href
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
if (!id) return null
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'group flex shrink-0 cursor-pointer items-center gap-2 rounded p-2 text-sm font-medium transition-colors duration-100 hover:bg-zinc-500/10 hover:dark:bg-zinc-300/20',
|
||||
isPending
|
||||
? 'text-zinc-400 dark:text-zinc-500'
|
||||
: 'text-zinc-800 dark:text-zinc-400',
|
||||
active
|
||||
? 'bg-zinc-500/20 text-zinc-900 font-semibold hover:bg-zinc-500/30 dark:text-white'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
<MessageCircleIcon className="h-4 w-4" />
|
||||
<div
|
||||
className="relative max-h-5 flex-1 overflow-hidden text-ellipsis break-all select-none"
|
||||
title={title}
|
||||
>
|
||||
<span className="whitespace-nowrap">{title}</span>
|
||||
</div>
|
||||
<button
|
||||
className="opacity-0 -mr-6 transition-opacity duration-0 hover:bg-zinc-400/50 group-hover:mr-0 group-hover:opacity-80 p-1 -my-1 rounded group-hover:duration-600"
|
||||
disabled={isPending}
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
startTransition(() => {
|
||||
removeChat({ id, userId, path: href }).then(() => {
|
||||
router.push('/')
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="animate-spin h-4 w-4" />
|
||||
) : (
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
117
app/sidebar.tsx
117
app/sidebar.tsx
|
|
@ -1,117 +0,0 @@
|
|||
import { Login } from '@/components/ui/login'
|
||||
import { NextChatLogo } from '@/components/ui/nextchat-logo'
|
||||
import { UserMenu } from '@/components/ui/user-menu'
|
||||
import { kv } from '@vercel/kv'
|
||||
import { Chat } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type Session } from '@auth/nextjs/types'
|
||||
import { Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink } from './external-link'
|
||||
import { SidebarItem } from './sidebar-item'
|
||||
|
||||
export interface SidebarProps {
|
||||
session?: Session
|
||||
newChat?: boolean
|
||||
}
|
||||
|
||||
export function Sidebar({ session, newChat }: SidebarProps) {
|
||||
return (
|
||||
<div className="hidden shrink-0 bg-zinc-200/80 dark:bg-black md:flex md:w-[260px] md:flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col ">
|
||||
<div className="scrollbar-trigger relative h-full w-full flex-1 items-start">
|
||||
<aside className="flex h-full w-full flex-col p-2 shadow-lg ring-1 ring-zinc-900/10 dark:ring-zinc-200/10 relative z-10">
|
||||
<div className="flex flex-row gap-1 items-center justify-between text-base font-semibold tracking-tight antialiased bg-black text-white px-4 py-4 -m-2 mb-2">
|
||||
<div className="flex flex-row gap-2 whitespace-nowrap items-center">
|
||||
<NextChatLogo className="h-6 w-6" />
|
||||
<span className="select-none">Next.js Chatbot</span>
|
||||
</div>
|
||||
<div>
|
||||
{session?.user ? <UserMenu session={session} /> : <Login />}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
'flex shrink-0 cursor-pointer items-center gap-2 rounded p-2 text-sm text-zinc-800 dark:text-zinc-200 font-medium transition-colors duration-300 dark:hover:bg-zinc-300/20 select-none',
|
||||
newChat
|
||||
? 'bg-zinc-500/20 text-zinc-900 font-semibold hover:bg-zinc-500/30 dark:text-white'
|
||||
: 'hover:bg-zinc-500/10'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Chat
|
||||
</Link>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-zinc-300 dark:border-zinc-700 flex-1 flex-col overflow-y-auto overflow-x-hidden transition-opacity duration-500">
|
||||
<div className="flex flex-col pb-2 text-sm text-zinc-100">
|
||||
{/* @ts-ignore */}
|
||||
<SidebarList session={session} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex gap-4 items-center justify-center text-sm font-medium leading-4 whitespace-nowrap">
|
||||
<a
|
||||
href="https://github.com/vercel/nextjs-ai-chatbot/"
|
||||
target="_blank"
|
||||
className="inline-flex gap-2 px-3 py-2 items-center border border-zinc-300 rounded-md hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
<svg
|
||||
aria-label="Vercel logomark"
|
||||
height="13"
|
||||
role="img"
|
||||
className="w-auto overflow-hidden"
|
||||
viewBox="0 0 74 64"
|
||||
>
|
||||
<path
|
||||
d="M37.5896 0.25L74.5396 64.25H0.639648L37.5896 0.25Z"
|
||||
fill="var(--geist-foreground)"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Deploy</span>
|
||||
</a>
|
||||
<ExternalLink href="https://github.com/vercel/nextjs-ai-chatbot/">
|
||||
View on GitHub
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
|
||||
async function SidebarList({ session }: { session?: Session }) {
|
||||
const results: Chat[] = await getChats(session?.user?.email ?? '')
|
||||
|
||||
return results.map(c => (
|
||||
<SidebarItem
|
||||
key={c.id}
|
||||
title={c.title}
|
||||
userId={session?.user?.email ?? ''}
|
||||
href={`/chat/${c.id}`}
|
||||
id={c.id}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
async function getChats(userId: string) {
|
||||
try {
|
||||
const pipeline = kv.pipeline()
|
||||
const chats: string[] = await kv.zrange(`user:chat:${userId}`, 0, -1)
|
||||
|
||||
for (const chat of chats) {
|
||||
pipeline.hgetall(chat)
|
||||
}
|
||||
|
||||
const results = await pipeline.exec()
|
||||
|
||||
return results as Chat[]
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
28
auth.ts
28
auth.ts
|
|
@ -1,27 +1,27 @@
|
|||
import NextAuth from "@auth/nextjs";
|
||||
import GitHub from "@auth/nextjs/providers/github";
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from 'next/server'
|
||||
import NextAuth from '@auth/nextjs'
|
||||
import GitHub from '@auth/nextjs/providers/github'
|
||||
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
CSRF_experimental,
|
||||
CSRF_experimental
|
||||
} = NextAuth({
|
||||
// @ts-ignore
|
||||
providers: [GitHub],
|
||||
session: { strategy: "jwt" },
|
||||
session: { strategy: 'jwt' },
|
||||
async authorized({ request, auth }: any) {
|
||||
const url = request.nextUrl;
|
||||
const url = request.nextUrl
|
||||
|
||||
if (request.method === "POST") {
|
||||
const { authToken } = (await request.json()) ?? {};
|
||||
if (request.method === 'POST') {
|
||||
const { authToken } = (await request.json()) ?? {}
|
||||
// If the request has a valid auth token, it is authorized
|
||||
const valid = true;
|
||||
if (valid) return true;
|
||||
return NextResponse.json("Invalid auth token", { status: 401 });
|
||||
const valid = true
|
||||
if (valid) return true
|
||||
return NextResponse.json('Invalid auth token', { status: 401 })
|
||||
}
|
||||
|
||||
// Logged in users are authenticated, otherwise redirect to login page
|
||||
return !!auth;
|
||||
},
|
||||
});
|
||||
return !!auth
|
||||
}
|
||||
})
|
||||
|
|
|
|||
48
components/button-scroll-to-bottom.tsx
Normal file
48
components/button-scroll-to-bottom.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||
|
||||
export function ButtonScrollToBottom({ className, ...props }: ButtonProps) {
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsAtBottom(
|
||||
window.innerHeight + window.scrollY > document.body.offsetHeight - 100
|
||||
)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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={() =>
|
||||
window.scrollTo({
|
||||
top: document.body.offsetHeight,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
<span className="sr-only">Scroll to bottom</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
28
components/chat-list.tsx
Normal file
28
components/chat-list.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
'use client'
|
||||
|
||||
import { type Message } from 'ai-connector'
|
||||
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ChatMessage } from '@/components/chat-message'
|
||||
import { EmptyScreen } from '@/components/empty-screen'
|
||||
|
||||
export interface ChatList {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
export function ChatList({ messages }: ChatList) {
|
||||
return (
|
||||
<div className="relative mx-auto max-w-2xl px-4">
|
||||
{messages.length > 0 ? (
|
||||
messages.map((message, index) => (
|
||||
<div key={index}>
|
||||
<ChatMessage message={message} />
|
||||
{index < messages.length - 1 && <Separator className="my-8" />}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyScreen />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
components/chat-message.tsx
Normal file
73
components/chat-message.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Message } from 'ai-connector'
|
||||
import { User } from 'lucide-react'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeBlock } from '@/components/ui/codeblock'
|
||||
import { OpenAI } from '@/components/icons'
|
||||
import { MemoizedReactMarkdown } from '@/components/markdown'
|
||||
|
||||
export interface ChatMessageProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, ...props }: ChatMessageProps) {
|
||||
return (
|
||||
<div className={cn('relative mb-4 flex items-start md:-ml-12')} {...props}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-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' ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<OpenAI className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 px-1">
|
||||
<MemoizedReactMarkdown
|
||||
className="prose leading-6 dark:prose-invert prose-p:leading-[1.8rem] prose-pre:rounded-md prose-pre:bg-[#282c34]"
|
||||
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 || '')
|
||||
|
||||
return !inline ? (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={(match && match[1]) || ''}
|
||||
value={String(children).replace(/\n$/, '')}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</MemoizedReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
components/chat-panel.tsx
Normal file
75
components/chat-panel.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
'use client'
|
||||
|
||||
import { UseChatHelpers } from 'ai-connector'
|
||||
import { RefreshCcw, StopCircle } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ExternalLink } from '@/components/external-link'
|
||||
import { PromptForm } from '@/components/prompt-form'
|
||||
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
|
||||
|
||||
export interface ChatPanelProps
|
||||
extends Pick<
|
||||
UseChatHelpers,
|
||||
'append' | 'isLoading' | 'reload' | 'messages' | 'stop'
|
||||
> {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
isLoading,
|
||||
stop,
|
||||
append,
|
||||
reload,
|
||||
messages
|
||||
}: ChatPanelProps) {
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 bg-gradient-to-b from-muted/10 from-10% to-muted/30 to-50%">
|
||||
<ButtonScrollToBottom />
|
||||
<div className="mx-auto sm:max-w-2xl sm:px-4">
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
{isLoading ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => stop()}
|
||||
className="bg-background"
|
||||
>
|
||||
<StopCircle className="mr-2 h-4 w-4" />
|
||||
Stop generating
|
||||
</Button>
|
||||
) : (
|
||||
messages?.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => reload()}
|
||||
className="bg-background"
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
Regenerate response
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
onSubmit={value => {
|
||||
append({
|
||||
content: value,
|
||||
role: 'user'
|
||||
})
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<p className="hidden px-2 text-center text-xs leading-normal text-muted-foreground sm:block">
|
||||
Open source AI chatbot app built with{' '}
|
||||
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '}
|
||||
<ExternalLink href="https://vercel.com/storage/kv">
|
||||
Vercel KV
|
||||
</ExternalLink>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
components/chat.tsx
Normal file
33
components/chat.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use client'
|
||||
|
||||
import { useChat, type Message } from 'ai-connector'
|
||||
|
||||
import { ChatList } from '@/components/chat-list'
|
||||
import { ChatPanel } from '@/components/chat-panel'
|
||||
|
||||
export interface ChatProps {
|
||||
// create?: (input: string) => Chat | undefined;
|
||||
initialMessages?: Message[]
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function Chat({ id, initialMessages }: ChatProps) {
|
||||
const { messages, append, reload, stop, isLoading } = useChat({
|
||||
initialMessages,
|
||||
id
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pb-[200px] pt-4 md:pt-10">
|
||||
<ChatList messages={messages} />
|
||||
<ChatPanel
|
||||
id={id}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
append={append}
|
||||
reload={reload}
|
||||
messages={messages}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
components/empty-screen.tsx
Normal file
57
components/empty-screen.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
import { useChatStore } from '@/lib/hooks/use-chat-store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ExternalLink } from '@/components/external-link'
|
||||
|
||||
const exampleMessages = [
|
||||
{
|
||||
heading: 'Explain technical concepts',
|
||||
message: `What is a "serverless function"?`
|
||||
},
|
||||
{
|
||||
heading: 'Summarize an article',
|
||||
message: 'Summarize the following article for a 2nd grader: \n'
|
||||
},
|
||||
{
|
||||
heading: 'Draft an email',
|
||||
message: `Draft an email to my boss about the following: \n`
|
||||
}
|
||||
]
|
||||
|
||||
export function EmptyScreen({ className }: React.ComponentProps<'div'>) {
|
||||
const { setDefaultMessage } = useChatStore()
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border bg-background p-8', className)}>
|
||||
<h1 className="mb-2 text-lg font-semibold">
|
||||
Welcome to Next.js Chatbot!
|
||||
</h1>
|
||||
<p className="mb-2 leading-normal text-muted-foreground">
|
||||
This is an open source AI chatbot app built with{' '}
|
||||
<ExternalLink href="https://nextjs.org">Next.js</ExternalLink> and{' '}
|
||||
<ExternalLink href="https://vercel.com/storage/kv">
|
||||
Vercel KV
|
||||
</ExternalLink>
|
||||
.
|
||||
</p>
|
||||
<p className="leading-normal text-muted-foreground">
|
||||
You can start a conversation here or try the following examples:
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col items-start space-y-2">
|
||||
{exampleMessages.map((message, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="link"
|
||||
className="h-auto p-0 text-base"
|
||||
onClick={() => setDefaultMessage(message.message)}
|
||||
>
|
||||
<ArrowRight className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
{message.heading}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ export function ExternalLink({
|
|||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
className="inline-flex gap-1 hover:underline flex-1 justify-center leading-4"
|
||||
className="inline-flex flex-1 justify-center gap-1 leading-4 hover:underline"
|
||||
>
|
||||
<span>{children}</span>
|
||||
<svg
|
||||
50
components/header.tsx
Normal file
50
components/header.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { auth } from '@/auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { GitHub, Separator, Vercel } from '@/components/icons'
|
||||
import { Sidebar } from '@/components/sidebar'
|
||||
import { UserMenu } from '@/components/user-menu'
|
||||
import { SidebarList } from './sidebar-list'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export async function Header() {
|
||||
const session = await auth()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex h-16 w-full shrink-0 items-center justify-between border-b bg-gradient-to-b from-background/10 via-background/50 to-background/80 px-4 backdrop-blur-xl">
|
||||
<div className="flex items-center">
|
||||
{/* @ts-ignore */}
|
||||
<Sidebar session={session}>
|
||||
<Suspense fallback={<div className="flex-1 overflow-auto" />}>
|
||||
{/* @ts-ignore */}
|
||||
<SidebarList session={session} />
|
||||
</Suspense>
|
||||
</Sidebar>
|
||||
<div className="hidden items-center md:flex">
|
||||
<Separator className="h-6 w-6 text-muted-foreground/50" />
|
||||
<UserMenu session={session} />
|
||||
</div>
|
||||
</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' }))}
|
||||
>
|
||||
<GitHub className="mr-2 h-4 w-4" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/vercel/nextjs-ai-chatbot/"
|
||||
target="_blank"
|
||||
className={cn(buttonVariants())}
|
||||
>
|
||||
<Vercel className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:block">Deploy to Vercel</span>
|
||||
<span className="sm:hidden">Deploy</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
160
components/icons.tsx
Normal file
160
components/icons.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function NextChat({
|
||||
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('h-4 w-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>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenAI({ 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('h-4 w-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>
|
||||
)
|
||||
}
|
||||
|
||||
export function Vercel({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
aria-label="Vercel logomark"
|
||||
role="img"
|
||||
viewBox="0 0 74 64"
|
||||
className={cn('h-4 w-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M37.5896 0.25L74.5396 64.25H0.639648L37.5896 0.25Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitHub({ 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('h-4 w-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>
|
||||
)
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
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('h-4 w-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M16.88 3.549L7.12 20.451"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
99
components/prompt-form.tsx
Normal file
99
components/prompt-form.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { CornerDownLeft, Plus } from 'lucide-react'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
|
||||
import { useChatStore } from '@/lib/hooks/use-chat-store'
|
||||
import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
export interface PromptProps {
|
||||
onSubmit: (value: string) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function PromptForm({ onSubmit, isLoading }: PromptProps) {
|
||||
const { defaultMessage } = useChatStore()
|
||||
const [input, setInput] = React.useState(defaultMessage)
|
||||
const { formRef, onKeyDown } = useEnterSubmit()
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
setInput(defaultMessage)
|
||||
}, [defaultMessage])
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault()
|
||||
if (input === '') {
|
||||
return
|
||||
}
|
||||
setInput('')
|
||||
await onSubmit(input)
|
||||
}}
|
||||
ref={formRef}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<div className="relative flex w-full grow flex-col overflow-hidden bg-background px-8 sm:rounded-md sm:border sm:px-12">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
buttonVariants({ size: 'sm', variant: 'outline' }),
|
||||
'absolute left-0 top-4 h-8 w-8 rounded-full bg-background p-0 sm:left-4'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="sr-only">New Chat</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New Chat</TooltipContent>
|
||||
</Tooltip>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
placeholder="Send a message."
|
||||
spellCheck={false}
|
||||
className="min-h-[60px] w-full resize-none bg-transparent px-4 py-[1.3rem] focus-within:outline-none sm:text-sm"
|
||||
/>
|
||||
<div className="absolute right-0 top-4 sm:right-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={isLoading || input === ''}
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Send message</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Send message</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
104
components/sidebar-item.tsx
Normal file
104
components/sidebar-item.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useTransition } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Loader2Icon, MessageSquare, Trash2Icon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import { removeChat } from '@/app/actions'
|
||||
|
||||
export function SidebarItem({
|
||||
title,
|
||||
href,
|
||||
id,
|
||||
userId
|
||||
}: {
|
||||
title: string
|
||||
href: string
|
||||
id: string
|
||||
userId: string
|
||||
}) {
|
||||
const [open, setIsOpen] = React.useState(false)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const isActive = pathname === href
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
if (!id) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'group w-full px-2',
|
||||
isActive && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
<div
|
||||
className="relative max-h-5 flex-1 select-none overflow-hidden text-ellipsis break-all"
|
||||
title={title}
|
||||
>
|
||||
<span className="whitespace-nowrap">{title}</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={isPending}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
)}
|
||||
</Link>
|
||||
<AlertDialog open={open} onOpenChange={setIsOpen}>
|
||||
<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={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isPending}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
startTransition(async () => {
|
||||
await removeChat({ id, userId, path: href })
|
||||
setIsOpen(false)
|
||||
router.push('/')
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isPending && (
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
40
components/sidebar-list.tsx
Normal file
40
components/sidebar-list.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { getChats } from '@/app/actions'
|
||||
import { Session } from '@auth/core/types'
|
||||
import { SidebarItem } from './sidebar-item'
|
||||
|
||||
export interface SidebarListProps {
|
||||
session?: Session
|
||||
}
|
||||
|
||||
export async function SidebarList(props: SidebarListProps) {
|
||||
const chats = await getChats(props.session?.user?.email)
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{chats?.length ? (
|
||||
<div className="px-2 space-y-2">
|
||||
{chats.map(chat => (
|
||||
<SidebarItem
|
||||
key={chat.id}
|
||||
title={chat.title}
|
||||
userId={props?.session?.user?.email ?? ''}
|
||||
href={`/chat/${chat.id}`}
|
||||
id={chat.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{props?.session?.user ? (
|
||||
<>No chat history</>
|
||||
) : (
|
||||
<>Login for history</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
SidebarList.displayName = 'SidebarList'
|
||||
55
components/sidebar.tsx
Normal file
55
components/sidebar.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { signOut } from '@auth/nextjs/client'
|
||||
import { type Session } from '@auth/nextjs/types'
|
||||
import { Sidebar as SidebarIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from '@/components/ui/sheet'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
|
||||
export interface SidebarProps {
|
||||
session?: Session
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function Sidebar({ session, children }: SidebarProps) {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" className="-ml-2 h-9 w-9 p-0">
|
||||
<SidebarIcon className="h-6 w-6" />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
position="left"
|
||||
className="inset-y-0 flex h-auto w-[300px] flex-col gap-0 rounded-r-lg p-0 lg:inset-y-2"
|
||||
>
|
||||
<SheetHeader className="p-4">
|
||||
<SheetTitle className="text-sm">Chat History</SheetTitle>
|
||||
</SheetHeader>
|
||||
{children}
|
||||
<div className="flex items-center p-4">
|
||||
<ThemeToggle />
|
||||
{session?.user && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => signOut()}
|
||||
className="ml-auto"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
14
components/tailwind-indicator.tsx
Normal file
14
components/tailwind-indicator.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export function TailwindIndicator() {
|
||||
if (process.env.NODE_ENV === 'production') return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-1 left-1 z-50 flex h-6 w-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,33 +1,31 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTransition } from 'react'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme()
|
||||
const [_, startTransition] = useTransition()
|
||||
const [_, startTransition] = React.useTransition()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="p-0 leading-4 h-4 font-normal w-full"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span className="flex flex-row justify-between text-xs text-zinc-900 dark:text-zinc-400 w-full">
|
||||
<span className="">Toggle theme</span>
|
||||
{!theme ? null : theme === 'dark' ? (
|
||||
<Moon className="h-4 transition-all" />
|
||||
<Moon className="h-4 w-4 transition-all" />
|
||||
) : (
|
||||
<Sun className="h-4 transition-all" />
|
||||
<Sun className="h-4 w-4 transition-all" />
|
||||
)}
|
||||
</span>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
150
components/ui/alert-dialog.tsx
Normal file
150
components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
|
||||
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
</AlertDialogPrimitive.Portal>
|
||||
)
|
||||
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-opacity animate-in fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel
|
||||
}
|
||||
|
|
@ -1,28 +1,30 @@
|
|||
import * as React from 'react'
|
||||
import { VariantProps, cva } from 'class-variance-authority'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium shadow ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-md hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'underline-offset-4 hover:underline text-primary'
|
||||
ghost: 'shadow-none hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 shadow-none hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 py-2 px-4',
|
||||
sm: 'h-9 px-3 rounded-md',
|
||||
lg: 'h-11 px-8 rounded-md',
|
||||
xs: 'h-6 px-3 rounded-md'
|
||||
default: 'h-8 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-8 w-8 p-0'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -34,12 +36,15 @@ const buttonVariants = cva(
|
|||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<button
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
'use client'
|
||||
import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-cliipboard'
|
||||
import { Check, Clipboard, Download } from 'lucide-react'
|
||||
|
||||
import { FC, memo } from 'react'
|
||||
import { Check, Clipboard, Download } from 'lucide-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-cliipboard'
|
||||
|
||||
interface Props {
|
||||
language: string
|
||||
value: string
|
||||
|
|
@ -124,4 +126,4 @@ const CodeBlock: FC<Props> = memo(({ language, value }) => {
|
|||
})
|
||||
CodeBlock.displayName = 'CodeBlock'
|
||||
|
||||
export default CodeBlock
|
||||
export { CodeBlock }
|
||||
|
|
|
|||
128
components/ui/dialog.tsx
Normal file
128
components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DialogPrimitive.DialogPortalProps) => (
|
||||
<DialogPrimitive.Portal className={cn(className)} {...props}>
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
</DialogPrimitive.Portal>
|
||||
)
|
||||
DialogPortal.displayName = DialogPrimitive.Portal.displayName
|
||||
|
||||
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-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in',
|
||||
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 z-50 grid w-full gap-4 rounded-b-lg border bg-background p-6 shadow-sm animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0',
|
||||
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">
|
||||
<X className="h-4 w-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,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
}
|
||||
200
components/ui/dropdown-menu.tsx
Normal file
200
components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-sm animate-in 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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup
|
||||
}
|
||||
25
components/ui/input.tsx
Normal file
25
components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { signIn } from '@auth/nextjs/client'
|
||||
|
||||
export function Login() {
|
||||
return (
|
||||
<button
|
||||
className="inline-flex w-full items-center justify-center rounded border border-zinc-800 bg-white h-8 px-4 -my-1.5 text-sm leading-6 tracking-tight text-zinc-900 transition-colors ease-in-out hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => signIn('github')}
|
||||
>
|
||||
<span className="font-medium">Login</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { useId } from 'react'
|
||||
|
||||
export function NextChatLogo(
|
||||
props: JSX.IntrinsicElements['svg'] & { inverted?: boolean }
|
||||
) {
|
||||
const id = useId()
|
||||
return (
|
||||
<svg
|
||||
width={17}
|
||||
height={17}
|
||||
viewBox="0 0 17 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`gradient-${id}-1`}
|
||||
x1="10.6889"
|
||||
y1="10.3556"
|
||||
x2="13.8445"
|
||||
y2="14.2667"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor={props.inverted ? 'white' : 'black'} />
|
||||
<stop
|
||||
offset={1}
|
||||
stopColor={props.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={props.inverted ? 'white' : 'black'} />
|
||||
<stop
|
||||
offset={1}
|
||||
stopColor={props.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={props.inverted ? 'black' : 'white'}
|
||||
stroke={props.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={props.inverted ? 'black' : 'white'} />
|
||||
</mask>
|
||||
<g mask="url(#mask0_91_2047)">
|
||||
<circle cx={9} cy={8} r={8} fill={props.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>
|
||||
)
|
||||
}
|
||||
|
||||
NextChatLogo.displayName = 'DevGPT-logo'
|
||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
'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 }
|
||||
145
components/ui/sheet.tsx
Normal file
145
components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SheetPrimitive.DialogPortalProps) => (
|
||||
<SheetPrimitive.Portal
|
||||
className={cn('fixed inset-0 z-50 flex', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SheetPrimitive.Portal>
|
||||
)
|
||||
SheetPortal.displayName = SheetPrimitive.Portal.displayName
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 h-full scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg',
|
||||
{
|
||||
variants: {
|
||||
position: {
|
||||
left: 'data-[state=closed]:animate-slide-to-left data-[state=open]:animate-slide-from-left',
|
||||
right: 'h-full animate-in slide-in-from-right duration-300'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
position: 'left'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ position, className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ position }), 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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = 'SheetHeader'
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = 'SheetFooter'
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription
|
||||
}
|
||||
24
components/ui/textarea.tsx
Normal file
24
components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
30
components/ui/tooltip.tsx
Normal file
30
components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm font-medium text-popover-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
'use client'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { signOut } from '@auth/nextjs/client'
|
||||
import { type Session } from '@auth/nextjs/types'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import Image from 'next/image'
|
||||
|
||||
export interface UserMenuProps {
|
||||
session: Session
|
||||
}
|
||||
|
||||
export function UserMenu({ session }: UserMenuProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button className="focus:outline-none">
|
||||
{session.user?.image ? (
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
className="h-6 w-6 rounded-full select-none ring-zinc-100/10 ring-1 hover:opacity-80 transition-opacity duration-300"
|
||||
src={session.user?.image ? `${session.user.image}&s=60` : ''}
|
||||
alt={session.user.name ?? 'Avatar'}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-r from-cyan-500 to-blue-500 p-2 select-none">
|
||||
<div
|
||||
className="font-medium uppercase text-zinc-100"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{session.user?.name ? session.user?.name.slice(0, 2) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="min-w-[200px] bg-white dark:bg-zinc-950 rounded-lg shadow-lg text-zinc-900 dark:text-zinc-400 overflow-hidden border border-transparent dark:border-zinc-700 focus:outline-none relative z-20 py-2"
|
||||
sideOffset={8}
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenu.Item className="py-2 px-3 focus:outline-none">
|
||||
<div className="text-xs font-medium">{session.user?.name}</div>
|
||||
<div className="text-xs text-zinc-500">{session.user?.email}</div>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="my-1 h-[1px] bg-zinc-100 dark:bg-zinc-800" />
|
||||
<DropdownMenu.Item className="py-2 px-3 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors duration-200 cursor-pointer text-xs focus:outline-none">
|
||||
<ThemeToggle />
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator className="my-1 h-[1px] bg-zinc-100 dark:bg-zinc-800" />
|
||||
<DropdownMenu.Item className="py-2 px-3 hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors duration-200 cursor-pointer text-xs focus:outline-none ">
|
||||
<a
|
||||
href="https://vercel.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-between w-full"
|
||||
>
|
||||
<span>Vercel Homepage</span>
|
||||
<span>
|
||||
<svg
|
||||
fill="none"
|
||||
height={16}
|
||||
shapeRendering="geometricPrecision"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width={16}
|
||||
aria-hidden="true"
|
||||
className="mr-1"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" />
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14L21 3" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="py-2 px-3 hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors duration-200 cursor-pointer text-xs focus:outline-none"
|
||||
onClick={() => signOut()}
|
||||
>
|
||||
Log Out
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
UserMenu.displayName = 'UserMenu'
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const VercelLogo = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
height={22}
|
||||
viewBox="0 0 235 203"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Vercel Logo"
|
||||
className={cn(className, 'dark:fill-white fill-black')}
|
||||
>
|
||||
<path d="M117.082 0L234.164 202.794H0L117.082 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
37
components/user-menu.tsx
Normal file
37
components/user-menu.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { signIn } from '@auth/nextjs/client'
|
||||
import { type Session } from '@auth/nextjs/types'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export interface UserMenuProps {
|
||||
session?: Session
|
||||
}
|
||||
|
||||
export function UserMenu({ session }: UserMenuProps) {
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsLoading(true)
|
||||
signIn('github')
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Login
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="px-2 text-sm font-medium">Logged in as {session.user.name}</p>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { NextRequest, NextFetchEvent } from 'next/server'
|
||||
import type { NextApiRequest } from 'next'
|
||||
import type { NextFetchEvent, NextRequest } from 'next/server'
|
||||
|
||||
export const initAnalytics = ({
|
||||
request,
|
||||
|
|
|
|||
12
lib/fonts.ts
12
lib/fonts.ts
|
|
@ -1,20 +1,10 @@
|
|||
import {
|
||||
JetBrains_Mono as FontMono,
|
||||
IBM_Plex_Sans as FontMessage,
|
||||
Inter as FontSans
|
||||
} from 'next/font/google'
|
||||
import { JetBrains_Mono as FontMono, Inter as FontSans } from 'next/font/google'
|
||||
|
||||
export const fontSans = FontSans({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans'
|
||||
})
|
||||
|
||||
export const fontMessage = FontMessage({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600'],
|
||||
variable: '--font-message'
|
||||
})
|
||||
|
||||
export const fontMono = FontMono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono'
|
||||
|
|
|
|||
11
lib/hooks/use-chat-store.ts
Normal file
11
lib/hooks/use-chat-store.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
interface UseChatStore {
|
||||
defaultMessage: string
|
||||
setDefaultMessage: (message: string) => void
|
||||
}
|
||||
|
||||
export const useChatStore = create<UseChatStore>()(set => ({
|
||||
defaultMessage: '',
|
||||
setDefaultMessage: message => set({ defaultMessage: message })
|
||||
}))
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface useCopyToClipboardProps {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { RefObject } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { useRef, type RefObject } from 'react'
|
||||
|
||||
export function useCmdEnterSubmit(): {
|
||||
export function useEnterSubmit(): {
|
||||
formRef: RefObject<HTMLFormElement>
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
} {
|
||||
|
|
@ -10,8 +9,9 @@ export function useCmdEnterSubmit(): {
|
|||
const handleKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLTextAreaElement>
|
||||
): void => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
formRef.current?.requestSubmit()
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function useWindowSize() {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { auth as middleware } from "./auth";
|
||||
export { auth as middleware } from './auth'
|
||||
|
|
|
|||
26
package.json
26
package.json
|
|
@ -16,7 +16,12 @@
|
|||
"dependencies": {
|
||||
"@auth/core": "0.0.0-manual.527fff6c",
|
||||
"@auth/nextjs": "0.0.0-manual.030e8328",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@vercel/analytics": "^1.0.0",
|
||||
"@vercel/kv": "^0.2.1",
|
||||
"ai-connector": "^0.0.5",
|
||||
|
|
@ -26,7 +31,7 @@
|
|||
"focus-trap-react": "^10.1.1",
|
||||
"lucide-react": "0.216.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.5-canary.3",
|
||||
"next": "13.4.5-canary.12",
|
||||
"next-themes": "^0.2.1",
|
||||
"openai-edge": "^0.5.1",
|
||||
"react": "^18.2.0",
|
||||
|
|
@ -36,7 +41,8 @@
|
|||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-math": "^5.1.1"
|
||||
"remark-math": "^5.1.1",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
|
|
@ -44,30 +50,24 @@
|
|||
"@types/react": "^18.0.22",
|
||||
"@types/react-dom": "^18.0.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.6",
|
||||
"@typescript-eslint/parser": "^5.59.7",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"dotenv": "^16.0.3",
|
||||
"drizzle-kit": "^0.18.0",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-config-next": "13.4.5-canary.3",
|
||||
"eslint-config-next": "13.4.5-canary.12",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-tailwindcss": "^3.12.0",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^2.7.1",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"typescript": "^4.9.3"
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@auth/core": "0.0.0-manual.527fff6c"
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "avoid",
|
||||
"trailingComma": "none"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
738
pnpm-lock.yaml
generated
738
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
34
prettier.config.cjs
Normal file
34
prettier.config.cjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** @type {import('prettier').Config} */
|
||||
module.exports = {
|
||||
endOfLine: 'lf',
|
||||
semi: false,
|
||||
useTabs: false,
|
||||
singleQuote: true,
|
||||
arrowParens: 'avoid',
|
||||
tabWidth: 2,
|
||||
trailingComma: 'none',
|
||||
importOrder: [
|
||||
'^(react/(.*)$)|^(react$)',
|
||||
'^(next/(.*)$)|^(next$)',
|
||||
'<THIRD_PARTY_MODULES>',
|
||||
'',
|
||||
'^types$',
|
||||
'^@/types/(.*)$',
|
||||
'^@/config/(.*)$',
|
||||
'^@/lib/(.*)$',
|
||||
'^@/hooks/(.*)$',
|
||||
'^@/components/ui/(.*)$',
|
||||
'^@/components/(.*)$',
|
||||
'^@/registry/(.*)$',
|
||||
'^@/styles/(.*)$',
|
||||
'^@/app/(.*)$',
|
||||
'',
|
||||
'^[./]'
|
||||
],
|
||||
importOrderSeparation: false,
|
||||
importOrderSortSpecifiers: true,
|
||||
importOrderBuiltinModulesToTop: true,
|
||||
importOrderParserPlugins: ['typescript', 'jsx', 'decorators-legacy'],
|
||||
importOrderMergeDuplicateImports: true,
|
||||
importOrderCombineTypeAndValueImports: true
|
||||
}
|
||||
|
|
@ -1,36 +1,99 @@
|
|||
const { fontFamily } = require("tailwindcss/defaultTheme");
|
||||
const { fontFamily } = require('tailwindcss/defaultTheme')
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
|
||||
darkMode: ['class'],
|
||||
content: ['app/**/*.{ts,tsx}', 'components/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
'2xl': '1400px'
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||
sans: ['var(--font-sans)', ...fontFamily.sans]
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: `var(--radius)`,
|
||||
md: `calc(var(--radius) - 2px)`,
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['var(--font-sans)', ...fontFamily.sans]
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
'accordion-down': {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 'var(--radix-accordion-content-height)' }
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: 0 }
|
||||
},
|
||||
'slide-from-left': {
|
||||
'0%': {
|
||||
transform: 'translateX(-100%)'
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(0)'
|
||||
}
|
||||
},
|
||||
'slide-to-left': {
|
||||
'0%': {
|
||||
transform: 'translateX(0)'
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(-100%)'
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
'slide-from-left':
|
||||
'slide-from-left 0.4s cubic-bezier(0.82, 0.085, 0.395, 0.895)',
|
||||
'slide-to-left':
|
||||
'slide-to-left 0.25s cubic-bezier(0.82, 0.085, 0.395, 0.895)',
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
};
|
||||
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue