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 -

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: -[![Deploy with Vercel](https://vercel.com/button)](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; -}