├── .env.example ├── .eslintrc.json ├── .gitignore ├── .npmrc ├── .prettierrc.json ├── .velite ├── docs.json ├── index.d.ts └── index.js ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── actions ├── chat.client.ts ├── chat.ts ├── conversation.ts └── tools.ts ├── app ├── (private-layout) │ ├── chat │ │ └── [id] │ │ │ ├── chat-sidebar.mobile.tsx │ │ │ ├── chat-sidebar.tsx │ │ │ ├── chat.tsx │ │ │ └── page.tsx │ └── tools │ │ ├── layout.tsx │ │ └── page.tsx ├── (public-layout) │ ├── (home) │ │ └── page.tsx │ ├── about │ │ ├── about-us.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── pricing │ │ └── page.tsx │ ├── refund-policy │ │ └── page.tsx │ └── subscription-terms │ │ └── page.tsx ├── api │ └── auth │ │ └── [...nextauth] │ │ └── route.ts ├── apple-icon.png ├── error.tsx ├── favicon.ico ├── globals.css ├── icon.png ├── icon.svg ├── layout.tsx ├── login │ └── page.tsx ├── manifest.json └── not-found.tsx ├── components.json ├── components ├── chat-client-fetch.tsx ├── conversations │ └── delete-conversation.tsx ├── footer.tsx ├── login-form.tsx ├── logo.tsx ├── mdx │ ├── callout.tsx │ ├── code-block-wrapper.tsx │ ├── codeblock.tsx │ ├── component-source.tsx │ ├── components.tsx │ ├── copy-button.tsx │ ├── mdx-content-renderer.tsx │ ├── toc.tsx │ └── zoomable-media.tsx ├── nagative.client.tsx ├── navbar.tsx ├── plan │ ├── plan-card.tsx │ ├── tabs.tsx │ ├── token-card.tsx │ ├── upgrade-chat.tsx │ └── yealy-card.tsx ├── profile.tsx ├── session-provider.tsx ├── settings-panel.tsx ├── sign-out.tsx ├── signout-btn.tsx ├── submit.tsx ├── subscriptions.tsx ├── theme-provider.tsx ├── toggle.tsx ├── tools │ ├── create-tool-button.tsx │ ├── tool-dialog.tsx │ ├── tool-item.tsx │ └── tool-skeleton.tsx └── ui │ ├── accordion.tsx │ ├── alert.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── files.tsx │ ├── input.tsx │ ├── label.tsx │ ├── progress.tsx │ ├── responsive-dialog.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ └── use-toast.ts ├── config └── site.config.ts ├── constant ├── config.ts └── plan.ts ├── docker-compose.yml ├── hooks ├── use-mobile.tsx └── use-mounted.tsx ├── lib ├── auth.ts ├── mobile.ts └── utils.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prisma ├── client.ts ├── migrations │ └── migration_lock.toml └── schema.prisma ├── public ├── robots.txt └── sitemap.xml ├── store └── chat.store.ts ├── tailwind.config.ts ├── tsconfig.json ├── types └── index.ts └── velite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | # Authentication 2 | AUTH_SECRET=your_auth_secret_here 3 | AUTH_GOOGLE_ID=your_google_client_id_here 4 | AUTH_GOOGLE_SECRET=your_google_client_secret_here 5 | AUTH_RESEND_KEY="your_resend_api_key_here" 6 | AUTH_GITHUB_ID="your_github_client_id_here" 7 | AUTH_GITHUB_SECRET="your_github_client_secret_here" 8 | 9 | # Database 10 | # The connection URL with Prisma support for connection pooling 11 | POSTGRES_PRISMA_URL=your_pooled_connection_url_here 12 | 13 | # A direct connection to the database. Used for migrations 14 | POSTGRES_URL_NON_POOLING=your_non_pooled_connection_url_here 15 | 16 | 17 | # Base 18 | NEXTAUTH_URL="http://localhost:3000" 19 | NEXT_PUBLIC_RANDOM_ID_CHARACTERS="dadsawdawdawdawdadwa" 20 | NEXT_PUBLIC_OPENROUTER_API_KEY="your_open_router_key" 21 | 22 | 23 | # Pricing Plans (Yearly) 24 | NEXT_PUBLIC_YEARLY_ESSENTIAL_PLAN_ID=your_yearly_essential_plan_id_here 25 | NEXT_PUBLIC_YEARLY_PRO_PLAN_ID=your_yearly_pro_plan_id_here 26 | NEXT_PUBLIC_YEARLY_ULTIMATE_PLAN_ID=your_yearly_ultimate_plan_id_here 27 | 28 | # Pricing Plans (Monthly) 29 | NEXT_PUBLIC_MONTHLY_ESSENTIAL_PLAN_ID=your_monthly_essential_plan_id_here 30 | NEXT_PUBLIC_MONTHLY_PRO_PLAN_ID=your_monthly_pro_plan_id_here 31 | NEXT_PUBLIC_MONTHLY_ULTIMATE_PLAN_ID=your_monthly_ultimate_plan_id_here 32 | 33 | # Token Packages 34 | NEXT_PUBLIC_TOKEN_PACKAGE_300K_ID=your_token_package_300k_id_here 35 | NEXT_PUBLIC_TOKEN_PACKAGE_1M_ID=your_token_package_1m_id_here 36 | NEXT_PUBLIC_TOKEN_PACKAGE_2M_ID=your_token_package_2m_id_here 37 | 38 | # Other API Keys (if any) 39 | # NEXT_PUBLIC_SOME_API_KEY=your_api_key_here 40 | 41 | # Add any other environment variables your application uses -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript", "prettier"], 3 | "plugins": ["import", "unused-imports"], 4 | "rules": { 5 | "react/no-unescaped-entities": "off", 6 | "no-console": ["error", { "allow": ["error", "warn"] }], 7 | "@typescript-eslint/no-unused-vars": "off", 8 | "@typescript-eslint/no-explicit-any": "off", 9 | "@typescript-eslint/no-empty-object-type": "off", 10 | "import/order": [ 11 | "error", 12 | { 13 | "groups": ["builtin", "external", "internal", ["parent", "sibling"], "index", "object", "type"], 14 | "newlines-between": "always", 15 | "alphabetize": { "order": "asc", "caseInsensitive": true } 16 | } 17 | ], 18 | "import/first": "error", 19 | "import/newline-after-import": "error", 20 | "import/no-duplicates": "error", 21 | "unused-imports/no-unused-imports": "error", 22 | "unused-imports/no-unused-vars": [ 23 | "warn", 24 | { "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | shell-emulator=true 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "none", 5 | "tabWidth": 2, 6 | "plugins": ["prettier-plugin-tailwindcss"], 7 | "printWidth": 120, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /.velite/docs.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /.velite/index.d.ts: -------------------------------------------------------------------------------- 1 | // This file is generated by Velite 2 | 3 | import type __vc from '../velite.config.ts' 4 | 5 | type Collections = typeof __vc.collections 6 | 7 | export type Docs = Collections['docs']['schema']['_output'] 8 | export declare const docs: Docs[] 9 | -------------------------------------------------------------------------------- /.velite/index.js: -------------------------------------------------------------------------------- 1 | // This file is generated by Velite 2 | 3 | export { default as docs } from './docs.json' -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "always" 5 | }, 6 | "editor.formatOnSave": true, 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "[typescript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[typescriptreact]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker.io/docker/dockerfile:1 2 | 3 | FROM node:22-alpine AS base 4 | 5 | RUN apk add --no-cache \ 6 | libc6-compat \ 7 | openssl \ 8 | openssl-dev \ 9 | build-base 10 | 11 | RUN npm install -g pnpm 12 | 13 | FROM base AS deps 14 | WORKDIR /app 15 | 16 | COPY package.json pnpm-lock.yaml prisma ./ 17 | 18 | RUN pnpm install --frozen-lockfile 19 | 20 | FROM base AS builder 21 | WORKDIR /app 22 | 23 | COPY --from=deps /app/node_modules ./node_modules 24 | COPY . . 25 | RUN npx prisma generate 26 | 27 | RUN pnpm run build 28 | 29 | FROM base AS runner 30 | WORKDIR /app 31 | 32 | ENV NODE_ENV=production 33 | 34 | RUN addgroup --system --gid 1001 nodejs 35 | RUN adduser --system --uid 1001 nextjs 36 | 37 | COPY --from=builder /app/.next/standalone ./ 38 | COPY --from=builder /app/.next/static ./.next/static 39 | COPY --from=builder /app/prisma ./prisma 40 | 41 | USER nextjs 42 | 43 | EXPOSE 3000 44 | ENV PORT=3000 45 | ENV HOSTNAME="0.0.0.0" 46 | 47 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js 15 AI Chat Application 2 | 3 | ## Project Overview 4 | 5 | This is a modern, efficient AI chat application built with Next.js 15, showcasing the latest features and best practices in web development. It's designed to be a rapid prototype for validating ideas, with a focus on simplicity, flexibility, and ease of deployment. 6 | 7 | ## Key Features and Advantages 8 | 9 | 1. **Next.js 15 Framework**: Leverages the latest features of Next.js, including React Server Components (RSC), Server Actions, and more. 10 | 11 | 2. **Minimalist Approach**: No additional request libraries included, allowing developers to integrate their preferred solutions (e.g., react-query, SWR, GraphQL, useRequest). 12 | 13 | 3. **Unified Frontend and Backend**: A monolithic approach that simplifies development, reduces context switching, and streamlines the idea validation process. 14 | 15 | 4. **Easy Deployment**: Includes Dockerfile and docker-compose configurations for straightforward deployment. Compatible with services like Cloudflare for quick setup. 16 | 17 | 5. **Customizable UI with Radix UI**: Utilizes Radix UI components as needed, offering a flexible and extensible design system. 18 | 19 | 6. **Core Business Logic**: Implements essential features including login, subscription management, payment processing, AI chat functionality, and necessary legal agreements (refund policy, user agreement, subscription terms, company information, contact details). 20 | 21 | 7. **Theme Support**: Includes both light and dark theme options for enhanced user experience. 22 | 23 | 8. **MDX Support**: Utilizes MDX for writing documentation and content, allowing for rich, interactive content with embedded React components. 24 | 25 | 9. **Content Management with Velite**: Integrates Velite for efficient content management, enabling easy creation and organization of documentation and other content. 26 | 27 | ## Tech Stack 28 | 29 | - Next.js 15 30 | - React 31 | - TypeScript 32 | - Radix UI 33 | - Prisma 34 | - NextAuth 35 | - OpenAI / OpenRouter 36 | - PostgreSQL 37 | - Docker 38 | - Velite 39 | 40 | ## Getting Started 41 | 42 | ### Prerequisites 43 | 44 | - Node.js (v18 or later) 45 | - npm, yarn, or pnpm 46 | - Docker and docker-compose (for deployment) 47 | - PostgreSQL database 48 | 49 | ### Installation 50 | 51 | 1. Clone the repository: 52 | 53 | ```bash 54 | git clone https://github.com/Shiinama/easy-business-ai 55 | cd easy-business-ai 56 | ``` 57 | 58 | 2. Install dependencies: 59 | 60 | ```bash 61 | npm install 62 | # or 63 | yarn install 64 | # or 65 | pnpm install 66 | ``` 67 | 68 | 3. Set up environment variables: 69 | Create a `.env` file in the root directory and add the necessary environment variables (database URL, API keys, etc.). 70 | 71 | 4. Set up the database: 72 | 73 | ```bash 74 | npx prisma prisma generate 75 | npx prisma db push 76 | ``` 77 | 78 | 5. Run the development server: 79 | 80 | ```bash 81 | npm run dev 82 | # or 83 | yarn dev 84 | # or 85 | pnpm dev 86 | ``` 87 | 88 | 6. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 89 | 90 | ## Deployment 91 | 92 | 1. Build the Docker image: 93 | 94 | ```bash 95 | docker build -t your-app-name . 96 | ``` 97 | 98 | 2. Run the application using docker-compose: 99 | 100 | ```bash 101 | docker-compose up -d 102 | ``` 103 | 104 | 3. Configure your server's security group to allow incoming traffic. 105 | 106 | 4. Add an A record in Cloudflare pointing to your server's IP address. 107 | 108 | Certainly! I'll update the Project Structure section of the README based on the current project structure. Here's the updated section: 109 | 110 | **File: /Users/weishunyu/ChatGPT/README.md** 111 | 112 | ## Project Structure 113 | 114 | The project follows a well-organized structure: 115 | 116 | ``` 117 | 118 | easy-business-ai/ 119 | ├── .velite/ 120 | ├── actions/ 121 | ├── app/ 122 | │ ├── (private)/ 123 | │ │ └── chat/ 124 | │ ├── (public)/ 125 | │ │ ├── login/ 126 | │ │ └── register/ 127 | │ ├── api/ 128 | │ └── layout.tsx 129 | ├── content/ 130 | │ └── docs/ 131 | ├── components/ 132 | │ ├── ui/ 133 | │ └── [other component folders] 134 | ├── constant/ 135 | ├── lib/ 136 | ├── prisma/ 137 | │ └── schema.prisma 138 | ├── public/ 139 | ├── script/ 140 | ├── store/ 141 | ├── types/ 142 | ├── .env 143 | ├── .gitignore 144 | ├── Dockerfile 145 | ├── LICENSE 146 | ├── README.md 147 | ├── components.json 148 | ├── docker-compose.yml 149 | ├── next.config.mjs 150 | ├── package.json 151 | ├── postcss.config.js 152 | ├── tailwind.config.ts 153 | ├── velite.config.ts 154 | └── tsconfig.json 155 | 156 | ``` 157 | 158 | - `actions/`: Contains Next Server Actions and Clint Actions. 159 | - `app/`: Main application directory with layouts and pages. 160 | - `(private)/`: Private routes and components. 161 | - `(public)/`: Public routes and components. 162 | - `api/`: API routes for auth. 163 | - `components/`: Reusable UI components. 164 | - `constant/`: Constant values used throughout the application. 165 | - `lib/`: Shared libraries and utilities. 166 | - `prisma/`: Prisma ORM configuration and schema. 167 | - `public/`: Static assets like images and icons. 168 | - `script/`: Custom scripts for project setup or maintenance. 169 | - `store/`: State management files (e.g., Redux). 170 | - `types/`: TypeScript type definitions. 171 | - `content/`: Contains MDX files for documentation and other content. 172 | - `docs/`: MDX files for documentation pages. 173 | - `.velite/`: Generated Velite output. 174 | - `velite.config.ts`: Configuration file for Velite content management. 175 | 176 | Key configuration files: 177 | 178 | - `Dockerfile` and `docker-compose.yml`: For containerization and deployment. 179 | - `next.config.mjs`: Next.js configuration. 180 | - `package.json`: Project dependencies and scripts. 181 | - `tailwind.config.ts`: Tailwind CSS configuration. 182 | - `tsconfig.json`: TypeScript configuration. 183 | 184 | ## Contributing 185 | 186 | Contributions are welcome! Please feel free to submit a Pull Request. 187 | 188 | ## License 189 | 190 | This project is licensed under the [MIT License](LICENSE). 191 | 192 | ## Contact Me 193 | 194 | If you need a more customized implementation or have interesting commercial projects, I'd love to hear from you. You can reach me via email: 195 | 196 | - Email: [contact@linkai.website](mailto:contact@linkai.website) 197 | 198 | Feel free to get in touch if you have any questions, need assistance with implementation, or want to discuss potential collaborations. I'm always open to exciting new projects and opportunities! 199 | -------------------------------------------------------------------------------- /actions/chat.client.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { createOpenRouter } from '@openrouter/ai-sdk-provider' 4 | import { MessageRole } from '@prisma/client' 5 | import { streamText } from 'ai' 6 | 7 | import { useToast } from '@/components/ui/use-toast' 8 | import { OPEN_ROUTER_API_KEY } from '@/constant/config' 9 | import { generateRandomId } from '@/lib/utils' 10 | import { DEFAULT_CHAT_SETTINGS, useChatStore } from '@/store/chat.store' 11 | 12 | import { checkUserTokenLimit, createMessage, updateUserTokenUsage } from './chat' 13 | import { getConversationsWithMessages } from './conversation' 14 | 15 | export type Message = { 16 | role: 'assistant' | 'user' 17 | content: string 18 | } 19 | 20 | export const useChat = (conversationId: string) => { 21 | const appendMessage = useChatStore((i) => i.appendMessage) 22 | const addSession = useChatStore((i) => i.addSession) 23 | const sessions = useChatStore((i) => i.sessionsMessages) 24 | const sessionsChatSettings = useChatStore((i) => i.sessionsChatSettings) 25 | const { toast } = useToast() 26 | 27 | async function createCompletion(message: string, scrollToBottom: () => void) { 28 | const isLimit = await checkUserTokenLimit() 29 | if (isLimit) { 30 | toast({ 31 | title: 'Token limit reached', 32 | description: 'Please upgrade your subscription to continue using the chat feature.' 33 | }) 34 | return 35 | } 36 | const useAssistantId = generateRandomId(24) 37 | const session = sessions[conversationId] 38 | const chatSettings = sessionsChatSettings[conversationId] ?? DEFAULT_CHAT_SETTINGS 39 | 40 | if (!session) { 41 | addSession({ 42 | conversationId, 43 | lastMessageId: null, 44 | messages: [ 45 | { 46 | id: useAssistantId, 47 | role: MessageRole.user, 48 | content: message 49 | } 50 | ] 51 | }) 52 | } else { 53 | appendMessage( 54 | { 55 | id: useAssistantId, 56 | role: MessageRole.user, 57 | content: message 58 | }, 59 | conversationId 60 | ) 61 | } 62 | 63 | createMessage({ 64 | id: useAssistantId, 65 | role: MessageRole.user, 66 | content: message, 67 | tokenCount: 0, 68 | conversationId: conversationId 69 | }) 70 | 71 | getConversationsWithMessages(conversationId) 72 | 73 | const openrouter = createOpenRouter({ 74 | apiKey: OPEN_ROUTER_API_KEY 75 | }) 76 | 77 | const systemMessage = { role: MessageRole.assistant, content: chatSettings.systemPrompt } 78 | const historyMessages = (session?.messages ?? []).map((i) => ({ ...i, role: i.role })) 79 | const userMessage = { role: MessageRole.user, content: message } 80 | 81 | const response = streamText({ 82 | model: openrouter(chatSettings.model), 83 | messages: [systemMessage, ...historyMessages, userMessage], 84 | maxTokens: chatSettings.maxTokens, 85 | temperature: chatSettings.temperature, 86 | topP: chatSettings.topP, 87 | frequencyPenalty: chatSettings.frequencyPenalty, 88 | presencePenalty: chatSettings.presencePenalty, 89 | maxRetries: 3 90 | }) 91 | 92 | const assistantId = generateRandomId(24) 93 | appendMessage( 94 | { 95 | id: assistantId, 96 | role: MessageRole.assistant, 97 | content: '' 98 | }, 99 | conversationId 100 | ) 101 | let str = '' 102 | 103 | setTimeout(() => { 104 | scrollToBottom() 105 | }, 200) 106 | 107 | try { 108 | for await (const textPart of response.textStream) { 109 | str += textPart 110 | requestAnimationFrame(() => { 111 | appendMessage( 112 | { 113 | id: assistantId, 114 | role: MessageRole.assistant, 115 | content: textPart 116 | }, 117 | conversationId 118 | ) 119 | }) 120 | } 121 | 122 | const usage = await response.usage 123 | 124 | const assistantTokenCount = usage.completionTokens 125 | 126 | createMessage({ 127 | id: assistantId, 128 | conversationId: conversationId, 129 | role: MessageRole.assistant, 130 | content: str, 131 | tokenCount: assistantTokenCount 132 | }) 133 | 134 | updateUserTokenUsage(usage.totalTokens) 135 | } catch (error) { 136 | console.error('Error in createCompletion:', error) 137 | toast({ 138 | title: 'Error occurred', 139 | description: 'An error occurred while processing your request. Please try switching to another model.', 140 | variant: 'destructive' 141 | }) 142 | 143 | // Remove the incomplete assistant message 144 | appendMessage( 145 | { 146 | id: assistantId, 147 | role: MessageRole.assistant, 148 | content: 'An error occurred. Please try again or switch to another model.' 149 | }, 150 | conversationId 151 | ) 152 | } 153 | } 154 | 155 | return { 156 | createCompletion 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /actions/chat.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { Prisma } from '@prisma/client' 4 | import { redirect } from 'next/navigation' 5 | 6 | import { OPEN_ROUTER_API_KEY } from '@/constant/config' 7 | import { auth } from '@/lib/auth' 8 | import prisma from '@/prisma/client' 9 | import { ChatSettings } from '@/store/chat.store' 10 | 11 | export async function checkUserTokenLimit(): Promise { 12 | const session = await auth() 13 | if (!session?.user) redirect('/login') 14 | 15 | const activeSubscription = await prisma.subscription.findFirst({ 16 | where: { 17 | userId: session.user.id, 18 | status: 'ACTIVE', 19 | endDate: { 20 | gte: new Date() 21 | } 22 | }, 23 | select: { 24 | usedTokens: true, 25 | totalTokens: true 26 | } 27 | }) 28 | 29 | if (!activeSubscription) { 30 | throw new Error('No active subscription found for the user') 31 | } 32 | 33 | const remainingTokens = activeSubscription.totalTokens - activeSubscription.usedTokens 34 | 35 | return remainingTokens <= 0 36 | } 37 | 38 | export async function updateUserTokenUsage(tokensUsed: number) { 39 | const session = await auth() 40 | if (!session?.user) redirect('/login') 41 | 42 | const activeSubscription = await prisma.subscription.findFirst({ 43 | where: { 44 | userId: session.user.id, 45 | status: 'ACTIVE', 46 | endDate: { 47 | gte: new Date() 48 | } 49 | }, 50 | orderBy: { 51 | endDate: 'desc' 52 | } 53 | }) 54 | 55 | if (!activeSubscription) { 56 | throw new Error('No active subscription found for the user') 57 | } 58 | 59 | const updatedSubscription = await prisma.subscription.update({ 60 | where: { 61 | id: activeSubscription.id 62 | }, 63 | data: { 64 | usedTokens: { 65 | increment: tokensUsed 66 | } 67 | } 68 | }) 69 | 70 | if (updatedSubscription.usedTokens > updatedSubscription.totalTokens) { 71 | console.warn(`User ${session.user.id} has exceeded their token limit`) 72 | } 73 | 74 | return updatedSubscription 75 | } 76 | 77 | export async function newChat(id: string, toolId: string) { 78 | const session = await auth() 79 | if (!session?.user) redirect('/login') 80 | 81 | await prisma.conversation.create({ 82 | data: { 83 | id, 84 | toolId: toolId 85 | } 86 | }) 87 | 88 | redirect(`/chat/${toolId}-${id}`) 89 | } 90 | 91 | export async function createMessage( 92 | data: Omit & { conversationId: string } 93 | ) { 94 | const session = await auth() 95 | if (!session?.user) redirect('/login') 96 | const { conversationId, ...messageData } = data 97 | 98 | try { 99 | const createdMessage = await prisma.message.create({ 100 | data: { 101 | ...messageData, 102 | conversation: { 103 | connect: { id: conversationId } 104 | } 105 | } 106 | }) 107 | 108 | return createdMessage 109 | } catch (error) { 110 | console.error('Failed to create message:', error) 111 | throw new Error('Failed to create message') 112 | } 113 | } 114 | 115 | export async function saveSettings(userId: string, settings: ChatSettings) { 116 | await prisma.user.update({ 117 | where: { id: userId }, 118 | data: { 119 | settings 120 | } 121 | }) 122 | } 123 | 124 | // Add this function to fetch available models from OpenRouter 125 | export async function getAvailableModels() { 126 | const response = await fetch('https://openrouter.ai/api/v1/models', { 127 | headers: { 128 | Authorization: `Bearer ${OPEN_ROUTER_API_KEY}` 129 | } 130 | }) 131 | 132 | if (!response.ok) { 133 | throw new Error('Failed to fetch models from OpenRouter') 134 | } 135 | 136 | const data = await response.json() 137 | return data.data.map((model: any) => ({ 138 | id: model.id, 139 | name: model.name 140 | })) 141 | } 142 | -------------------------------------------------------------------------------- /actions/conversation.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { revalidatePath } from 'next/cache' 4 | 5 | import { auth } from '@/lib/auth' 6 | import prisma from '@/prisma/client' 7 | 8 | export async function deleteConversation(id: string) { 9 | await prisma.conversation.delete({ 10 | where: { id } 11 | }) 12 | revalidatePath(`/chat/${id}`) 13 | } 14 | 15 | export async function getTool(id: string) { 16 | const session = await auth() 17 | if (!session?.user) return null 18 | 19 | const res = await prisma.tool.findUnique({ 20 | where: { 21 | id 22 | }, 23 | include: { 24 | conversations: { 25 | orderBy: { 26 | updatedAt: 'desc' 27 | }, 28 | take: 20, 29 | include: { 30 | messages: { 31 | orderBy: { 32 | createdAt: 'desc' 33 | }, 34 | take: 1, 35 | select: { 36 | role: true, 37 | id: true, 38 | content: true, 39 | createdAt: true 40 | } 41 | } 42 | } 43 | } 44 | } 45 | }) 46 | return res 47 | } 48 | 49 | export async function getConversationsWithMessages(id: string) { 50 | const res = await prisma.conversation.findUnique({ 51 | where: { 52 | id: id 53 | }, 54 | include: { 55 | messages: { 56 | orderBy: { 57 | createdAt: 'asc' 58 | } 59 | } 60 | } 61 | }) 62 | 63 | revalidatePath(`/conversation/${id}`) 64 | 65 | return res 66 | } 67 | -------------------------------------------------------------------------------- /actions/tools.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { revalidatePath } from 'next/cache' 4 | import { redirect } from 'next/navigation' 5 | import { z } from 'zod' 6 | 7 | import { auth } from '@/lib/auth' 8 | import prisma from '@/prisma/client' 9 | import { ChatSettings } from '@/store/chat.store' 10 | 11 | // Schema validation for tool data 12 | const toolSchema = z.object({ 13 | name: z.string().min(1, 'Tool name is required'), 14 | description: z.string().optional(), 15 | isPublic: z.boolean().optional().default(false), 16 | settings: z.object({ 17 | model: z.string(), 18 | systemPrompt: z.string(), 19 | openingMessage: z.string().optional() 20 | }) 21 | }) 22 | 23 | /** 24 | * Fetch tools based on the specified type 25 | * @param type 'my' for user's tools, 'all' for all public tools 26 | * @returns Array of tools 27 | */ 28 | export async function fetchTools(type: 'my' | 'all') { 29 | const session = await auth() 30 | if (!session?.user) redirect('/login') 31 | 32 | try { 33 | const where = type === 'my' ? { userId: session.user.id } : { isPublic: true } 34 | 35 | const tools = await prisma.tool.findMany({ 36 | where, 37 | include: { 38 | conversations: { 39 | orderBy: { 40 | updatedAt: 'desc' 41 | }, 42 | take: 1, 43 | select: { 44 | id: true 45 | } 46 | } 47 | }, 48 | orderBy: { updatedAt: 'desc' } 49 | }) 50 | 51 | return tools.map((tool) => { 52 | const lastConversation = tool.conversations[0] 53 | return { 54 | ...tool, 55 | lastConversationId: lastConversation?.id, 56 | conversations: undefined 57 | } 58 | }) 59 | } catch (error) { 60 | console.error('Failed to fetch tools:', error) 61 | throw new Error('Failed to fetch tools') 62 | } 63 | } 64 | /** 65 | * Create a new tool 66 | * @param data Tool data including name, description, and settings 67 | * @returns Created tool 68 | */ 69 | export async function createTool(data: { 70 | name: string 71 | description?: string 72 | isPublic?: boolean 73 | settings: Partial & { 74 | openingMessage?: string 75 | } 76 | }) { 77 | const session = await auth() 78 | if (!session?.user) redirect('/login') 79 | 80 | // Validate input data 81 | const validatedData = toolSchema.parse(data) 82 | 83 | try { 84 | const tool = await prisma.tool.create({ 85 | data: { 86 | name: validatedData.name, 87 | description: validatedData.description, 88 | isPublic: validatedData.isPublic || false, 89 | settings: { 90 | ...validatedData.settings, 91 | openingMessage: validatedData.settings.openingMessage || '' 92 | }, 93 | user: { 94 | connect: { id: session.user.id } 95 | } 96 | } 97 | }) 98 | revalidatePath('/tools') 99 | return tool 100 | } catch (error) { 101 | console.error('Failed to create tool:', error) 102 | throw new Error('Failed to create tool') 103 | } 104 | } 105 | 106 | /** 107 | * Update an existing tool 108 | * @param id Tool ID 109 | * @param data Updated tool data 110 | * @returns Updated tool 111 | */ 112 | export async function updateTool( 113 | id: string, 114 | data: { 115 | name: string 116 | description?: string 117 | isPublic?: boolean 118 | settings?: Partial & { 119 | openingMessage?: string 120 | } 121 | } 122 | ) { 123 | const session = await auth() 124 | if (!session?.user) redirect('/login') 125 | 126 | // Validate input data 127 | const validatedData = toolSchema.parse(data) 128 | 129 | try { 130 | // First check if the tool belongs to the current user 131 | const existingTool = await prisma.tool.findFirst({ 132 | where: { 133 | id, 134 | userId: session.user.id 135 | } 136 | }) 137 | 138 | if (!existingTool) { 139 | throw new Error('Tool not found or you do not have permission to update it') 140 | } 141 | 142 | const tool = await prisma.tool.update({ 143 | where: { id }, 144 | data: { 145 | name: validatedData.name, 146 | description: validatedData.description, 147 | ...(validatedData.isPublic !== undefined && { isPublic: validatedData.isPublic }), 148 | settings: { 149 | ...(existingTool.settings as any), 150 | ...validatedData.settings, 151 | openingMessage: validatedData.settings?.openingMessage || (existingTool.settings as any)?.openingMessage || '' 152 | } 153 | } 154 | }) 155 | revalidatePath('/tools') 156 | return tool 157 | } catch (error) { 158 | console.error('Failed to update tool:', error) 159 | throw new Error('Failed to update tool') 160 | } 161 | } 162 | 163 | /** 164 | * Delete a tool 165 | * @param id Tool ID 166 | * @returns Success status 167 | */ 168 | export async function deleteTool(id: string) { 169 | const session = await auth() 170 | if (!session?.user) redirect('/login') 171 | 172 | try { 173 | // First check if the tool belongs to the current user 174 | const existingTool = await prisma.tool.findFirst({ 175 | where: { 176 | id 177 | } 178 | }) 179 | 180 | if (!existingTool) { 181 | throw new Error('Tool not found or you do not have permission to delete it') 182 | } 183 | 184 | await prisma.tool.delete({ 185 | where: { id } 186 | }) 187 | revalidatePath('/tools') 188 | return { success: true } 189 | } catch (error) { 190 | console.error('Failed to delete tool:', error) 191 | throw new Error('Failed to delete tool') 192 | } 193 | } 194 | 195 | /** 196 | * Get a tool by ID 197 | * @param id Tool ID 198 | * @returns Tool or null if not found 199 | */ 200 | export async function getToolById(id: string) { 201 | const session = await auth() 202 | if (!session?.user) redirect('/login') 203 | 204 | try { 205 | const tool = await prisma.tool.findFirst({ 206 | where: { 207 | id, 208 | userId: session.user.id 209 | } 210 | }) 211 | return tool 212 | } catch (error) { 213 | console.error('Failed to get tool:', error) 214 | throw new Error('Failed to get tool') 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /app/(private-layout)/chat/[id]/chat-sidebar.mobile.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { formatDistanceToNow } from 'date-fns' 4 | import { Plus, MessageSquare, Settings } from 'lucide-react' 5 | import Link from 'next/link' 6 | import { useState } from 'react' 7 | 8 | import { newChat } from '@/actions/chat' 9 | import DeleteConversation from '@/components/conversations/delete-conversation' 10 | import { SettingsForm } from '@/components/settings-panel' 11 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' 12 | import { ScrollArea } from '@/components/ui/scroll-area' 13 | import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' 14 | import { generateRandomId } from '@/lib/utils' 15 | import { useChatStore } from '@/store/chat.store' 16 | 17 | interface ChatSidebarProps { 18 | toolId: string 19 | conversationId: string 20 | conversations: { 21 | id: string 22 | createdAt: Date 23 | updatedAt: Date 24 | messages: { 25 | role: 'assistant' | 'user' 26 | id: string 27 | createdAt: Date 28 | content: string 29 | }[] 30 | }[] 31 | settings: { 32 | systemPrompt: string 33 | model: string 34 | } 35 | } 36 | 37 | export default function MobileChatMenu({ toolId, conversations, settings, conversationId }: ChatSidebarProps) { 38 | const [isConversationsOpen, setIsConversationsOpen] = useState(false) 39 | const [isSettingsOpen, setIsSettingsOpen] = useState(false) 40 | const addSession = useChatStore((state) => state.addSession) 41 | 42 | const handleNewChat = () => { 43 | const chatId = generateRandomId(24) 44 | addSession({ 45 | conversationId: chatId, 46 | messages: [], 47 | lastMessageId: null 48 | }) 49 | newChat(chatId, toolId) 50 | } 51 | return ( 52 | <> 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | New Chat 61 | 62 | setIsConversationsOpen(true)}> 63 | 64 | Conversations 65 | 66 | setIsSettingsOpen(true)}> 67 | 68 | Chat Settings 69 | 70 | 71 | 72 | 73 | {/* Conversations Dialog */} 74 | 75 | 76 | 77 | Conversations 78 | 79 | 80 | 81 |
82 | {conversations.map((item) => { 83 | const isActive = conversationId === item.id 84 | 85 | // Get the first message or use a default title 86 | const firstUserMessage = item.messages.find((msg) => msg.role === 'user') 87 | const title = firstUserMessage 88 | ? firstUserMessage.content.substring(0, 25) + (firstUserMessage.content.length > 25 ? '...' : '') 89 | : 'New conversation' 90 | 91 | return ( 92 |
98 | setIsConversationsOpen(false)} 102 | > 103 |
104 |

{title}

105 |

106 | {formatDistanceToNow(new Date(item.updatedAt), { addSuffix: true })} 107 |

108 |
109 | 110 | {!isActive && ( 111 |
112 | 113 |
114 | )} 115 |
116 | ) 117 | })} 118 |
119 |
120 |
121 |
122 | 123 | 124 | 125 | 126 | Chat Settings 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | ) 135 | } 136 | -------------------------------------------------------------------------------- /app/(private-layout)/chat/[id]/chat-sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { formatDistanceToNow } from 'date-fns' 4 | import { Plus, Settings } from 'lucide-react' 5 | import Link from 'next/link' 6 | import { useState } from 'react' 7 | 8 | import { newChat } from '@/actions/chat' 9 | import DeleteConversation from '@/components/conversations/delete-conversation' 10 | import { SettingsForm } from '@/components/settings-panel' 11 | import { Button } from '@/components/ui/button' 12 | import { ResponsiveDialog } from '@/components/ui/responsive-dialog' 13 | import { ScrollArea } from '@/components/ui/scroll-area' 14 | import { generateRandomId } from '@/lib/utils' 15 | import { useChatStore } from '@/store/chat.store' 16 | 17 | interface ChatSidebarProps { 18 | toolId: string 19 | conversationId: string 20 | conversations: { 21 | id: string 22 | createdAt: Date 23 | updatedAt: Date 24 | messages: { 25 | role: 'assistant' | 'user' 26 | id: string 27 | createdAt: Date 28 | content: string 29 | }[] 30 | }[] 31 | settings: { 32 | systemPrompt: string 33 | model: string 34 | } 35 | } 36 | 37 | export default function ChatSidebar({ toolId, conversations, settings, conversationId }: ChatSidebarProps) { 38 | const addSession = useChatStore((state) => state.addSession) 39 | 40 | const [isSettingsOpen, setIsSettingsOpen] = useState(false) 41 | 42 | const handleNewChat = () => { 43 | const chatId = generateRandomId(24) 44 | addSession({ 45 | conversationId: chatId, 46 | messages: [], 47 | lastMessageId: null 48 | }) 49 | newChat(chatId, toolId) 50 | } 51 | 52 | return ( 53 |
54 |
55 |

Chats

56 | 59 |
60 | 61 | 62 |
63 | {conversations.map((item) => { 64 | const isActive = conversationId === item.id 65 | 66 | // Get the first message or use a default title 67 | const firstUserMessage = item.messages.find((msg) => msg.role === 'user') 68 | const title = firstUserMessage 69 | ? firstUserMessage.content.substring(0, 25) + (firstUserMessage.content.length > 25 ? '...' : '') 70 | : 'New conversation' 71 | 72 | return ( 73 |
79 | 80 |
81 |

{title}

82 |

83 | {formatDistanceToNow(new Date(item.updatedAt), { addSuffix: true })} 84 |

85 |
86 | 87 | {!isActive && ( 88 |
89 | 90 |
91 | )} 92 |
93 | ) 94 | })} 95 |
96 |
97 | 98 |
99 | setIsSettingsOpen(!isSettingsOpen)} 105 | > 106 | 107 | Chat Settings 108 | 109 | } 110 | > 111 | 112 | 113 |
114 |
115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /app/(private-layout)/chat/[id]/chat.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useRef, useEffect, useState } from 'react' 4 | import rehypeAutolinkHeadings from 'rehype-autolink-headings' 5 | import rehypeSlug from 'rehype-slug' 6 | import { remark } from 'remark' 7 | import remarkHtml from 'remark-html' 8 | 9 | import { useChat } from '@/actions/chat.client' 10 | import { getConversationsWithMessages } from '@/actions/conversation' 11 | import { CopyButton } from '@/components/mdx/copy-button' 12 | import Submit from '@/components/submit' 13 | import { Textarea } from '@/components/ui/textarea' 14 | import { ChatItem, DEFAULT_CHAT_SETTINGS, useChatStore } from '@/store/chat.store' 15 | 16 | type ChatProps = { 17 | conversationId: string 18 | settings: { 19 | systemPrompt: string 20 | model: string 21 | openingMessage?: string 22 | } 23 | lastMessageId?: string 24 | } 25 | 26 | export default function Chat({ conversationId, settings, lastMessageId }: ChatProps) { 27 | const scrollRef = useRef(null) 28 | 29 | const sessions = useChatStore((state) => state.sessionsMessages) 30 | const addSession = useChatStore((i) => i.addSession) 31 | const setChatSetting = useChatStore((state) => state.setChatSetting) 32 | const sessionsChatSettings = useChatStore((state) => state.sessionsChatSettings) 33 | const isInitialized = useChatStore((i) => i.isInitialized) 34 | 35 | const getMessages = async () => { 36 | const currentSession = sessions[conversationId] 37 | 38 | if (!currentSession) return 39 | 40 | if (lastMessageId && currentSession?.lastMessageId === lastMessageId) { 41 | return 42 | } 43 | 44 | try { 45 | const res = await getConversationsWithMessages(conversationId) 46 | 47 | if (res && res.messages.length > 0) { 48 | addSession({ 49 | conversationId, 50 | lastMessageId: res.messages[res.messages.length - 1].id, 51 | messages: (res.messages as any) || [] 52 | }) 53 | } 54 | } catch (error) { 55 | console.error('Failed to fetch messages:', error) 56 | } 57 | } 58 | 59 | useEffect(() => { 60 | if (isInitialized) getMessages() 61 | }, [isInitialized]) 62 | 63 | const messages = sessions[conversationId]?.messages 64 | 65 | const scrollToBottom = () => { 66 | if (scrollRef.current) { 67 | scrollRef.current.scrollTop = scrollRef.current.scrollHeight 68 | } 69 | } 70 | 71 | useEffect(() => { 72 | if (!sessionsChatSettings[conversationId]) { 73 | setChatSetting( 74 | { ...DEFAULT_CHAT_SETTINGS, model: settings.model, systemPrompt: settings.systemPrompt }, 75 | conversationId 76 | ) 77 | } 78 | }, []) 79 | 80 | if (!isInitialized) return 81 | 82 | return ( 83 |
84 |
88 |
89 | {!messages?.length ? ( 90 |
91 | 98 |
99 | ) : ( 100 | messages.map((message) => ) 101 | )} 102 |
103 |
104 | 105 |
106 | ) 107 | } 108 | 109 | type MessageItemProps = { 110 | message: ChatItem 111 | } 112 | 113 | function MessageItem({ message }: MessageItemProps) { 114 | const roleStyles: Record = { 115 | user: 'bg-secondary text-secondary-foreground shadow-md ml-auto', 116 | assistant: 'bg-primary text-primary-foreground shadow-md mr-auto' 117 | } 118 | const [isHovered, setIsHovered] = useState(false) 119 | 120 | const isUser = message.role === 'user' 121 | 122 | const renderedContent = remark() 123 | .use(rehypeSlug) 124 | .use(rehypeAutolinkHeadings) 125 | .use(remarkHtml) 126 | .processSync(message.content) 127 | .toString() 128 | 129 | return ( 130 |
setIsHovered(true)} 132 | onMouseLeave={() => setIsHovered(false)} 133 | className={`relative w-fit ${isUser ? 'self-end' : 'self-start'}`} 134 | > 135 |
139 |
142 | {message.content} 143 |
144 |
145 | ) 146 | } 147 | 148 | type ConversationComponent = { 149 | id: string 150 | scrollToBottom: () => void 151 | } 152 | 153 | function ChatInput({ id, scrollToBottom }: ConversationComponent) { 154 | const inputRef = useRef(null) 155 | const formRef = useRef(null) 156 | 157 | const { createCompletion } = useChat(id) 158 | 159 | async function handleSubmit(formData: FormData) { 160 | const message = formData.get('message') as string 161 | if (!message) return 162 | 163 | if (inputRef.current) { 164 | inputRef.current.value = '' 165 | } 166 | await createCompletion(message, scrollToBottom) 167 | } 168 | 169 | const handleKeyDown = (e: React.KeyboardEvent) => { 170 | if (e.key === 'Enter' && !e.shiftKey) { 171 | e.preventDefault() 172 | formRef.current?.requestSubmit() 173 | } 174 | } 175 | 176 | return ( 177 |
178 |