diff --git a/.changeset/strict-bushes-change.md b/.changeset/strict-bushes-change.md new file mode 100644 index 00000000..439fdd99 --- /dev/null +++ b/.changeset/strict-bushes-change.md @@ -0,0 +1,5 @@ +--- +"chatbot-with-billing-with-clerk": patch +--- + +feat(examples): new example with clerk auth diff --git a/examples/chatbot-with-billing-with-clerk/.env.example b/examples/chatbot-with-billing-with-clerk/.env.example new file mode 100644 index 00000000..57f8f9ed --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/.env.example @@ -0,0 +1,15 @@ +# Clerk keys — https://dashboard.clerk.com +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=**** +CLERK_SECRET_KEY=**** + +# required for non-vercel deployments, vercel uses OIDC automatically +# https://vercel.com/ai-gateway +AI_GATEWAY_API_KEY=**** + +# https://vercel.com/docs/postgres +POSTGRES_URL=**** + +# https://polar.sh — billing destination +# POLAR_SERVER can be 'sandbox' (default) or 'production' +POLAR_ACCESS_TOKEN=**** +POLAR_SERVER=sandbox diff --git a/examples/chatbot-with-billing-with-clerk/.github/workflows/lint.yml b/examples/chatbot-with-billing-with-clerk/.github/workflows/lint.yml new file mode 100644 index 00000000..9fd04890 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint +on: + push: + +jobs: + build: + runs-on: ubuntu-22.04 + strategy: + matrix: + node-version: [20] + steps: + - uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + - name: Run lint + run: pnpm run lint + - name: Run type check + run: pnpm run check-types diff --git a/examples/chatbot-with-billing-with-clerk/.github/workflows/playwright.yml b/examples/chatbot-with-billing-with-clerk/.github/workflows/playwright.yml new file mode 100644 index 00000000..faf9f4d0 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/.github/workflows/playwright.yml @@ -0,0 +1,58 @@ +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + timeout-minutes: 30 + runs-on: ubuntu-latest + env: + AUTH_SECRET: ${{ secrets.OSS_AUTH_SECRET }} + AI_GATEWAY_API_KEY: ${{ secrets.OSS_AI_GATEWAY_API_KEY }} + POSTGRES_URL: ${{ secrets.OSS_POSTGRES_URL }} + POLAR_ACCESS_TOKEN: ${{ secrets.OSS_POLAR_ACCESS_TOKEN }} + POLAR_SERVER: 'sandbox' + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 10.32.1 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Build application + run: pnpm run build + + - name: Cache Playwright browsers + uses: actions/cache@v3 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install Playwright Browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm run test:setup + + - name: Run Playwright tests + run: pnpm run test:e2e + + - uses: actions/upload-artifact@v4 + if: always() && !cancelled() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/examples/chatbot-with-billing-with-clerk/.github/workflows/pull-upstream.yml b/examples/chatbot-with-billing-with-clerk/.github/workflows/pull-upstream.yml new file mode 100644 index 00000000..6042f22e --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/.github/workflows/pull-upstream.yml @@ -0,0 +1,39 @@ +name: Pull Upstream Template + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + pull-and-update: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Run pull script + run: node scripts/pull-upstream.js + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: 'chore: sync upstream template from narevai/ai-billing' + branch: chore/sync-upstream + delete-branch: true + title: 'chore: sync upstream template from narevai/ai-billing' + body: 'Automated sync of upstream template changes from narevai/ai-billing.' diff --git a/examples/chatbot-with-billing-with-clerk/.gitignore b/examples/chatbot-with-billing-with-clerk/.gitignore new file mode 100644 index 00000000..b24a32e0 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/.gitignore @@ -0,0 +1,27 @@ +.pnpm-store/ +node_modules +.pnp +.pnp.js +coverage +.next/ +out/ +build +.DS_Store +*.pem +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.env.local +.env.development.local +.env.production.local +.turbo +.env +.vercel +.env*.local +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/* +next-env.d.ts +tsconfig.tsbuildinfo diff --git a/examples/chatbot-with-billing-with-clerk/.oxlintrc.json b/examples/chatbot-with-billing-with-clerk/.oxlintrc.json new file mode 100644 index 00000000..640a04b3 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/.oxlintrc.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "warn", + "suspicious": "warn" + }, + "rules": { + "eslint/no-shadow": "off", + "eslint/no-loss-of-precision": "off", + "eslint/no-unused-vars": "off" + }, + "ignorePatterns": ["**/dist/**", "**/coverage/**", "**/.next/**"] +} diff --git a/examples/chatbot-with-billing-with-clerk/README.md b/examples/chatbot-with-billing-with-clerk/README.md new file mode 100644 index 00000000..44eb8e09 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/README.md @@ -0,0 +1,69 @@ + + Chatbot with Billing +

Chatbot with AI Billing (Clerk)

+
+ +

+ This is an open-source template built with Next.js, the AI SDK, Clerk for authentication, and the ai-billing package. It helps you quickly build powerful chatbot applications with built-in monetization and billing capabilities. +

+ +

+ Read Docs · + Features · + Model Providers · + Deploy Your Own · + Running locally +

+
+ +## Features + +- **[ai-billing](https://narev.ai/docs/sdk/ai-billing)** + - Seamless monetization and billing integration for your AI applications + - Powered by [narev.ai](https://narev.ai) +- [Next.js](https://nextjs.org) App Router + - Advanced routing for seamless navigation and performance + - React Server Components (RSCs) and Server Actions for server-side rendering and increased performance +- [AI SDK](https://ai-sdk.dev/docs/introduction) + - Unified API for generating text, structured objects, and tool calls with LLMs + - Hooks for building dynamic chat and generative user interfaces + - Supports OpenAI, Anthropic, Google, xAI, and other model providers via AI Gateway +- [shadcn/ui](https://ui.shadcn.com) + - Styling with [Tailwind CSS](https://tailwindcss.com) + - Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility +- Data Persistence + - [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data +- [Clerk](https://clerk.com) + - Authentication with email verification code (passwordless sign-in) + +## Model Providers + +This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. Models are configured in `lib/ai/models.ts` with per-model provider routing. Included models: Mistral, Moonshot, DeepSeek, OpenAI, and xAI. + +### AI Gateway Authentication + +**For Vercel deployments**: Authentication is handled automatically via OIDC tokens. + +**For non-Vercel deployments**: You need to provide an AI Gateway API key by setting the `AI_GATEWAY_API_KEY` environment variable in your `.env.local` file. + +With the [AI SDK](https://ai-sdk.dev/docs/introduction), you can also switch to direct LLM providers like [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [many more](https://ai-sdk.dev/providers/ai-sdk-providers) with just a few lines of code. + +## Deploy Your Own + +You can deploy your own version of Chatbot with billing to Vercel with one click: + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fnarevai%2Fchatbot-with-billing&env=NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,CLERK_SECRET_KEY,AI_GATEWAY_API_KEY,POSTGRES_URL,POLAR_ACCESS_TOKEN,POLAR_SERVER&envDefaults=%7B%22POLAR_SERVER%22%3A%22sandbox%22%7D) + +## Running locally + +You will need to use the environment variables [defined in `.env.example`](.env.example) to run Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/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 AI and authentication provider accounts. + +```bash +pnpm install +pnpm db:migrate # Setup database or apply latest database changes +pnpm dev +``` + +Your app template should now be running on [localhost:3000](http://localhost:3000). diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/actions.ts b/examples/chatbot-with-billing-with-clerk/app/(chat)/actions.ts new file mode 100644 index 00000000..455b97c6 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/actions.ts @@ -0,0 +1,50 @@ +'use server'; + +import { generateText, type UIMessage } from 'ai'; +import { createOpenAI } from '@ai-sdk/openai'; +import { auth } from '@clerk/nextjs/server'; +import type { VisibilityType } from '@/components/chat/visibility-selector'; +import { titlePrompt } from '@/lib/ai/prompts'; +import { + getChatById, + getUserId, + updateChatVisibilityById, +} from '@/lib/db/queries'; +import { getTextFromMessage } from '@/lib/utils'; + +export async function generateTitleFromUserMessage({ + message, +}: { + message: UIMessage; +}) { + const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY }); + const { text } = await generateText({ + model: openai('gpt-4o-mini'), + system: titlePrompt, + prompt: getTextFromMessage(message), + }); + return text + .replace(/^[#*"\s]+/, '') + .replace(/["]+$/, '') + .trim(); +} + +export async function updateChatVisibility({ + chatId, + visibility, +}: { + chatId: string; + visibility: VisibilityType; +}) { + const dbUserId = await getUserId(); + if (!dbUserId) { + throw new Error('Unauthorized'); + } + + const chat = await getChatById({ id: chatId }); + if (!chat || chat.userId !== dbUserId) { + throw new Error('Unauthorized'); + } + + await updateChatVisibilityById({ chatId, visibility }); +} diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/api/chat/route.ts b/examples/chatbot-with-billing-with-clerk/app/(chat)/api/chat/route.ts new file mode 100644 index 00000000..f7c9a45e --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/api/chat/route.ts @@ -0,0 +1,33 @@ +import { auth } from '@clerk/nextjs/server'; +import { deleteChatById, getChatById, getUserId } from '@/lib/db/queries'; +import { ChatbotError } from '@/lib/errors'; + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return new ChatbotError('bad_request:api').toResponse(); + } + + const { userId } = await auth(); + + if (!userId) { + return new ChatbotError('unauthorized:chat').toResponse(); + } + + const dbUserId = await getUserId(); + if (!dbUserId) { + return new ChatbotError('unauthorized:chat').toResponse(); + } + + const chat = await getChatById({ id }); + + if (chat?.userId !== dbUserId) { + return new ChatbotError('forbidden:chat').toResponse(); + } + + const deletedChat = await deleteChatById({ id }); + + return Response.json(deletedChat, { status: 200 }); +} diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/api/history/route.ts b/examples/chatbot-with-billing-with-clerk/app/(chat)/api/history/route.ts new file mode 100644 index 00000000..275be77c --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/api/history/route.ts @@ -0,0 +1,58 @@ +import type { NextRequest } from 'next/server'; +import { auth } from '@clerk/nextjs/server'; +import { + deleteAllChatsByUserId, + getChatsByUserId, + getUserId, +} from '@/lib/db/queries'; +import { ChatbotError } from '@/lib/errors'; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + + const limit = Math.min( + Math.max(Number.parseInt(searchParams.get('limit') || '10', 10), 1), + 50, + ); + const startingAfter = searchParams.get('starting_after'); + const endingBefore = searchParams.get('ending_before'); + + if (startingAfter && endingBefore) { + return new ChatbotError( + 'bad_request:api', + 'Only one of starting_after or ending_before can be provided.', + ).toResponse(); + } + + const { userId } = await auth(); + + if (!userId) { + return new ChatbotError('unauthorized:chat').toResponse(); + } + + const dbUserId = await getUserId(); + if (!dbUserId) { + return new ChatbotError('unauthorized:chat').toResponse(); + } + + const chats = await getChatsByUserId({ + id: dbUserId, + limit, + startingAfter, + endingBefore, + }); + + return Response.json(chats); +} + +export async function DELETE() { + const dbUserId = await getUserId(); + + if (!dbUserId) { + return new ChatbotError('unauthorized:chat').toResponse(); + } + + const result = await deleteAllChatsByUserId({ userId: dbUserId }); + + return Response.json(result, { status: 200 }); +} diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/[id]/page.tsx b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/[id]/page.tsx new file mode 100644 index 00000000..346731c8 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/[id]/page.tsx @@ -0,0 +1,36 @@ +import { Suspense } from 'react'; +import { redirect } from 'next/navigation'; +import type { UIMessage } from 'ai'; +import { auth } from '@clerk/nextjs/server'; +import { ChatShell } from '@/components/chat/shell'; +import { getChatById, getMessagesByChatId, getUserId } from '@/lib/db/queries'; +import { convertToUIMessages } from '@/lib/utils'; + +async function ChatContent({ id }: { id: string }) { + const { userId } = await auth(); + if (!userId) redirect('/sign-in'); + + const dbUserId = await getUserId(); + if (!dbUserId) redirect('/sign-in'); + + const chat = await getChatById({ id }); + if (chat && chat.userId !== dbUserId) redirect('/'); + + const dbMessages = chat ? await getMessagesByChatId({ id }) : []; + const messages = convertToUIMessages(dbMessages) as UIMessage[]; + + return ; +} + +export default async function ChatPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ( + + + + ); +} diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/layout.tsx b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/layout.tsx new file mode 100644 index 00000000..4d3a86e8 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/layout.tsx @@ -0,0 +1,7 @@ +export default function ChatLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/page.tsx b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/page.tsx new file mode 100644 index 00000000..8e38c090 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/chat/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from 'react'; +import { auth } from '@clerk/nextjs/server'; +import { redirect } from 'next/navigation'; +import { generateUUID } from '@/lib/utils'; + +async function ChatRedirect() { + const { userId } = await auth(); + if (!userId) redirect('/sign-in'); + redirect(`/chat/${generateUUID()}`); + return null; +} + +export default function ChatPage() { + return ( + + + + ); +} diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/db-actions.ts b/examples/chatbot-with-billing-with-clerk/app/(chat)/db-actions.ts new file mode 100644 index 00000000..c949b990 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/db-actions.ts @@ -0,0 +1,29 @@ +'use server'; + +import { + saveChat as _saveChat, + saveMessages as _saveMessages, + updateChatTitleById as _updateChatTitleById, +} from '@/lib/db/queries'; +import type { DBMessage } from '@/lib/db/schema'; +import type { VisibilityType } from '@/components/chat/visibility-selector'; + +export async function saveChat(args: { + id: string; + userId: string; + title: string; + visibility: VisibilityType; +}) { + return _saveChat(args); +} + +export async function saveMessages(args: { messages: DBMessage[] }) { + return _saveMessages(args); +} + +export async function updateChatTitleById(args: { + chatId: string; + title: string; +}) { + return _updateChatTitleById(args); +} diff --git a/examples/chatbot-with-billing-with-clerk/app/(chat)/layout.tsx b/examples/chatbot-with-billing-with-clerk/app/(chat)/layout.tsx new file mode 100644 index 00000000..d24a1f36 --- /dev/null +++ b/examples/chatbot-with-billing-with-clerk/app/(chat)/layout.tsx @@ -0,0 +1,44 @@ +import '@/lib/ai/chat-setup'; +import { cookies } from 'next/headers'; +import Script from 'next/script'; +import { Suspense } from 'react'; +import { Toaster } from 'sonner'; +import { AppSidebar } from '@/components/chat/app-sidebar'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; +import { auth } from '@clerk/nextjs/server'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> +