diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
deleted file mode 100644
index 53b17f3..0000000
--- a/.github/workflows/playwright.yml
+++ /dev/null
@@ -1,74 +0,0 @@
-name: Playwright Tests
-on:
- push:
- branches: [main, master]
- pull_request:
- branches: [main, master]
-
-jobs:
- test:
- timeout-minutes: 30
- runs-on: ubuntu-latest
- env:
- BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
- BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
- POSTGRES_URL: ${{ secrets.POSTGRES_URL }}
- BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
- REDIS_URL: ${{ secrets.REDIS_URL }}
-
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 1
-
- - uses: actions/setup-node@v4
- with:
- node-version: lts/*
-
- - name: Install pnpm
- uses: pnpm/action-setup@v2
- with:
- version: latest
- run_install: false
-
- - name: Get pnpm store directory
- id: pnpm-cache
- shell: bash
- run: |
- echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
-
- - uses: actions/cache@v3
- with:
- path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
- key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pnpm-store-
-
- - uses: actions/setup-node@v4
- with:
- node-version: lts/*
- cache: "pnpm"
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - 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 exec playwright install --with-deps chromium
-
- - name: Run Playwright tests
- run: pnpm test
-
- - uses: actions/upload-artifact@v4
- if: always() && !cancelled()
- with:
- name: playwright-report
- path: playwright-report/
- retention-days: 7
diff --git a/README.md b/README.md
index b407018..9003e1f 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,16 @@
-
+
- Chatbot
+ chat.devdocify.com
- Chatbot (formerly AI Chatbot) is a free, open-source template built with Next.js and the AI SDK that helps you quickly build powerful chatbot applications.
+ An AI chatbot built with Next.js, the Vercel AI SDK, and Claude. Part of the DevDocify portfolio.
- Read Docs ·
+ Live demo ·
Features ·
- Model Providers ·
- Deploy Your Own ·
+ Model providers ·
Running locally
@@ -24,48 +23,73 @@
- [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
+- Data persistence
- [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data
- [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage
- [Auth.js](https://authjs.dev)
- Simple and secure authentication
-## Model Providers
+## Model providers
-This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. Models are configured in `lib/ai/models.ts` with per-model provider routing. Included models: Mistral, Moonshot, DeepSeek, OpenAI, and xAI.
+Models are configured in `lib/ai/models.ts`. This deployment uses [Anthropic](https://anthropic.com) directly via `@ai-sdk/anthropic`:
-### AI Gateway Authentication
+| Model | ID | Use |
+|---|---|---|
+| Claude Sonnet | `claude-sonnet-4-6` | Default chat model |
+| Claude Opus | `claude-opus-4-6` | Complex tasks |
+| Claude Haiku | `claude-haiku-4-5-20251001` | Title generation, fast tasks |
-**For Vercel deployments**: Authentication is handled automatically via OIDC tokens.
+## Running locally
-**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.
+### Prerequisites
-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.
+- Node.js 20+
+- [pnpm](https://pnpm.io)
+- [Vercel CLI](https://vercel.com/docs/cli): `npm i -g vercel`
-## Deploy Your Own
+### Setup
-You can deploy your own version of Chatbot to Vercel with one click:
+1. Clone the repo and install dependencies:
-[](https://vercel.com/templates/next.js/chatbot)
+```bash
+git clone https://github.com/matthewrgourd/chat-devdocify.git
+cd chat-devdocify
+pnpm install
+```
-## Running locally
+2. Link to the Vercel project and pull environment variables:
-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.
+```bash
+vercel link
+vercel env pull
+```
-> 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.
+This creates a `.env.local` file with all required variables including `AUTH_SECRET`, `ANTHROPIC_API_KEY`, and `POSTGRES_URL`.
-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`
+3. Run database migrations:
+
+```bash
+pnpm db:migrate
+```
+
+4. Start the dev server:
```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).
+The app runs at [localhost:3000](http://localhost:3000).
+
+### Required environment variables
+
+| Variable | Description |
+|---|---|
+| `AUTH_SECRET` | Random secret for Auth.js session signing |
+| `ANTHROPIC_API_KEY` | API key from [console.anthropic.com](https://console.anthropic.com) |
+| `POSTGRES_URL` | Neon Postgres connection string (auto-added by Vercel) |
+| `BLOB_READ_WRITE_TOKEN` | Vercel Blob token (auto-added by Vercel) |
+
+> Never commit `.env.local` -- it's in `.gitignore`.
diff --git a/biome.jsonc b/biome.jsonc
index af049a9..79ac3c1 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -25,7 +25,8 @@
"noExplicitAny": "off",
"noUnknownAtRules": "off",
"noConsole": "off",
- "noBitwiseOperators": "off"
+ "noBitwiseOperators": "off",
+ "noSkippedTests": "off"
},
"style": {
"noMagicNumbers": "off",
diff --git a/playwright.config.ts b/playwright.config.ts
deleted file mode 100644
index 06e9982..0000000
--- a/playwright.config.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { defineConfig, devices } from "@playwright/test";
-
-/**
- * Read environment variables from file.
- * https://github.com/motdotla/dotenv
- */
-import { config } from "dotenv";
-
-config({
- path: ".env.local",
-});
-
-/* Use process.env.PORT by default and fallback to port 3000 */
-const PORT = process.env.PORT || 3000;
-
-/**
- * Set webServer.url and use.baseURL with the location
- * of the WebServer respecting the correct set port
- */
-const baseURL = `http://localhost:${PORT}`;
-
-/**
- * See https://playwright.dev/docs/test-configuration.
- */
-export default defineConfig({
- testDir: "./tests",
- /* Run tests in files in parallel */
- fullyParallel: true,
- /* Fail the build on CI if you accidentally left test.only in the source code. */
- forbidOnly: !!process.env.CI,
- /* Retry on CI only */
- retries: 0,
- /* Limit workers to prevent browser crashes */
- workers: process.env.CI ? 2 : 2,
- /* Reporter to use. See https://playwright.dev/docs/test-reporters */
- reporter: "html",
- /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
- use: {
- /* Base URL to use in actions like `await page.goto('/')`. */
- baseURL,
-
- /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
- trace: "retain-on-failure",
- },
-
- /* Configure global timeout for each test */
- timeout: 240 * 1000, // 120 seconds
- expect: {
- timeout: 240 * 1000,
- },
-
- /* Configure projects */
- projects: [
- {
- name: "e2e",
- testMatch: /e2e\/.*.test.ts/,
- use: {
- ...devices["Desktop Chrome"],
- },
- },
-
- // {
- // name: 'firefox',
- // use: { ...devices['Desktop Firefox'] },
- // },
-
- // {
- // name: 'webkit',
- // use: { ...devices['Desktop Safari'] },
- // },
-
- /* Test against mobile viewports. */
- // {
- // name: 'Mobile Chrome',
- // use: { ...devices['Pixel 5'] },
- // },
- // {
- // name: 'Mobile Safari',
- // use: { ...devices['iPhone 12'] },
- // },
-
- /* Test against branded browsers. */
- // {
- // name: 'Microsoft Edge',
- // use: { ...devices['Desktop Edge'], channel: 'msedge' },
- // },
- // {
- // name: 'Google Chrome',
- // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
- // },
- ],
-
- /* Run your local dev server before starting the tests */
- webServer: {
- command: "pnpm dev",
- url: `${baseURL}/ping`,
- timeout: 120 * 1000,
- reuseExistingServer: !process.env.CI,
- },
-});
diff --git a/tests/e2e/api.test.ts b/tests/e2e/api.test.ts
deleted file mode 100644
index 9b78779..0000000
--- a/tests/e2e/api.test.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { expect, test } from "@playwright/test";
-
-const CHAT_URL_REGEX = /\/chat\/[\w-]+/;
-const ERROR_TEXT_REGEX = /error|failed|trouble/i;
-
-test.describe("Chat API Integration", () => {
- test("sends message and receives AI response", async ({ page }) => {
- await page.goto("/");
-
- const input = page.getByTestId("multimodal-input");
- await input.fill("Hello");
- await page.getByTestId("send-button").click();
-
- // Wait for assistant response to appear
- const assistantMessage = page.locator("[data-role='assistant']").first();
- await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
-
- // Verify it has some text content
- const content = await assistantMessage.textContent();
- expect(content?.length).toBeGreaterThan(0);
- });
-
- test("redirects to /chat/:id after sending message", async ({ page }) => {
- await page.goto("/");
-
- const input = page.getByTestId("multimodal-input");
- await input.fill("Test redirect");
- await page.getByTestId("send-button").click();
-
- // URL should change to /chat/:id format
- await expect(page).toHaveURL(CHAT_URL_REGEX, { timeout: 10_000 });
- });
-
- test("clears input after sending", async ({ page }) => {
- await page.goto("/");
-
- const input = page.getByTestId("multimodal-input");
- await input.fill("Test message");
- await page.getByTestId("send-button").click();
-
- // Input should be cleared
- await expect(input).toHaveValue("");
- });
-
- test("shows stop button during generation", async ({ page }) => {
- await page.goto("/");
- const input = page.getByTestId("multimodal-input");
- await input.fill("Test");
- await page.getByTestId("send-button").click();
-
- // Stop button should appear during generation
- const stopButton = page.getByTestId("stop-button");
- await expect(stopButton).toBeVisible({ timeout: 5000 });
- });
-});
-
-test.describe("Chat Error Handling", () => {
- test("handles API error gracefully", async ({ page }) => {
- await page.route("**/api/chat", async (route) => {
- await route.fulfill({
- status: 500,
- contentType: "application/json",
- body: JSON.stringify({ error: "Internal server error" }),
- });
- });
-
- await page.goto("/");
- const input = page.getByTestId("multimodal-input");
- await input.fill("Test error");
- await page.getByTestId("send-button").click();
-
- // Should show error toast or message
- await expect(page.getByText(ERROR_TEXT_REGEX).first()).toBeVisible({
- timeout: 5000,
- });
- });
-});
-
-test.describe("Suggested Actions", () => {
- test("suggested actions are clickable", async ({ page }) => {
- await page.goto("/");
-
- const suggestions = page.locator(
- "[data-testid='suggested-actions'] button"
- );
- const count = await suggestions.count();
-
- if (count > 0) {
- await suggestions.first().click();
-
- // Should redirect after clicking suggestion
- await expect(page).toHaveURL(CHAT_URL_REGEX, { timeout: 10_000 });
- }
- });
-});
diff --git a/tests/e2e/auth.test.ts b/tests/e2e/auth.test.ts
deleted file mode 100644
index 5dfcbb0..0000000
--- a/tests/e2e/auth.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { expect, test } from "@playwright/test";
-
-test.describe("Authentication Pages", () => {
- test("login page renders correctly", async ({ page }) => {
- await page.goto("/login");
- await expect(page.getByPlaceholder("user@acme.com")).toBeVisible();
- await expect(page.getByLabel("Password")).toBeVisible();
- await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
- await expect(page.getByText("Don't have an account?")).toBeVisible();
- });
-
- test("register page renders correctly", async ({ page }) => {
- await page.goto("/register");
- await expect(page.getByPlaceholder("user@acme.com")).toBeVisible();
- await expect(page.getByLabel("Password")).toBeVisible();
- await expect(page.getByRole("button", { name: "Sign Up" })).toBeVisible();
- await expect(page.getByText("Already have an account?")).toBeVisible();
- });
-
- test("can navigate from login to register", async ({ page }) => {
- await page.goto("/login");
- await page.getByRole("link", { name: "Sign up" }).click();
- await expect(page).toHaveURL("/register");
- });
-
- test("can navigate from register to login", async ({ page }) => {
- await page.goto("/register");
- await page.getByRole("link", { name: "Sign in" }).click();
- await expect(page).toHaveURL("/login");
- });
-});
diff --git a/tests/e2e/chat.test.ts b/tests/e2e/chat.test.ts
deleted file mode 100644
index 26cf753..0000000
--- a/tests/e2e/chat.test.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { expect, test } from "@playwright/test";
-
-test.describe("Chat Page", () => {
- test("home page loads with input field", async ({ page }) => {
- await page.goto("/");
- await expect(page.getByTestId("multimodal-input")).toBeVisible();
- });
-
- test("can type in the input field", async ({ page }) => {
- await page.goto("/");
- const input = page.getByTestId("multimodal-input");
- await input.fill("Hello world");
- await expect(input).toHaveValue("Hello world");
- });
-
- test("submit button is visible", async ({ page }) => {
- await page.goto("/");
- await expect(page.getByTestId("send-button")).toBeVisible();
- });
-
- test("suggested actions are visible on empty chat", async ({ page }) => {
- await page.goto("/");
- const suggestions = page.locator("[data-testid='suggested-actions']");
- await expect(suggestions).toBeVisible();
- });
-
- test("can stop generation with stop button", async ({ page }) => {
- await page.goto("/");
-
- // Type and send a message
- await page.getByTestId("multimodal-input").fill("Hello");
- await page.getByTestId("send-button").click();
-
- // Stop button should appear during generation
- const stopButton = page.getByTestId("stop-button");
- // If generation starts, stop button appears
- // This is a best-effort check since timing depends on API
- await stopButton.click({ timeout: 5000 }).catch(() => {
- // Generation may have finished before we could click
- });
- });
-});
-
-test.describe("Chat Input Features", () => {
- test("input clears after sending", async ({ page }) => {
- await page.goto("/");
- const input = page.getByTestId("multimodal-input");
- await input.fill("Test message");
- await page.getByTestId("send-button").click();
-
- // Input should clear after sending
- await expect(input).toHaveValue("");
- });
-
- test("input supports multiline text", async ({ page }) => {
- await page.goto("/");
- const input = page.getByTestId("multimodal-input");
- await input.fill("Line 1\nLine 2\nLine 3");
- await expect(input).toContainText("Line 1");
- });
-});
diff --git a/tests/e2e/model-selector.test.ts b/tests/e2e/model-selector.test.ts
deleted file mode 100644
index ae47af6..0000000
--- a/tests/e2e/model-selector.test.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { expect, test } from "@playwright/test";
-
-const MODEL_BUTTON_REGEX = /Kimi|Codestral|Mistral|DeepSeek|GPT|Grok/i;
-
-test.describe("Model Selector", () => {
- test.beforeEach(async ({ page }) => {
- await page.goto("/");
- });
-
- test("displays a model button", async ({ page }) => {
- const modelButton = page
- .locator("button")
- .filter({ hasText: MODEL_BUTTON_REGEX })
- .first();
- await expect(modelButton).toBeVisible();
- });
-
- test("opens model selector popover on click", async ({ page }) => {
- const modelButton = page
- .locator("button")
- .filter({ hasText: MODEL_BUTTON_REGEX })
- .first();
- await modelButton.click();
-
- await expect(page.getByPlaceholder("Search models...")).toBeVisible();
- });
-
- test("can search for models", async ({ page }) => {
- const modelButton = page
- .locator("button")
- .filter({ hasText: MODEL_BUTTON_REGEX })
- .first();
- await modelButton.click();
-
- const searchInput = page.getByPlaceholder("Search models...");
- await searchInput.fill("Mistral");
-
- await expect(page.getByText("Mistral Small").first()).toBeVisible();
- });
-
- test("can close model selector by clicking outside", async ({ page }) => {
- const modelButton = page
- .locator("button")
- .filter({ hasText: MODEL_BUTTON_REGEX })
- .first();
- await modelButton.click();
-
- await expect(page.getByPlaceholder("Search models...")).toBeVisible();
-
- await page.keyboard.press("Escape");
-
- await expect(page.getByPlaceholder("Search models...")).not.toBeVisible();
- });
-
- test("shows model provider groups", async ({ page }) => {
- const modelButton = page
- .locator("button")
- .filter({ hasText: MODEL_BUTTON_REGEX })
- .first();
- await modelButton.click();
-
- await expect(page.getByText("Mistral")).toBeVisible();
- await expect(page.getByText("Moonshot")).toBeVisible();
- });
-
- test("can select a different model", async ({ page }) => {
- const modelButton = page
- .locator("button")
- .filter({ hasText: MODEL_BUTTON_REGEX })
- .first();
- await modelButton.click();
-
- await page.getByText("Mistral Small").first().click();
-
- await expect(page.getByPlaceholder("Search models...")).not.toBeVisible();
-
- await expect(
- page.locator("button").filter({ hasText: "Mistral Small" }).first()
- ).toBeVisible();
- });
-});
diff --git a/tests/fixtures.ts b/tests/fixtures.ts
deleted file mode 100644
index c782fc0..0000000
--- a/tests/fixtures.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { expect as baseExpect, test as baseTest } from "@playwright/test";
-import { ChatPage } from "./pages/chat";
-
-type Fixtures = {
- chatPage: ChatPage;
-};
-
-export const test = baseTest.extend({
- chatPage: async ({ page }, use) => {
- const chatPage = new ChatPage(page);
- await use(chatPage);
- },
-});
-
-export const expect = baseExpect;
diff --git a/tests/helpers.ts b/tests/helpers.ts
deleted file mode 100644
index 6d492fa..0000000
--- a/tests/helpers.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { generateId } from "ai";
-import { getUnixTime } from "date-fns";
-
-export function generateRandomTestUser() {
- const email = `test-${getUnixTime(new Date())}@playwright.com`;
- const password = generateId();
-
- return {
- email,
- password,
- };
-}
-
-export function generateTestMessage() {
- return `Test message ${Date.now()}`;
-}
diff --git a/tests/pages/chat.ts b/tests/pages/chat.ts
deleted file mode 100644
index 3f199f6..0000000
--- a/tests/pages/chat.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import type { Page } from "@playwright/test";
-
-const MODEL_BUTTON_REGEX = /Kimi|Codestral|Mistral|DeepSeek|GPT|Grok/i;
-
-export class ChatPage {
- page: Page;
-
- constructor(page: Page) {
- this.page = page;
- }
-
- async goto() {
- await this.page.goto("/");
- }
-
- async createNewChat() {
- await this.page.goto("/");
- await this.page.waitForSelector("[data-testid='multimodal-input']");
- }
-
- getInput() {
- return this.page.getByTestId("multimodal-input");
- }
-
- async typeMessage(message: string) {
- const input = this.getInput();
- await input.fill(message);
- }
-
- async sendMessage() {
- await this.page.getByTestId("send-button").click();
- }
-
- async sendUserMessage(message: string) {
- await this.typeMessage(message);
- await this.sendMessage();
- }
-
- getSendButton() {
- return this.page.getByTestId("send-button");
- }
-
- getStopButton() {
- return this.page.getByTestId("stop-button");
- }
-
- async clickSuggestedAction(index = 0) {
- const suggestions = this.page.locator(
- "[data-testid='suggested-actions'] button"
- );
- await suggestions.nth(index).click();
- }
-
- async openModelSelector() {
- const modelButton = this.page
- .locator("button")
- .filter({ hasText: MODEL_BUTTON_REGEX })
- .first();
- await modelButton.click();
- }
-
- async selectModel(modelName: string) {
- await this.openModelSelector();
- await this.page.getByText(modelName).first().click();
- }
-
- async searchModels(query: string) {
- await this.openModelSelector();
- await this.page.getByPlaceholder("Search models...").fill(query);
- }
-}
diff --git a/tests/prompts/utils.ts b/tests/prompts/utils.ts
deleted file mode 100644
index b173436..0000000
--- a/tests/prompts/utils.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
-
-const mockUsage = {
- inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
- outputTokens: { total: 20, text: 20, reasoning: 0 },
-};
-
-export function getResponseChunksByPrompt(
- _prompt: unknown,
- includeReasoning = false
-): LanguageModelV3StreamPart[] {
- const chunks: LanguageModelV3StreamPart[] = [];
-
- if (includeReasoning) {
- chunks.push(
- { type: "reasoning-start", id: "r1" },
- { type: "reasoning-delta", id: "r1", delta: "Let me think about this." },
- { type: "reasoning-end", id: "r1" }
- );
- }
-
- chunks.push(
- { type: "text-start", id: "t1" },
- { type: "text-delta", id: "t1", delta: "Hello, world!" },
- { type: "text-end", id: "t1" },
- {
- type: "finish",
- finishReason: { unified: "stop", raw: "stop" },
- usage: mockUsage,
- }
- );
-
- return chunks;
-}