diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d3d9a33..152e8d3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -15,6 +15,8 @@ jobs: cancel-in-progress: true permissions: contents: read + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} steps: - name: Checkout @@ -41,6 +43,8 @@ jobs: - name: Run Playwright smoke tests run: pnpm e2e:smoke + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - name: Upload Playwright report if: always() diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2077b95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,427 @@ +--- +description: "Rules for AI agents working with the better-stack monorepo - plugin development, build configuration, and testing" +alwaysApply: true +--- + +# Better Stack Monorepo - Agent Rules + +This document contains essential rules and patterns for AI agents working with this monorepo. + +## Environment Setup + +### Node.js Version +Always use Node.js v22 before running any commands: +```bash +source ~/.nvm/nvm.sh && nvm use 22 +``` + +### Build Commands +```bash +pnpm build # Build all packages +pnpm typecheck # Type check all packages +pnpm lint # Lint all packages +``` + +## Plugin Development + +### Plugin Architecture Pattern + +Plugins consist of two parts that must be kept in sync: + +1. **API Plugin** (`src/plugins/{name}/api/plugin.ts`) + - Uses `defineBackendPlugin` from `@btst/stack/plugins` + - Defines database schema, API endpoints, and server-side hooks + - Exports types for the API router + +2. **Client Plugin** (`src/plugins/{name}/client/plugin.tsx`) + - Uses `defineClientPlugin` from `@btst/stack/plugins` + - Defines routes, loaders, meta generators, and client-side hooks + - Must configure `queryClient`, `siteBaseURL`, `siteBasePath` in config + +### Lifecycle Hooks Pattern + +Both API and client plugins should follow consistent hook naming: + +```typescript +// API Plugin Hooks +onBeforeChat, onAfterChat, onChatError +onBeforeConversationCreated, onAfterConversationCreated, onConversationCreateError +onBeforeConversationRead, onAfterConversationRead, onConversationReadError +onBeforeConversationUpdated, onAfterConversationUpdated, onConversationUpdateError +onBeforeConversationDeleted, onAfterConversationDeleted, onConversationDeleteError +onBeforeConversationsListed, onAfterConversationsListed, onConversationsListError + +// Client Plugin Hooks +beforeLoad*, afterLoad*, onLoadError +onRouteRender, onRouteError +onBefore*PageRendered +``` + +### Query Keys Factory + +Create a query keys file for React Query integration: + +```typescript +// src/plugins/{name}/query-keys.ts +import { mergeQueryKeys, createQueryKeys } from "@lukemorales/query-key-factory"; + +export function create{Name}QueryKeys(client, headers?) { + return mergeQueryKeys( + createQueryKeys("resourceName", { + list: () => ({ queryKey: ["list"], queryFn: async () => { /* ... */ } }), + detail: (id: string) => ({ queryKey: [id], queryFn: async () => { /* ... */ } }), + }) + ); +} +``` + +### Client Overrides + +Client plugins need overrides configured in consumer layouts. Required overrides: + +```typescript +type PluginOverrides = { + apiBaseURL: string; // Base URL for API calls + apiBasePath: string; // API route prefix (e.g., "/api/data") + navigate: (path: string) => void; + refresh?: () => void; + Link: ComponentType; + Image?: ComponentType; + uploadImage?: (file: File) => Promise; + headers?: HeadersInit; + localization?: Partial; +} +``` + +### Lazy Loading Page Components + +Use React.lazy() to code-split page components and reduce initial bundle size: + +```typescript +import { lazy } from "react"; + +// Lazy load page components for code splitting +// Use .then() to handle named exports +const HomePageComponent = lazy(() => + import("./components/pages/home-page").then(m => ({ default: m.HomePageComponent })) +); +const NewPostPageComponent = lazy(() => + import("./components/pages/new-post-page").then(m => ({ default: m.NewPostPageComponent })) +); +const EditPostPageComponent = lazy(() => + import("./components/pages/edit-post-page").then(m => ({ default: m.EditPostPageComponent })) +); +``` + +For default exports, the simpler form works: +```typescript +const PostPage = lazy(() => import("./components/pages/post-page")); +``` + +### Client Plugin Route Structure + +Each route in `defineClientPlugin` should return three parts: + +```typescript +routes: () => ({ + routeName: createRoute("/path/:param", ({ params }) => ({ + // 1. PageComponent - The React component to render + PageComponent: () => , + + // 2. loader - SSR data prefetching (runs only on server) + loader: createMyLoader(params.param, config), + + // 3. meta - SEO meta tag generator + meta: createMyMeta(params.param, config), + })), +}), +``` + +### SSR Loader Pattern + +Loaders should only run on the server and prefetch data into React Query: + +```typescript +function createMyLoader(param: string, config: MyClientConfig) { + return async () => { + // Only run on server - skip on client + if (typeof window === "undefined") { + const { queryClient, apiBasePath, apiBaseURL, hooks, headers } = config; + + const context: LoaderContext = { + path: `/resource/${param}`, + params: { param }, + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + + try { + // Before hook - allow consumers to cancel/modify loading + if (hooks?.beforeLoad) { + const canLoad = await hooks.beforeLoad(param, context); + if (!canLoad) { + throw new Error("Load prevented by beforeLoad hook"); + } + } + + // Create API client and query keys + const client = createApiClient({ + baseURL: apiBaseURL, + basePath: apiBasePath, + }); + const queries = createMyQueryKeys(client, headers); + + // Prefetch data into queryClient + await queryClient.prefetchQuery(queries.resource.detail(param)); + + // After hook + if (hooks?.afterLoad) { + const data = queryClient.getQueryData(queries.resource.detail(param).queryKey); + await hooks.afterLoad(data, param, context); + } + + // Check for errors - call hook but don't throw + const queryState = queryClient.getQueryState(queries.resource.detail(param).queryKey); + if (queryState?.error && hooks?.onLoadError) { + const error = queryState.error instanceof Error + ? queryState.error + : new Error(String(queryState.error)); + await hooks.onLoadError(error, context); + } + } catch (error) { + // Log error but don't re-throw during SSR + // Let Error Boundaries handle errors when components render + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} +``` + +Key patterns: +- **Server-only execution**: `if (typeof window === "undefined")` +- **Don't throw errors during SSR**: Let React Query store errors and Error Boundaries catch them during render +- **Hook integration**: Call before/after/error hooks for consumer customization +- **Prefetch into queryClient**: Use `queryClient.prefetchQuery()` so data is available immediately on client + +### Meta Generator Pattern + +Meta generators read prefetched data from queryClient: + +```typescript +function createMyMeta(param: string, config: MyClientConfig) { + return () => { + const { queryClient, apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, seo } = config; + + // Get prefetched data from queryClient + const queries = createMyQueryKeys( + createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath }) + ); + const data = queryClient.getQueryData(queries.resource.detail(param).queryKey); + + // Fallback if data not loaded + if (!data) { + return [ + { title: "Unknown route" }, + { name: "robots", content: "noindex" }, + ]; + } + + const fullUrl = `${siteBaseURL}${siteBasePath}/resource/${param}`; + + return [ + { title: data.title }, + { name: "description", content: data.description }, + { property: "og:type", content: "website" }, + { property: "og:title", content: data.title }, + { property: "og:url", content: fullUrl }, + // ... more meta tags + ]; + }; +} +``` + +## Build Configuration + +### Adding New Entry Points + +When creating new exports, update both files: + +1. **`packages/better-stack/build.config.ts`** - Add entry to the entries array: +```typescript +entries: [ + // ... existing entries + { input: "src/plugins/{name}/query-keys.ts" }, + { input: "src/plugins/{name}/client/hooks/index.tsx" }, + { input: "src/plugins/{name}/client/components/index.tsx" }, +] +``` + +2. **`packages/better-stack/package.json`** - Add exports AND typesVersions: +```json +{ + "exports": { + "./plugins/{name}/client/hooks": { + "import": "./dist/plugins/{name}/client/hooks/index.mjs", + "require": "./dist/plugins/{name}/client/hooks/index.cjs" + } + }, + "typesVersions": { + "*": { + "plugins/{name}/client/hooks": ["./dist/plugins/{name}/client/hooks/index.d.ts"] + } + } +} +``` + +### CSS Exports + +Plugins with UI components must provide CSS entry points: + +1. **`src/plugins/{name}/client.css`** - Client-side styles +2. **`src/plugins/{name}/style.css`** - Full styles with Tailwind source directives + +Export in package.json: +```json +{ + "exports": { + "./plugins/{name}/css": "./dist/plugins/{name}/client.css" + } +} +``` + +The `postbuild.cjs` script copies CSS files automatically. + +## Example Apps + +### Updating All Examples + +When adding a new plugin or changing plugin configuration, update ALL three example apps: + +1. **Next.js** (`examples/nextjs/`) + - `lib/better-stack.tsx` - Backend plugin registration + - `lib/better-stack-client.tsx` - Client plugin registration + - `app/pages/[[...all]]/layout.tsx` - Override configuration + - `app/globals.css` - CSS import: `@import "@btst/stack/plugins/{name}/css";` + +2. **React Router** (`examples/react-router/`) + - `app/lib/better-stack.tsx` - Backend plugin registration + - `app/lib/better-stack-client.tsx` - Client plugin registration + - `app/routes/pages/_layout.tsx` - Override configuration + - `app/app.css` - CSS import: `@import "@btst/stack/plugins/{name}/css";` + +3. **TanStack** (`examples/tanstack/`) + - `src/lib/better-stack.tsx` - Backend plugin registration + - `src/lib/better-stack-client.tsx` - Client plugin registration + - `src/routes/pages/route.tsx` - Override configuration + - `src/styles/app.css` - CSS import: `@import "@btst/stack/plugins/{name}/css";` + +### Override Type Registration + +Add your plugin's overrides to the PluginOverrides type in layouts: + +```typescript +import type { YourPluginOverrides } from "@btst/stack/plugins/{name}/client" + +type PluginOverrides = { + blog: BlogPluginOverrides, + "ai-chat": AiChatPluginOverrides, + "{name}": YourPluginOverrides, // Add new plugins here +} +``` + +## Testing + +### E2E Tests + +Tests are in `e2e/tests/` using Playwright. Pattern: `smoke.{feature}.spec.ts` + +Run tests with API keys from the nextjs example: +```bash +cd e2e +export $(cat ../examples/nextjs/.env | xargs) +pnpm e2e:smoke +``` + +Run specific test file: +```bash +pnpm e2e:smoke -- tests/smoke.chat.spec.ts +``` + +Run for specific project: +```bash +pnpm e2e:smoke -- --project="nextjs:memory" +``` + +### Test Configuration + +The `playwright.config.ts` defines three projects: +- `nextjs:memory` - port 3003 +- `tanstack:memory` - port 3004 +- `react-router:memory` - port 3005 + +All three web servers start for every test run. Timeout is 300 seconds per server. + +### API Key Requirements + +Features requiring external APIs (like OpenAI) should: +1. Check for API key availability in tests +2. Skip tests gracefully when key is missing +3. Document required env vars in test files + +```typescript +test.beforeEach(async () => { + if (!process.env.OPENAI_API_KEY) { + test.skip(); + } +}); +``` + +## Shared UI Package + +### Using @workspace/ui + +Shared components live in `packages/ui/src/components/`. Import via: +```typescript +import { Button } from "@workspace/ui/button" +import { MarkdownContent } from "@workspace/ui/markdown-content" +``` + +### Adding Shadcn Components + +Use the shadcn CLI to add components to the UI package: +```bash +cd packages/ui +pnpm dlx shadcn@latest add {component-name} +``` + +## Documentation + +### FumaDocs Site + +Documentation is in `docs/content/docs/`. Update when adding/changing plugins: + +1. Create/update MDX file: `docs/content/docs/plugins/{name}.mdx` +2. Use `AutoTypeTable` for TypeScript interfaces +3. Include code examples with proper syntax highlighting +4. Document all configuration options, hooks, and overrides + +## Common Pitfalls + +1. **Missing overrides** - Client components using `usePluginOverrides()` will crash if overrides aren't configured in the layout or default values are not provided to the hook. + +2. **Build cache** - Run `pnpm build` after changes to see them in examples. The turbo cache may need clearing: `pnpm turbo clean` + +3. **Type exports** - Always add both `exports` AND `typesVersions` entries for new paths + +4. **CSS not loading** - Ensure CSS files are listed in postbuild.cjs patterns and exported in package.json + +5. **React Query stale data** - Use `staleTime: Infinity` for data that shouldn't refetch automatically + +6. **Link component href** - Next.js Link requires non-undefined href. Use `href={href || "#"}` pattern + +7. **AI SDK versions** - Use AI SDK v5 patterns. Check https://ai-sdk.dev/docs for current API diff --git a/README.md b/README.md index b067f00..0ebd2bf 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,121 @@ -# @BTST - Better Stack +# @btst/stack — Better Stack
-**Composable full-stack plugin system for React frameworks** +**Installable full-stack features for React apps** +Framework-agnostic. Database-flexible. No lock-in. -[![npm version](https://img.shields.io/npm/v/@btst/stack.svg)](https://www.npmjs.com/package/@btst/stack) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![npm](https://img.shields.io/npm/v/@btst/stack.svg)](https://www.npmjs.com/package/@btst/stack) +[![MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[📖 Documentation](https://www.better-stack.ai/docs) • [🐛 Issues](https://github.com/better-stack-ai/better-stack/issues) +[Docs](https://www.better-stack.ai/docs) · [Examples](./examples) · [Issues](https://github.com/better-stack-ai/better-stack/issues)
--- -## What Problem Does This Solve? +## What is Better Stack? -Your app needs a blog. Or a scheduling system. Or user feedback collection. Or an AI assistant. These are **horizontal features**—capabilities that cut across your entire app, not specific to your core domain. +Better Stack lets you **install production-ready app features as npm packages**. -Building them from scratch means weeks of work: routes, API endpoints, database schemas, authentication, SSR, metadata, hooks, forms, error handling... +Instead of spending weeks building the same things again and again +(routes, APIs, database schemas, SSR, SEO, forms…): -Better Stack lets you **add these features in minutes** as composable plugins that work across any React framework. +```bash +npm install @btst/stack +```` -- **Composable architecture** - Mix and match features like LEGO blocks. Add blog + scheduling + feedback + newsletters, all working together seamlessly -- **Framework agnostic** - One feature works with Next.js App Router, React Router, TanStack Router, Remix—switch frameworks without rewriting -- **Plugin overrides** - Leverage framework-specific features via overrides. Use Next.js `Image` and `Link`, React Router's `Link`, or any framework's components -- **Full-stack in one package** - Each feature includes routes, API endpoints, database schemas, React components, hooks, loaders, and metadata -- **Zero boilerplate** - No wiring up routes, API handlers, or query clients. Just configure and it works -- **First-class SSR** - Server-side rendering, data prefetching, and SEO metadata generation built-in -- **Lifecycle hooks** - Intercept at any point: authorization, data transformation, analytics, caching, webhooks -- **Horizontal features** - Perfect for blog, scheduling, feedback, newsletters, AI assistants, comments—anything reusable across apps +Enable the features you need and keep building your product. +### Examples of installable features -## Installation +* Blog +* AI Chat +* CMS +* Newsletter +* Scheduling +* Kanban board +* Analytics dashboard +* Generic forms -```bash -npm install @btst/stack +Each feature ships **frontend + backend together**: +routes, APIs, database models, React components, SSR, and SEO — already wired. + +--- + +## Why use it? + +* **Installable features** – real product features, not just UI +* **Framework-agnostic** – Next.js, React Router, TanStack Router, Remix +* **Database-flexible** – Prisma, Drizzle, Kysely, MongoDB +* **Zero boilerplate** – no manual route or API wiring +* **Type-safe** – end-to-end TypeScript + +You keep your codebase, database, and deployment. + +--- + +## Minimal usage + +lib/better-stack.ts: +```ts +import { betterStack } from "@btst/stack" +import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" + +export const { handler } = betterStack({ + plugins: { + blog: blogBackendPlugin(blogConfig) + } +}) +``` + +lib/better-stack-client.tsx: +```tsx +import { createStackClient } from "@btst/stack/client" +import { blogClientPlugin } from "@btst/stack/plugins/blog/client" +import { QueryClient } from "@tanstack/react-query" + +const client = createStackClient({ + plugins: { + blog: blogClientPlugin(blogConfig) + } +}) ``` +Now you have a working blog: API, DB schema, pages, SSR, and SEO. -For database schema management, install the CLI: +## Database schemas & migrations + +Optional CLI to generate schemas and run migrations from enabled plugins: ```bash npm install -D @btst/cli ``` -The CLI helps generate migrations, Prisma schemas, and other database artifacts from your plugin schemas. - -Learn more about Better Stack, full installation, usage instructions and available plugins in the [documentation](https://www.better-stack.ai/docs). +Generate drizzle schema: -## The Bigger Picture +```bash +npx @btst/cli generate --orm drizzle --config lib/better-stack.ts --output db/schema.ts +``` -Better Stack transforms how you think about building apps: +Supports Prisma, Drizzle, MongoDB and Kysely SQL dialects. -- **Open source** - Share complete features, not just code snippets. Someone can add a newsletter plugin to their Next.js app in minutes -- **Fast development** - Add 5 features in an afternoon instead of 5 months. Validate ideas faster -- **Framework and Database Agnostic** - Use any framework and database you want. Better Stack works with any modern framework and database. +--- +## Examples -Each plugin is a complete, self-contained horizontal full-stack feature. No framework lock-in. Just add it and it works. +* [Next.js App Router](./examples/nextjs) +* [React Router](./examples/react-router) +* [TanStack Router](./examples/tanstack) -## Learn More +--- -For complete documentation, examples, and plugin development guides, visit **[https://www.better-stack.ai](https://www.better-stack.ai)** +## Learn more -## Examples +Full documentation, guides, and plugin development: +👉 **[https://www.better-stack.ai](https://www.better-stack.ai)** -- [Next.js App Router](./examples/nextjs) - Next.js App Router example -- [React Router](./examples/react-router) - React Router example -- [TanStack Router](./examples/tanstack) - TanStack Router example +--- -## License +If this saves you time, a ⭐ helps others find it. MIT © [olliethedev](https://github.com/olliethedev) diff --git a/docs/assets/blog-demo-1.png b/docs/assets/blog-demo-1.png new file mode 100644 index 0000000..98422e4 Binary files /dev/null and b/docs/assets/blog-demo-1.png differ diff --git a/docs/assets/blog-demo-2.png b/docs/assets/blog-demo-2.png new file mode 100644 index 0000000..7ba8d5f Binary files /dev/null and b/docs/assets/blog-demo-2.png differ diff --git a/docs/assets/blog-demo-3.png b/docs/assets/blog-demo-3.png new file mode 100644 index 0000000..bb48ae2 Binary files /dev/null and b/docs/assets/blog-demo-3.png differ diff --git a/docs/assets/blog-demo.png b/docs/assets/blog-demo.png index 85a3b14..e59b349 100644 Binary files a/docs/assets/blog-demo.png and b/docs/assets/blog-demo.png differ diff --git a/docs/assets/chat-demo-1.png b/docs/assets/chat-demo-1.png new file mode 100644 index 0000000..6f55d29 Binary files /dev/null and b/docs/assets/chat-demo-1.png differ diff --git a/docs/assets/chat-demo.png b/docs/assets/chat-demo.png new file mode 100644 index 0000000..fbddf40 Binary files /dev/null and b/docs/assets/chat-demo.png differ diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index cbdb0e7..1375ef0 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -11,6 +11,7 @@ "---[Layers]Plugins---", "plugins/index", "plugins/blog", + "plugins/ai-chat", "plugins/development", "---[Database]Databases---", "databases/adapters", diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx new file mode 100644 index 0000000..027c5f6 --- /dev/null +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -0,0 +1,758 @@ +--- +title: AI Chat Plugin +description: AI-powered chat functionality with conversation history, streaming, sidebar navigation, and customizable models +--- + +import { Tabs, Tab } from "fumadocs-ui/components/tabs"; +import { Callout } from "fumadocs-ui/components/callout"; +import Image from "next/image"; + +import chatDemo from "../../../assets/chat-demo.png"; +import chatDemo1 from "../../../assets/chat-demo-1.png"; + + + +## Installation + + +Ensure you followed the general [framework installation guide](/installation) first. + + +Follow these steps to add the AI Chat plugin to your Better Stack setup. + +### 1. Add Plugin to Backend API + +Import and register the AI Chat backend plugin in your `better-stack.ts` file: + +```ts title="lib/better-stack.ts" +import { betterStack } from "@btst/stack" +import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" +import { openai } from "@ai-sdk/openai" +// ... your adapter imports + +const { handler, dbSchema } = betterStack({ + basePath: "/api/data", + plugins: { + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), // Or any LanguageModel from AI SDK + mode: "authenticated", // "authenticated" (default) or "public" + // Extract userId from request headers to scope conversations per user + getUserId: async (ctx) => { + const token = ctx.headers?.get("authorization") + if (!token) return null // Deny access if no auth + const user = await verifyToken(token) // Your auth logic + return user?.id ?? null + }, + systemPrompt: "You are a helpful assistant.", // Optional + tools: {}, // Optional: AI SDK v5 tools + }) + }, + adapter: (db) => createMemoryAdapter(db)({}) +}) + +export { handler, dbSchema } +``` + +The `aiChatBackendPlugin()` accepts optional hooks for customizing behavior (authorization, logging, etc.). + + +**Model Configuration:** You can use any model from the AI SDK, including OpenAI, Anthropic, Google, and more. Make sure to install the corresponding provider package (e.g., `@ai-sdk/openai`) and set up your API keys in environment variables. + + +### 2. Add Plugin to Client + +Register the AI Chat client plugin in your `better-stack-client.tsx` file: + +```tsx title="lib/better-stack-client.tsx" +import { createStackClient } from "@btst/stack/client" +import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client" +import { QueryClient } from "@tanstack/react-query" + +const getBaseURL = () => + typeof window !== 'undefined' + ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) + : (process.env.BASE_URL || "http://localhost:3000") + +export const getStackClient = (queryClient: QueryClient, options?: { headers?: Headers }) => { + const baseURL = getBaseURL() + return createStackClient({ + plugins: { + aiChat: aiChatClientPlugin({ + // Required configuration + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + headers: options?.headers, + // Mode should match backend config + mode: "authenticated", // "authenticated" (default) or "public" + // Optional: SEO configuration + seo: { + siteName: "My Chat App", + description: "AI-powered chat assistant", + }, + }) + } + }) +} +``` + +**Required configuration:** +- `apiBaseURL`: Base URL for API calls during SSR data prefetching (use environment variables for flexibility) +- `apiBasePath`: Path where your API is mounted (e.g., `/api/data`) +- `siteBaseURL`: Base URL of your site +- `siteBasePath`: Path where your pages are mounted (e.g., `/pages`) +- `queryClient`: React Query client instance + + +**Why configure API paths here?** This configuration is used by **server-side loaders** that prefetch data before your pages render. These loaders run outside of React Context, so they need direct configuration. You'll also provide `apiBaseURL` and `apiBasePath` again in the Provider overrides (Section 4) for **client-side components** that run during actual rendering. + + +### 3. Import Plugin CSS + +Add the AI Chat plugin CSS to your global stylesheet: + +```css title="app/globals.css" +@import "@btst/stack/plugins/ai-chat/css"; +``` + +This includes all necessary styles for the chat components and markdown rendering. + +### 4. Add Context Overrides + +Configure framework-specific overrides in your `BetterStackProvider`: + + + + ```tsx title="app/pages/[[...all]]/layout.tsx" + import { BetterStackProvider } from "@btst/stack/context" + import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" + import Link from "next/link" + import Image from "next/image" + import { useRouter } from "next/navigation" + + const getBaseURL = () => + typeof window !== 'undefined' + ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) + : (process.env.BASE_URL || "http://localhost:3000") + + type PluginOverrides = { + "ai-chat": AiChatPluginOverrides + } + + export default function Layout({ children }) { + const router = useRouter() + const baseURL = getBaseURL() + + return ( + + basePath="/pages" + overrides={{ + "ai-chat": { + mode: "authenticated", // Should match backend config + apiBaseURL: baseURL, + apiBasePath: "/api/data", + navigate: (path) => router.push(path), + refresh: () => router.refresh(), + uploadFile: async (file) => { + // Implement your file upload logic + return "https://example.com/uploads/file.pdf" + }, + Link: (props) => , + Image: (props) => , + } + }} + > + {children} + + ) + } + ``` + + + + ```tsx title="app/routes/pages/_layout.tsx" + import { Outlet, Link, useNavigate } from "react-router" + import { BetterStackProvider } from "@btst/stack/context" + import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" + + const getBaseURL = () => + typeof window !== 'undefined' + ? (import.meta.env.VITE_BASE_URL || window.location.origin) + : (process.env.BASE_URL || "http://localhost:5173") + + type PluginOverrides = { + "ai-chat": AiChatPluginOverrides + } + + export default function Layout() { + const navigate = useNavigate() + const baseURL = getBaseURL() + + return ( + + basePath="/pages" + overrides={{ + "ai-chat": { + mode: "authenticated", + apiBaseURL: baseURL, + apiBasePath: "/api/data", + navigate: (href) => navigate(href), + uploadFile: async (file) => { + return "https://example.com/uploads/file.pdf" + }, + Link: ({ href, children, className, ...props }) => ( + + {children} + + ), + } + }} + > + + + ) + } + ``` + + + + ```tsx title="src/routes/pages/route.tsx" + import { BetterStackProvider } from "@btst/stack/context" + import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" + import { Link, useRouter, Outlet } from "@tanstack/react-router" + + const getBaseURL = () => + typeof window !== 'undefined' + ? (import.meta.env.VITE_BASE_URL || window.location.origin) + : (process.env.BASE_URL || "http://localhost:3000") + + type PluginOverrides = { + "ai-chat": AiChatPluginOverrides + } + + function Layout() { + const router = useRouter() + const baseURL = getBaseURL() + + return ( + + basePath="/pages" + overrides={{ + "ai-chat": { + mode: "authenticated", + apiBaseURL: baseURL, + apiBasePath: "/api/data", + navigate: (href) => router.navigate({ href }), + uploadFile: async (file) => { + return "https://example.com/uploads/file.pdf" + }, + Link: ({ href, children, className, ...props }) => ( + + {children} + + ), + } + }} + > + + + ) + } + ``` + + + +**Required overrides:** +- `apiBaseURL`: Base URL for API calls (used by client-side components during rendering) +- `apiBasePath`: Path where your API is mounted +- `navigate`: Function for programmatic navigation + +**Optional overrides:** +- `mode`: Plugin mode (`"authenticated"` or `"public"`) +- `uploadFile`: Function to upload files and return their URL +- `allowedFileTypes`: Array of allowed file type categories (default: all types) +- `Link`: Custom Link component (defaults to `` tag) +- `Image`: Custom Image component (useful for Next.js Image optimization) +- `refresh`: Function to refresh server-side cache (useful for Next.js) +- `localization`: Custom localization strings +- `headers`: Headers to pass with API requests + + +**Why provide API paths again?** You already configured these in Section 2, but that configuration is only available to **server-side loaders**. The overrides here provide the same values to **client-side components** (like hooks, forms, and UI) via React Context. These two contexts serve different phases: loaders prefetch data server-side before rendering, while components use data during actual rendering (both SSR and CSR). + + +### 5. Generate Database Schema + +After adding the plugin, generate your database schema using the CLI: + +```bash +npx @btst/cli generate --orm prisma --config lib/better-stack.ts --output prisma/schema.prisma +``` + +This will create the necessary database tables for conversations and messages. Run migrations as needed for your ORM. + +For more details on the CLI and all available options, see the [CLI documentation](/cli). + +## Congratulations, You're Done! 🎉 + +Your AI Chat plugin is now fully configured and ready to use! Here's a quick reference of what's available: + +### Plugin Modes + +The AI Chat plugin supports two distinct modes: + +**Authenticated Mode (Default)** +- Conversation persistence in database +- User-scoped data via `getUserId` +- Full UI with sidebar and conversation history +- Routes: `/chat` (new/list) and `/chat/:id` (existing conversation) + +**Public Mode** +- No persistence (stateless) +- Simple UI without sidebar +- Ideal for public-facing chatbots +- Single route: `/chat` + +### API Endpoints + +The AI Chat plugin provides the following API endpoints (mounted at your configured `apiBasePath`): + +- **POST** `/chat` - Send a message and receive streaming response +- **GET** `/conversations` - List all conversations (authenticated mode only) +- **GET** `/conversations/:id` - Get a conversation with messages +- **POST** `/conversations` - Create a new conversation +- **PUT** `/conversations/:id` - Update (rename) a conversation +- **DELETE** `/conversations/:id` - Delete a conversation + +### Page Routes + +The AI Chat plugin automatically creates the following pages (mounted at your configured `siteBasePath`): + +**Authenticated mode:** +- `/chat` - Start a new conversation (with sidebar showing history) +- `/chat/:id` - Resume an existing conversation + +**Public mode:** +- `/chat` - Simple chat interface without history + +### Features + +- **Full-page Layout**: Responsive chat interface with collapsible sidebar +- **Conversation Sidebar**: View, rename, and delete past conversations +- **Streaming Responses**: Real-time streaming of AI responses using AI SDK v5 +- **Markdown Support**: Full markdown rendering with code highlighting +- **File Uploads**: Attach images, PDFs, and text files to messages +- **Tools Support**: Use AI SDK v5 tools for function calling +- **Customizable Models**: Use any LanguageModel from the AI SDK +- **Authorization Hooks**: Add custom authentication and authorization logic +- **Localization**: Customize all UI strings + +### Adding Authorization + +To add authorization rules and customize behavior, you can use the lifecycle hooks defined in the API Reference section below. These hooks allow you to control access to API endpoints, add logging, and customize the plugin's behavior to fit your application's needs. + +## API Reference + +### Backend (`@btst/stack/plugins/ai-chat/api`) + +#### aiChatBackendPlugin + + + +#### AiChatBackendConfig + +The backend plugin accepts a configuration object with the model, mode, and optional hooks: + + + +#### AiChatBackendHooks + +Customize backend behavior with optional lifecycle hooks. All hooks are optional and allow you to add authorization, logging, and custom behavior: + + + +**Example usage:** + +```ts title="lib/better-stack.ts" +import { aiChatBackendPlugin, type AiChatBackendHooks } from "@btst/stack/plugins/ai-chat/api" + +const chatHooks: AiChatBackendHooks = { + // Authorization hooks - return false to deny access + onBeforeChat(messages, context) { + const authHeader = context.headers?.get("authorization") + if (!authHeader) return false + return true + }, + onBeforeListConversations(context) { + return isAuthenticated(context.headers as Headers) + }, + onBeforeDeleteConversation(conversationId, context) { + return isAuthenticated(context.headers as Headers) + }, + // Lifecycle hooks + onConversationCreated(conversation, context) { + console.log("Conversation created:", conversation.id) + }, + onAfterChat(conversationId, messages, context) { + console.log("Chat completed:", conversationId, "messages:", messages.length) + }, + // Error hooks + onChatError(error, context) { + console.error("Chat error:", error.message) + }, +} + +const { handler, dbSchema } = betterStack({ + plugins: { + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + hooks: chatHooks + }) + }, + // ... +}) +``` + +#### ChatApiContext + + + +### Client (`@btst/stack/plugins/ai-chat/client`) + +#### aiChatClientPlugin + + + +#### AiChatClientConfig + +The client plugin accepts a configuration object with required fields and optional SEO settings: + + + +**Example usage:** + +```tsx title="lib/better-stack-client.tsx" +aiChat: aiChatClientPlugin({ + // Required configuration + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + headers: options?.headers, + // Mode configuration + mode: "authenticated", + // Optional SEO configuration + seo: { + siteName: "My AI Assistant", + description: "Chat with our AI assistant", + locale: "en_US", + defaultImage: `${baseURL}/og-image.png`, + }, +}) +``` + +#### AiChatClientHooks + +Customize client-side behavior with lifecycle hooks. These hooks are called during data fetching (both SSR and CSR): + + + +**Example usage:** + +```tsx title="lib/better-stack-client.tsx" +aiChat: aiChatClientPlugin({ + // ... rest of the config + headers: options?.headers, + hooks: { + beforeLoadConversations: async (context) => { + // Check if user is authenticated before loading + if (!isAuthenticated(context.headers)) { + return false // Cancel loading + } + return true + }, + afterLoadConversation: async (conversation, id, context) => { + // Log access for analytics + console.log("User accessed conversation:", id) + return true + }, + onLoadError(error, context) { + // Handle error - redirect to login + redirect("/auth/sign-in") + }, + } +}) +``` + +#### LoaderContext + + + +#### RouteContext + + + +#### AiChatPluginOverrides + +Configure framework-specific overrides and route lifecycle hooks. All lifecycle hooks are optional: + + + +**Example usage:** + +```tsx +overrides={{ + "ai-chat": { + // Required overrides + apiBaseURL: baseURL, + apiBasePath: "/api/data", + navigate: (path) => router.push(path), + // Optional overrides + mode: "authenticated", + uploadFile: async (file) => { + const formData = new FormData() + formData.append("file", file) + const res = await fetch("/api/upload", { method: "POST", body: formData }) + const { url } = await res.json() + return url + }, + allowedFileTypes: ["image", "pdf", "text"], // Restrict allowed types + // Optional lifecycle hooks + onBeforeChatPageRendered: (context) => { + // Check if user can view chat. Useful for SPA. + return true + }, + onBeforeConversationPageRendered: (id, context) => { + // Check if user can view this specific conversation + return true + }, + } +}} +``` + +## React Data Hooks and Types + +You can import the hooks from `"@btst/stack/plugins/ai-chat/client/hooks"` to use in your components. + +```tsx +import { + useConversations, + useConversation, + useSuspenseConversations, + useSuspenseConversation, + useCreateConversation, + useRenameConversation, + useDeleteConversation, +} from "@btst/stack/plugins/ai-chat/client/hooks" +``` + +### UseConversationsOptions + + + +### UseConversationsResult + + + +### UseConversationOptions + + + +### UseConversationResult + + + +**Example usage:** + +```tsx +import { + useConversations, + useConversation, + useCreateConversation, + useRenameConversation, + useDeleteConversation, +} from "@btst/stack/plugins/ai-chat/client/hooks" + +function ConversationsList() { + // List all conversations + const { conversations, isLoading, error, refetch } = useConversations() + + // Get single conversation with messages + const { conversation } = useConversation(selectedId) + + // Mutations + const createMutation = useCreateConversation() + const renameMutation = useRenameConversation() + const deleteMutation = useDeleteConversation() + + const handleCreate = async () => { + const newConv = await createMutation.mutateAsync({ title: "New Chat" }) + // Navigate to new conversation + } + + const handleRename = async (id: string, newTitle: string) => { + await renameMutation.mutateAsync({ id, title: newTitle }) + } + + const handleDelete = async (id: string) => { + await deleteMutation.mutateAsync({ id }) + } + + // ... render conversations +} +``` + +## Model & Tools Configuration + +### Using Different Models + +```ts title="lib/better-stack.ts" +import { openai } from "@ai-sdk/openai" +import { anthropic } from "@ai-sdk/anthropic" +import { google } from "@ai-sdk/google" + +// Use OpenAI +aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), +}) + +// Or use Anthropic +aiChat: aiChatBackendPlugin({ + model: anthropic("claude-3-5-sonnet-20241022"), +}) + +// Or use Google +aiChat: aiChatBackendPlugin({ + model: google("gemini-1.5-pro"), +}) +``` + +### Adding Tools + +Use AI SDK v5 tools for function calling: + +```ts title="lib/better-stack.ts" +import { tool } from "ai" +import { z } from "zod" + +const weatherTool = tool({ + description: "Get the current weather in a location", + parameters: z.object({ + location: z.string().describe("The city and state"), + }), + execute: async ({ location }) => { + // Your implementation + return { temperature: 72, condition: "sunny" } + }, +}) + +aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + tools: { + getWeather: weatherTool, + }, +}) +``` + +## Public Mode Configuration + +For public chatbots without user authentication: + +### Backend Setup + +```ts title="lib/better-stack.ts" +import { betterStack } from "@btst/stack" +import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" +import { openai } from "@ai-sdk/openai" + +// Example rate limiter (implement your own) +const rateLimiter = new Map() + +const { handler, dbSchema } = betterStack({ + basePath: "/api/data", + plugins: { + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + mode: "public", // Stateless mode - no persistence + systemPrompt: "You are a helpful customer support bot.", + hooks: { + onBeforeChat: async (messages, ctx) => { + // Implement rate limiting + const ip = ctx.headers?.get("x-forwarded-for") || "unknown" + const requests = rateLimiter.get(ip) || 0 + if (requests > 10) { + return false // Block request + } + rateLimiter.set(ip, requests + 1) + return true + }, + }, + }) + }, + adapter: (db) => createMemoryAdapter(db)({}) +}) +``` + +### Client Setup + +```tsx title="lib/better-stack-client.tsx" +aiChat: aiChatClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + mode: "public", // Must match backend +}) +``` + +### Context Overrides + +```tsx +overrides={{ + "ai-chat": { + mode: "public", + apiBaseURL: baseURL, + apiBasePath: "/api/data", + navigate: (path) => router.push(path), + // No uploadFile needed in public mode typically + } +}} +``` + + +In public mode, the sidebar is hidden, conversation history is not saved, and only the `/chat` route is available. + + +## Localization + +Customize UI strings by providing a `localization` override: + +```tsx +overrides={{ + "ai-chat": { + // ... other overrides + localization: { + CHAT_PLACEHOLDER: "Ask me anything...", + CHAT_EMPTY_STATE: "How can I help you today?", + SIDEBAR_NEW_CHAT: "Start new conversation", + CONVERSATION_DELETE_CONFIRM_TITLE: "Delete this chat?", + // See AiChatLocalization type for all available strings + } + } +}} +``` + +#### AiChatLocalization + + diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index a72381c..54758ed 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -5,9 +5,27 @@ description: Content management, editor, drafts, publishing, SEO and more import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; - - -![Blog Plugin Demo](../../../assets/blog-demo.png) +import Image from "next/image"; + +import blogDemo from "../../../assets/blog-demo.png"; +import blogDemo1 from "../../../assets/blog-demo-1.png"; +import blogDemo2 from "../../../assets/blog-demo-2.png"; +import blogDemo3 from "../../../assets/blog-demo-3.png"; + + ## Installation diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx index cf1b622..0229e70 100644 --- a/docs/content/docs/plugins/index.mdx +++ b/docs/content/docs/plugins/index.mdx @@ -18,6 +18,12 @@ With more plugins coming soon, you can add complete features to your app in minu icon={} description="Content management, editor, drafts, publishing, SEO, RSS feeds." /> + } + description="AI-powered chat with conversation history, streaming, and customizable models." + /> `- ${page.data.title}: /docs${page.url}.mdx`).join('\n')} +${pages.map((page) => `- ${page.data.title}: /docs/${page.url === "/" ? "index" : page.url.slice(1)}.mdx`).join('\n')} ## How to Access diff --git a/e2e/package.json b/e2e/package.json index 3fc4100..ec29abe 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -10,6 +10,7 @@ }, "devDependencies": { "@playwright/test": "^1.48.2", + "dotenv": "^16.4.5", "tsx": "^4.20.3" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 3d31ddc..d758f4a 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,13 +1,28 @@ import { defineConfig } from "@playwright/test"; +import { config } from "dotenv"; +import { resolve } from "path"; + +// Load each project's .env file to get their specific environment variables. +// The first config() call also populates process.env for the test runner +// (used by tests to check if OPENAI_API_KEY is available for skip logic). +const nextjsEnv = + config({ path: resolve(__dirname, "../examples/nextjs/.env") }).parsed || {}; +const tanstackEnv = + config({ path: resolve(__dirname, "../examples/tanstack/.env") }).parsed || + {}; +const reactRouterEnv = + config({ path: resolve(__dirname, "../examples/react-router/.env") }) + .parsed || {}; export default defineConfig({ testDir: "./tests", timeout: 90_000, forbidOnly: !!process.env.CI, outputDir: "../test-results", - reporter: process.env.CI - ? [["list"], ["html", { open: "never" }]] - : [["list"]], + reporter: [ + ["list"], + ["html", { open: process.env.CI ? "never" : "on-failure" }], + ], expect: { timeout: 10_000, }, @@ -26,11 +41,12 @@ export default defineConfig({ command: "pnpm -F examples/nextjs run start:e2e", port: 3003, reuseExistingServer: !process.env["CI"], - timeout: 120_000, + timeout: 300_000, stdout: "pipe", stderr: "pipe", env: { ...process.env, + ...nextjsEnv, PORT: "3003", HOST: "127.0.0.1", BASE_URL: "http://localhost:3003", @@ -41,11 +57,12 @@ export default defineConfig({ command: "pnpm -F examples/tanstack run start:e2e", port: 3004, reuseExistingServer: !process.env["CI"], - timeout: 120_000, + timeout: 300_000, stdout: "pipe", stderr: "pipe", env: { ...process.env, + ...tanstackEnv, PORT: "3004", HOST: "127.0.0.1", BASE_URL: "http://localhost:3004", @@ -55,11 +72,12 @@ export default defineConfig({ command: "pnpm -F examples/react-router run start:e2e", port: 3005, reuseExistingServer: !process.env["CI"], - timeout: 120_000, + timeout: 300_000, stdout: "pipe", stderr: "pipe", env: { ...process.env, + ...reactRouterEnv, PORT: "3005", HOST: "127.0.0.1", BASE_URL: "http://localhost:3005", @@ -76,17 +94,19 @@ export default defineConfig({ "**/*.todos.spec.ts", "**/*.auth-blog.spec.ts", "**/*.blog.spec.ts", + "**/*.chat.spec.ts", + "**/*.public-chat.spec.ts", ], }, { name: "tanstack:memory", use: { baseURL: "http://localhost:3004" }, - testMatch: ["**/*.blog.spec.ts"], + testMatch: ["**/*.blog.spec.ts", "**/*.chat.spec.ts"], }, { name: "react-router:memory", use: { baseURL: "http://localhost:3005" }, - testMatch: ["**/*.blog.spec.ts"], + testMatch: ["**/*.blog.spec.ts", "**/*.chat.spec.ts"], }, ], }); diff --git a/e2e/tests/smoke.chat.spec.ts b/e2e/tests/smoke.chat.spec.ts new file mode 100644 index 0000000..2e2149e --- /dev/null +++ b/e2e/tests/smoke.chat.spec.ts @@ -0,0 +1,782 @@ +import { test, expect } from "@playwright/test"; + +const hasOpenAiKey = + typeof process.env.OPENAI_API_KEY === "string" && + process.env.OPENAI_API_KEY.trim().length > 0; + +if (!hasOpenAiKey) { + // eslint-disable-next-line no-console -- surfaced only when tests are skipped + console.warn( + "Skipping AI chat smoke tests: OPENAI_API_KEY is not available in the environment.", + ); +} + +test.skip( + !hasOpenAiKey, + "OPENAI_API_KEY is required to run AI chat smoke tests.", +); + +test.describe("AI Chat Plugin", () => { + test("should render chat page with sidebar", async ({ page }) => { + await page.goto("/pages/chat"); + + // Verify chat layout is visible + await expect(page.locator('[data-testid="chat-layout"]')).toBeVisible(); + + // Verify chat interface is visible + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + + // Verify empty state message + await expect(page.getByText("Start a conversation...")).toBeVisible(); + + // Verify input is available + await expect(page.getByPlaceholder("Type a message...")).toBeVisible(); + }); + + test("should start a new conversation and send a message", async ({ + page, + }) => { + // 1. Navigate to the chat page + await page.goto("/pages/chat"); + + // 2. Verify initial state + await expect(page.getByText("Start a conversation...")).toBeVisible(); + await expect(page.getByPlaceholder("Type a message...")).toBeVisible(); + + // 3. Send a message + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Hello, world!"); + // Use Enter key or find the submit button + await page.keyboard.press("Enter"); + + // 4. Verify user message appears - increase timeout to account for slower state updates + await expect(page.getByText("Hello, world!")).toBeVisible({ + timeout: 15000, + }); + + // 5. Verify AI response appears (using real OpenAI, so response content varies, but should exist) + // We wait for the assistant message container. The plugin uses an aria-label for a11y. + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + }); + + test("should show conversation in sidebar after sending message", async ({ + page, + }) => { + // Navigate to the chat page + await page.goto("/pages/chat"); + + // Send a message to create a conversation + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Test message for sidebar"); + await page.keyboard.press("Enter"); + + // Wait for the message to be sent and processed + await expect(page.getByText("Test message for sidebar")).toBeVisible({ + timeout: 5000, + }); + + // Wait for the AI response + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + // The conversation should appear in the sidebar after the assistant finishes responding + // (no refresh needed). + await expect( + page.getByRole("button", { name: /Test message for sidebar/i }), + ).toBeVisible({ timeout: 15000 }); + }); + + test("should navigate to existing conversation", async ({ page }) => { + // First create a conversation + await page.goto("/pages/chat"); + + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Navigation test message"); + await page.keyboard.press("Enter"); + + // Wait for response + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + // Wait for the URL to change to include the conversation ID + await page.waitForURL(/\/pages\/chat\/[a-zA-Z0-9]+/, { timeout: 10000 }); + + // Refresh and verify the conversation is still visible + await page.reload(); + + // Wait for the page to load + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible({ + timeout: 10000, + }); + + // The messages should still be visible in the chat interface (not just sidebar) + await expect( + page + .locator('[data-testid="chat-interface"]') + .getByText("Navigation test message", { exact: true }), + ).toBeVisible({ + timeout: 15000, + }); + }); + + test("should keep all messages in the same conversation", async ({ + page, + }) => { + // This test verifies that multiple messages in a conversation stay together + // and don't create separate history items (fixes the bug where every message + // created a new conversation) + + await page.goto("/pages/chat"); + + // Send first message + const input = page.getByPlaceholder("Type a message..."); + await input.fill("First message in conversation"); + await page.keyboard.press("Enter"); + + // Wait for first AI response + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + // Wait for navigation to new conversation URL + await page.waitForURL(/\/pages\/chat\/[a-zA-Z0-9]+/, { timeout: 10000 }); + + // Send second message + await input.fill("Second message in same conversation"); + await page.keyboard.press("Enter"); + + // Wait for second AI response (should be 2 prose elements now) + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toHaveCount(2, { timeout: 30000 }); + + // Refresh the page + await page.reload(); + + // Both messages should still be visible in the same conversation + await expect( + page + .locator('[data-testid="chat-interface"]') + .getByText("First message in conversation"), + ).toBeVisible({ timeout: 10000 }); + await expect( + page + .locator('[data-testid="chat-interface"]') + .getByText("Second message in same conversation"), + ).toBeVisible({ timeout: 10000 }); + + // There should only be ONE conversation in the sidebar with "First message" + // (the title is based on the first message) + const sidebarConversations = page.locator( + 'button:has-text("First message in conversation")', + ); + await expect(sidebarConversations).toHaveCount(1, { timeout: 5000 }); + }); + + test("should have new chat button in sidebar", async ({ page }) => { + // Navigate to the chat page + await page.goto("/pages/chat"); + + // Verify the "New chat" button exists in the sidebar + await expect( + page.getByRole("button", { name: "New chat", exact: true }), + ).toBeVisible({ timeout: 5000 }); + }); + + test("should navigate back to /chat when clicking New chat from a conversation", async ({ + page, + }) => { + // Ensure desktop layout so sidebar is visible + await page.setViewportSize({ width: 1280, height: 800 }); + + // Create a conversation + await page.goto("/pages/chat"); + const input = page.getByPlaceholder("Type a message..."); + await input.fill("New chat navigation test"); + await page.keyboard.press("Enter"); + + // Wait for AI response + navigation to conversation URL + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + await page.waitForURL(/\/pages\/chat\/[a-zA-Z0-9]+/, { timeout: 10000 }); + + // Click "New chat" and verify we navigate back to /pages/chat + await page.getByRole("button", { name: "New chat", exact: true }).click(); + await page.waitForURL("/pages/chat", { timeout: 10000 }); + + // Chat interface should be reset to empty state + await expect(page.getByText("Start a conversation...")).toBeVisible({ + timeout: 10000, + }); + await expect( + page + .locator('[data-testid="chat-interface"]') + .getByText("New chat navigation test"), + ).toHaveCount(0); + }); + + test("should reset draft input when clicking New chat on /chat", async ({ + page, + }) => { + // Ensure desktop layout so sidebar is visible + await page.setViewportSize({ width: 1280, height: 800 }); + + await page.goto("/pages/chat"); + + // Type a draft message but do not send it + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Draft message that should be cleared"); + + // Click "New chat" to reset the chat interface in-place + await page.getByRole("button", { name: "New chat", exact: true }).click(); + + // Draft should be cleared after remount/reset + await expect(page.getByPlaceholder("Type a message...")).toHaveValue(""); + await expect(page.getByText("Start a conversation...")).toBeVisible(); + }); + + test("should toggle sidebar on desktop", async ({ page }) => { + // Set desktop viewport + await page.setViewportSize({ width: 1280, height: 800 }); + + await page.goto("/pages/chat"); + + // Find the sidebar toggle button (desktop only) + const toggleButton = page + .locator('[aria-label="Close sidebar"]') + .or(page.locator('[aria-label="Open sidebar"]')); + + // Click to close sidebar + await toggleButton.first().click(); + await page.waitForTimeout(300); // Wait for animation + + // Click to open sidebar again + await toggleButton.first().click(); + await page.waitForTimeout(300); + }); + + test("should open mobile sidebar sheet", async ({ page }) => { + // Set mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + + await page.goto("/pages/chat"); + + // Find and click the mobile menu button + const menuButton = page.locator('[aria-label="Open menu"]'); + await menuButton.click(); + + // Verify the sidebar sheet is open + await expect( + page.getByRole("button", { name: "New chat", exact: true }), + ).toBeVisible({ timeout: 5000 }); + }); +}); + +test.describe("AI Chat Plugin - Widget Mode", () => { + // Widget mode tests would typically be done if the example app exposes a widget route + // For now, we test the main chat interface + + test("should render chat interface in compact mode when in widget", async ({ + page, + }) => { + // This test assumes the chat interface adapts based on container/props + // In a real widget scenario, you'd navigate to a widget-specific route + await page.goto("/pages/chat"); + + // Verify the chat interface is functional + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + }); +}); + +test.describe("AI Chat Plugin - File Uploads", () => { + test("should show file upload button in authenticated mode", async ({ + page, + }) => { + await page.goto("/pages/chat"); + + // Verify the file upload button is visible (paperclip icon) + await expect( + page.getByRole("button", { name: "Attach file" }), + ).toBeVisible(); + }); + + test("should upload and attach an image file", async ({ page }) => { + await page.goto("/pages/chat"); + + // Wait for chat interface to be fully rendered + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + + // Create a mock image file - wait for input to be attached first + const fileInput = page.locator('input[type="file"]'); + await expect(fileInput).toBeAttached({ timeout: 5000 }); + + // Upload a test image file + await fileInput.setInputFiles({ + name: "test-image.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), + }); + + // Wait for the upload to complete and preview to appear + // Image files show an img preview + await expect(page.locator('img[alt="test-image.png"]')).toBeVisible({ + timeout: 10000, + }); + + // Verify remove button is visible on hover + const preview = page.locator(".group").filter({ has: page.locator("img") }); + await preview.hover(); + await expect(preview.locator("button:has(svg)")).toBeVisible(); + }); + + test("should upload and attach a text file", async ({ page }) => { + await page.goto("/pages/chat"); + + // Wait for chat interface to be fully rendered + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + + // Create a mock image file - wait for input to be attached first + const fileInput = page.locator('input[type="file"]'); + await expect(fileInput).toBeAttached({ timeout: 5000 }); + + // Upload a test image file (1x1 red PNG) + const testImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + await fileInput.setInputFiles({ + name: "example.png", + mimeType: "image/png", + buffer: Buffer.from(testImageBase64, "base64"), + }); + + // Wait for the upload to complete and preview to appear + // Image files show a thumbnail preview + await expect(page.locator('img[alt="example.png"]')).toBeVisible({ + timeout: 10000, + }); + }); + + test("should remove attached file when clicking remove button", async ({ + page, + }) => { + await page.goto("/pages/chat"); + + // Wait for chat interface to be fully rendered + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + + // Upload a test image file - wait for input to be attached first + const fileInput = page.locator('input[type="file"]'); + await expect(fileInput).toBeAttached({ timeout: 5000 }); + const testImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + await fileInput.setInputFiles({ + name: "to-remove.png", + mimeType: "image/png", + buffer: Buffer.from(testImageBase64, "base64"), + }); + + // Wait for preview to appear (image shows as thumbnail) + await expect(page.locator('img[alt="to-remove.png"]')).toBeVisible({ + timeout: 10000, + }); + + // Hover over the preview and click remove button + const preview = page + .locator(".group") + .filter({ has: page.locator('img[alt="to-remove.png"]') }); + await preview.hover(); + await preview.locator("button").click(); + + // Verify file is removed + await expect(page.locator('img[alt="to-remove.png"]')).not.toBeVisible(); + }); + + test("should send message with attached file", async ({ page }) => { + await page.goto("/pages/chat"); + + // Wait for chat interface to be fully rendered + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + + // Upload a test image file - wait for input to be attached first + const fileInput = page.locator('input[type="file"]'); + await expect(fileInput).toBeAttached({ timeout: 5000 }); + const testImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + await fileInput.setInputFiles({ + name: "attachment.png", + mimeType: "image/png", + buffer: Buffer.from(testImageBase64, "base64"), + }); + + // Wait for preview (image shows as thumbnail) + await expect(page.locator('img[alt="attachment.png"]')).toBeVisible({ + timeout: 10000, + }); + + // Type a message and send + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Here is a file for you"); + await page.keyboard.press("Enter"); + + // Verify user message appears + await expect(page.getByText("Here is a file for you")).toBeVisible({ + timeout: 15000, + }); + + // Verify AI response appears + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + // After sending, the attachment preview should be cleared from input area + // (the attachments are now part of the sent message) + }); + + test("should clear attachments after sending", async ({ page }) => { + await page.goto("/pages/chat"); + + // Upload a test image file + const fileInput = page.locator('input[type="file"]'); + const testImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + await fileInput.setInputFiles({ + name: "clear-test.png", + mimeType: "image/png", + buffer: Buffer.from(testImageBase64, "base64"), + }); + + // Wait for preview (image shows as thumbnail) + await expect(page.locator('img[alt="clear-test.png"]')).toBeVisible({ + timeout: 10000, + }); + + // Type a message and send + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Please analyze this image"); + await page.keyboard.press("Enter"); + + // Wait for the user message to appear (confirming send worked) + await expect(page.getByText("Please analyze this image")).toBeVisible({ + timeout: 15000, + }); + + // Verify the attachment preview is cleared from input area after sending + // The image preview in the input area should be gone + const inputAreaImagePreview = page + .locator("form") + .locator('img[alt="clear-test.png"]'); + await expect(inputAreaImagePreview).not.toBeVisible({ timeout: 5000 }); + }); + + test("should allow multiple image uploads", async ({ page }) => { + await page.goto("/pages/chat"); + + const fileInput = page.locator('input[type="file"]'); + const testImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + // Upload first image + await fileInput.setInputFiles({ + name: "image1.png", + mimeType: "image/png", + buffer: Buffer.from(testImageBase64, "base64"), + }); + + await expect(page.locator('img[alt="image1.png"]')).toBeVisible({ + timeout: 10000, + }); + + // Upload second image + await fileInput.setInputFiles({ + name: "image2.png", + mimeType: "image/png", + buffer: Buffer.from(testImageBase64, "base64"), + }); + + await expect(page.locator('img[alt="image2.png"]')).toBeVisible({ + timeout: 10000, + }); + + // Both images should be visible + await expect(page.locator('img[alt="image1.png"]')).toBeVisible(); + await expect(page.locator('img[alt="image2.png"]')).toBeVisible(); + }); + + test("should retry AI response and maintain correct message order", async ({ + page, + }) => { + await page.goto("/pages/chat"); + + const chatInterface = page.locator('[data-testid="chat-interface"]'); + + // Send a message + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Say exactly: FIRST RESPONSE"); + await page.keyboard.press("Enter"); + + // Wait for user message and AI response within chat interface + await expect( + chatInterface.getByText("Say exactly: FIRST RESPONSE"), + ).toBeVisible({ + timeout: 15000, + }); + await expect( + chatInterface.locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + // Wait for the response to complete (status should be ready) + await page.waitForTimeout(2000); + + // Hover over the AI message to reveal retry button + const aiMessage = chatInterface + .locator('[aria-label="AI response"]') + .first(); + await aiMessage.hover(); + + // Click the retry button + const retryButton = chatInterface.getByTitle("Retry").first(); + await expect(retryButton).toBeVisible({ timeout: 5000 }); + await retryButton.click(); + + // Wait for the new response to complete + await page.waitForTimeout(7000); + + // Verify there's still only one user message and one AI response + const userMessages = chatInterface.locator('[aria-label="Your message"]'); + const aiMessages = chatInterface.locator('[aria-label="AI response"]'); + + await expect(userMessages).toHaveCount(1); + await expect(aiMessages).toHaveCount(1); + + // Wait for URL to update with conversation ID + await page.waitForURL(/\/pages\/chat\/[a-zA-Z0-9-]+/, { timeout: 10000 }); + const conversationUrl = page.url(); + + // Reload the page and verify message order is preserved + await page.goto(conversationUrl); + + // Wait for messages to load + await expect( + chatInterface.getByText("Say exactly: FIRST RESPONSE"), + ).toBeVisible({ + timeout: 15000, + }); + + // Verify still only one of each message type after reload + await expect( + chatInterface.locator('[aria-label="Your message"]'), + ).toHaveCount(1); + await expect( + chatInterface.locator('[aria-label="AI response"]'), + ).toHaveCount(1); + }); + + test("should edit user message and persist correctly", async ({ page }) => { + await page.goto("/pages/chat"); + + const chatInterface = page.locator('[data-testid="chat-interface"]'); + + // Send first message + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Original message content"); + await page.keyboard.press("Enter"); + + // Wait for user message and AI response within chat interface + await expect( + chatInterface.getByText("Original message content"), + ).toBeVisible({ + timeout: 15000, + }); + await expect( + chatInterface.locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + // Wait for the response to complete + await page.waitForTimeout(2000); + + // Wait for URL to update with conversation ID + await page.waitForURL(/\/pages\/chat\/[a-zA-Z0-9-]+/, { timeout: 10000 }); + const conversationUrl = page.url(); + + // Hover over the user message to reveal edit button + const userMessage = chatInterface + .locator('[aria-label="Your message"]') + .first(); + await userMessage.hover(); + + // Click the edit button + const editButton = chatInterface.getByTitle("Edit message").first(); + await expect(editButton).toBeVisible({ timeout: 5000 }); + await editButton.click(); + + // Find the edit textarea and modify the message + const editTextarea = chatInterface.locator("textarea").first(); + await expect(editTextarea).toBeVisible({ timeout: 5000 }); + await editTextarea.clear(); + await editTextarea.fill("Edited message content"); + + // Click the save/send button + const saveButton = chatInterface.getByTitle("Save").first(); + await saveButton.click(); + + // Wait for the edited message to appear + await expect( + chatInterface.getByText("Edited message content").first(), + ).toBeVisible({ + timeout: 15000, + }); + + // Wait for new AI response to complete + await page.waitForTimeout(5000); + + // Reload the page to verify persistence + await page.goto(conversationUrl); + + // Wait for messages to load - target user message specifically to avoid matching AI response text + await expect( + chatInterface + .locator('[aria-label="Your message"]') + .getByText("Edited message content"), + ).toBeVisible({ + timeout: 15000, + }); + + // Verify original message is gone after reload (database was synced correctly) + await expect( + chatInterface.getByText("Original message content"), + ).not.toBeVisible(); + + // Verify only one user message after reload + await expect( + chatInterface.locator('[aria-label="Your message"]'), + ).toHaveCount(1); + + // Verify one AI response after reload + await expect( + chatInterface.locator('[aria-label="AI response"]'), + ).toHaveCount(1); + }); + + test("should handle edit in middle of conversation and persist correctly", async ({ + page, + }) => { + await page.goto("/pages/chat"); + + const chatInterface = page.locator('[data-testid="chat-interface"]'); + const input = page.getByPlaceholder("Type a message..."); + + // Send first message + await input.fill("First question"); + await page.keyboard.press("Enter"); + await expect(chatInterface.getByText("First question")).toBeVisible({ + timeout: 15000, + }); + await expect( + chatInterface.locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + await page.waitForTimeout(2000); + + // Send second message + await input.fill("Second question"); + await page.keyboard.press("Enter"); + await expect(chatInterface.getByText("Second question")).toBeVisible({ + timeout: 15000, + }); + + // Wait for second AI response + await page.waitForTimeout(3000); + + // Wait for URL to update with conversation ID + await page.waitForURL(/\/pages\/chat\/[a-zA-Z0-9-]+/, { timeout: 10000 }); + const conversationUrl = page.url(); + + // Verify we have 2 user messages and 2 AI responses before edit + await expect( + chatInterface.locator('[aria-label="Your message"]'), + ).toHaveCount(2); + await expect( + chatInterface.locator('[aria-label="AI response"]'), + ).toHaveCount(2); + + // Hover over the FIRST user message to edit it + const firstUserMessage = chatInterface + .locator('[aria-label="Your message"]') + .first(); + await firstUserMessage.hover(); + + // Click the edit button (first one visible) + const editButton = chatInterface.getByTitle("Edit message").first(); + await expect(editButton).toBeVisible({ timeout: 5000 }); + await editButton.click(); + + // Edit the first message + const editTextarea = chatInterface.locator("textarea").first(); + await expect(editTextarea).toBeVisible({ timeout: 5000 }); + await editTextarea.clear(); + await editTextarea.fill("Edited first question"); + + // Save the edit + const saveButton = chatInterface.getByTitle("Save").first(); + await saveButton.click(); + + // Wait for the edited message to appear + await expect( + chatInterface.getByText("Edited first question").first(), + ).toBeVisible({ + timeout: 15000, + }); + + // Wait for new AI response to complete + await page.waitForTimeout(5000); + + // Reload and verify persistence - the edit should have truncated the conversation + await page.goto(conversationUrl); + + // Verify only the edited message and its response exist after reload + // Use locator scoped to user messages to avoid matching AI response text + await expect( + chatInterface + .locator('[aria-label="Your message"]') + .getByText("Edited first question"), + ).toBeVisible({ + timeout: 15000, + }); + + // Second question should be gone after reload (it was after the edit point) + await expect(chatInterface.getByText("Second question")).not.toBeVisible(); + + // First question (unedited) should be gone after reload + await expect( + chatInterface.getByText("First question", { exact: true }), + ).not.toBeVisible(); + + // Verify only one of each after reload + await expect( + chatInterface.locator('[aria-label="Your message"]'), + ).toHaveCount(1); + await expect( + chatInterface.locator('[aria-label="AI response"]'), + ).toHaveCount(1); + }); +}); diff --git a/e2e/tests/smoke.public-chat.spec.ts b/e2e/tests/smoke.public-chat.spec.ts new file mode 100644 index 0000000..8ccc519 --- /dev/null +++ b/e2e/tests/smoke.public-chat.spec.ts @@ -0,0 +1,254 @@ +import { test, expect } from "@playwright/test"; + +const hasOpenAiKey = + typeof process.env.OPENAI_API_KEY === "string" && + process.env.OPENAI_API_KEY.trim().length > 0; + +if (!hasOpenAiKey) { + // eslint-disable-next-line no-console -- surfaced only when tests are skipped + console.warn( + "Skipping AI chat public mode tests: OPENAI_API_KEY is not available in the environment.", + ); +} + +test.skip( + !hasOpenAiKey, + "OPENAI_API_KEY is required to run AI chat public mode tests.", +); + +/** + * E2E Tests for AI Chat Plugin in PUBLIC Mode + * + * These tests verify that: + * 1. Public chat works without authentication + * 2. No sidebar is displayed in public mode + * 3. Conversations are NOT persisted + * 4. Rate limiting hooks are called (via API tests) + * 5. Chat functionality works in stateless mode + * + * Tests use the /public-chat page and /api/public-chat endpoint. + */ + +test.describe("AI Chat Plugin - Public Mode", () => { + test("should render public chat page without sidebar", async ({ page }) => { + // Navigate to public chat page + await page.goto("/public-chat"); + + // Verify chat interface is visible + await expect(page.locator('[data-testid="chat-layout"]')).toBeVisible(); + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + + // Verify sidebar is NOT visible (public mode has no sidebar) + await expect( + page.locator('[data-testid="chat-sidebar"]'), + ).not.toBeVisible(); + + // Verify empty state message + await expect(page.getByText("Start a conversation...")).toBeVisible(); + + // Verify input is available + await expect(page.getByPlaceholder("Type a message...")).toBeVisible(); + + // Verify "New chat" button is NOT visible (no sidebar in public mode) + await expect( + page.getByRole("button", { name: "New chat", exact: true }), + ).not.toBeVisible(); + }); + + test("should send a message and receive AI response in public mode", async ({ + page, + }) => { + await page.goto("/public-chat"); + + // Verify initial state + await expect(page.getByText("Start a conversation...")).toBeVisible(); + + // Send a message + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Hello from public chat!"); + await page.keyboard.press("Enter"); + + // Verify user message appears + await expect(page.getByText("Hello from public chat!")).toBeVisible({ + timeout: 15000, + }); + + // Verify AI response appears + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + }); + + test("should NOT navigate to conversation URL in public mode", async ({ + page, + }) => { + await page.goto("/public-chat"); + + // Send a message + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Test URL behavior"); + await page.keyboard.press("Enter"); + + // Wait for AI response + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + // URL should NOT change to include conversation ID (unlike authenticated mode) + // It should stay at /public-chat + expect(page.url()).toContain("/public-chat"); + expect(page.url()).not.toMatch(/\/public-chat\/[a-zA-Z0-9]+/); + }); + + test("should NOT persist conversations in public mode", async ({ page }) => { + // Send a message + await page.goto("/public-chat"); + + const input = page.getByPlaceholder("Type a message..."); + await input.fill("Message that should not persist"); + await page.keyboard.press("Enter"); + + // Wait for AI response + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + // Refresh the page + await page.reload(); + + // The message should be gone (not persisted) + await expect( + page.getByText("Message that should not persist"), + ).not.toBeVisible(); + + // Empty state should be shown again + await expect(page.getByText("Start a conversation...")).toBeVisible(); + }); + + test("should handle multiple messages in same session", async ({ page }) => { + await page.goto("/public-chat"); + + // Send first message + const input = page.getByPlaceholder("Type a message..."); + await input.fill("First message"); + await page.keyboard.press("Enter"); + + // Wait for first AI response + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + // Send second message + await input.fill("Second message"); + await page.keyboard.press("Enter"); + + // Wait for second AI response (should be 2 responses now) + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toHaveCount(2, { timeout: 30000 }); + + // Both user messages should be visible + await expect(page.getByText("First message")).toBeVisible(); + await expect(page.getByText("Second message")).toBeVisible(); + }); +}); + +test.describe("AI Chat Plugin - Public Mode API", () => { + const API_BASE = "/api/public-chat"; + + test("API: chat endpoint works without authentication", async ({ + request, + }) => { + // Send a chat request without any auth headers + const response = await request.post(`${API_BASE}/chat`, { + data: { + messages: [ + { + id: "1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + }, + ], + }, + }); + + // Should succeed (public mode doesn't require auth) + expect(response.status()).toBe(200); + + // Response should be a stream + const contentType = response.headers()["content-type"]; + expect(contentType).toContain("text/event-stream"); + }); + + test("API: conversation endpoints return 404 in public mode", async ({ + request, + }) => { + // List conversations should return empty array or 404 + const listResponse = await request.get(`${API_BASE}/chat/conversations`); + expect(listResponse.status()).toBe(200); + const conversations = await listResponse.json(); + expect(conversations).toEqual([]); + + // Create conversation should return 404 (not available in public mode) + const createResponse = await request.post( + `${API_BASE}/chat/conversations`, + { + data: { title: "Test" }, + }, + ); + expect(createResponse.status()).toBe(404); + + // Get conversation should return 404 + const getResponse = await request.get( + `${API_BASE}/chat/conversations/abc123`, + ); + expect(getResponse.status()).toBe(404); + + // Update conversation should return 404 + const updateResponse = await request.put( + `${API_BASE}/chat/conversations/abc123`, + { + data: { title: "Updated" }, + }, + ); + expect(updateResponse.status()).toBe(404); + + // Delete conversation should return 404 + const deleteResponse = await request.delete( + `${API_BASE}/chat/conversations/abc123`, + ); + expect(deleteResponse.status()).toBe(404); + }); + + test("API: X-Conversation-Id header is NOT returned in public mode", async ({ + request, + }) => { + const response = await request.post(`${API_BASE}/chat`, { + data: { + messages: [ + { + id: "1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + }, + ], + }, + }); + + expect(response.status()).toBe(200); + + // In public mode, no conversation ID should be returned + const conversationId = response.headers()["x-conversation-id"]; + expect(conversationId).toBeUndefined(); + }); +}); diff --git a/examples/nextjs/app/api/public-chat/[[...all]]/route.ts b/examples/nextjs/app/api/public-chat/[[...all]]/route.ts new file mode 100644 index 0000000..df3709f --- /dev/null +++ b/examples/nextjs/app/api/public-chat/[[...all]]/route.ts @@ -0,0 +1,7 @@ +import { handler } from "@/lib/better-stack-public-chat"; + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const PATCH = handler; +export const DELETE = handler; diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 7366169..8594f13 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -34,6 +34,9 @@ export default function Home() { + diff --git a/examples/nextjs/app/pages/[[...all]]/layout.tsx b/examples/nextjs/app/pages/[[...all]]/layout.tsx index 5d6ce28..8621715 100644 --- a/examples/nextjs/app/pages/[[...all]]/layout.tsx +++ b/examples/nextjs/app/pages/[[...all]]/layout.tsx @@ -9,6 +9,7 @@ import { useState } from "react" import type { TodosPluginOverrides } from "@/lib/plugins/todo/client/overrides" import { getOrCreateQueryClient } from "@/lib/query-client" import { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" +import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" // Get base URL - works on both server and client // On server: uses process.env.BASE_URL @@ -18,10 +19,56 @@ const getBaseURL = () => ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) : (process.env.BASE_URL || "http://localhost:3000") +// Mock file upload URLs +const MOCK_IMAGE_URL = "https://placehold.co/400/png" +const MOCK_FILE_URL = "https://example-files.online-convert.com/document/txt/example.txt" + +// Mock file upload function that returns appropriate URL based on file type +async function mockUploadFile(file: File): Promise { + console.log("uploadFile", file.name, file.type) + // Return image placeholder for images, txt file URL for other file types + if (file.type.startsWith("image/")) { + return MOCK_IMAGE_URL + } + return MOCK_FILE_URL +} + +// Shared Next.js Image wrapper for plugins +// Handles both cases: with explicit dimensions or using fill mode +function NextImageWrapper(props: React.ImgHTMLAttributes) { + const { alt = "", src = "", width, height, ...rest } = props + + // Use fill mode if width or height are not provided + if (!width || !height) { + return ( + + {alt} + + ) + } + + return ( + {alt} + ) +} + // Define the shape of all plugin overrides type PluginOverrides = { todos: TodosPluginOverrides blog: BlogPluginOverrides, + "ai-chat": AiChatPluginOverrides, } export default function ExampleLayout({ @@ -51,38 +98,8 @@ export default function ExampleLayout({ apiBasePath: "/api/data", navigate: (path) => router.push(path), refresh: () => router.refresh(), - uploadImage: async (file) => { - console.log("uploadImage", file) - return "https://placehold.co/400/png" - }, - Image: (props) => { - const { alt = "", src = "", width, height, ...rest } = props - - // Use fill mode if width or height are not provided - if (!width || !height) { - return ( - - {alt} - - ) - } - - return ( - {alt} - ) - }, + uploadImage: mockUploadFile, + Image: NextImageWrapper, // Lifecycle Hooks - called during route rendering onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onRouteRender: Route rendered:`, routeName, context.path); @@ -110,6 +127,23 @@ export default function ExampleLayout({ console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforePostPageRendered: checking access for`, slug, context.path); return true; }, + }, + "ai-chat": { + mode: "authenticated", // Full chat with conversation history + apiBaseURL: baseURL, + apiBasePath: "/api/data", + navigate: (path) => router.push(path), + refresh: () => router.refresh(), + uploadFile: mockUploadFile, + Link: ({ href, ...props }) => , + Image: NextImageWrapper, + // Lifecycle hooks + onRouteRender: async (routeName, context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] AI Chat route:`, routeName, context.path); + }, + onRouteError: async (routeName, error, context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] AI Chat error:`, routeName, error.message); + }, } }} > diff --git a/examples/nextjs/app/public-chat/page.tsx b/examples/nextjs/app/public-chat/page.tsx new file mode 100644 index 0000000..3e61d96 --- /dev/null +++ b/examples/nextjs/app/public-chat/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client"; +import { BetterStackProvider } from "@btst/stack/context"; +import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +// Get base URL - works on both server and client +const getBaseURL = () => + typeof window !== "undefined" + ? process.env.NEXT_PUBLIC_BASE_URL || window.location.origin + : process.env.BASE_URL || "http://localhost:3000"; + +type PluginOverrides = { + "ai-chat": AiChatPluginOverrides; +}; + +/** + * Public Chat Page + * + * This demonstrates the AI Chat plugin in PUBLIC mode: + * - No authentication required + * - No conversation history (not persisted) + * - No sidebar + * - Ideal for public-facing chatbots + */ +export default function PublicChatPage() { + const [queryClient] = useState(() => new QueryClient()); + const baseURL = getBaseURL(); + + return ( + + + basePath="" + overrides={{ + "ai-chat": { + mode: "public", + apiBaseURL: baseURL, + apiBasePath: "/api/public-chat", + // Navigation not needed in public mode + navigate: () => {}, + }, + }} + > +
+
+ +
+
+ +
+ ); +} diff --git a/examples/nextjs/lib/better-stack-client.tsx b/examples/nextjs/lib/better-stack-client.tsx index e1a60b1..5ace013 100644 --- a/examples/nextjs/lib/better-stack-client.tsx +++ b/examples/nextjs/lib/better-stack-client.tsx @@ -1,6 +1,7 @@ import { createStackClient } from "@btst/stack/client" import { todosClientPlugin } from "@/lib/plugins/todo/client/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" +import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -86,7 +87,32 @@ export const getStackClient = ( ); }, } + }), + // AI Chat plugin with authenticated mode (default) + // For public chatbot without persistence, use mode: "public" + aiChat: aiChatClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + headers: options?.headers, + mode: "authenticated", // Default: full chat with conversation history + seo: { + siteName: "Better Stack Chat", + description: "AI-powered chat assistant", + }, + hooks: { + beforeLoadConversations: async (context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] beforeLoadConversations`); + return true; + }, + afterLoadConversations: async (conversations, context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] afterLoadConversations:`, conversations?.length || 0); + return true; + }, + }, }) } }) -} \ No newline at end of file +} diff --git a/examples/nextjs/lib/better-stack-public-chat.ts b/examples/nextjs/lib/better-stack-public-chat.ts new file mode 100644 index 0000000..2e998ee --- /dev/null +++ b/examples/nextjs/lib/better-stack-public-chat.ts @@ -0,0 +1,74 @@ +import { createMemoryAdapter } from "./adapters-build-check"; +import { betterStack } from "@btst/stack"; +import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api"; +import { openai } from "@ai-sdk/openai"; + +/** + * AI Chat backend plugin configured for PUBLIC mode + * + * This demonstrates a public chatbot configuration: + * - No authentication required + * - Conversations are NOT persisted to database + * - Rate limiting can be implemented via hooks + */ + +// Simple in-memory rate limiter for demo purposes +const rateLimits = new Map(); +const RATE_LIMIT = 20; // max requests per window +const RATE_WINDOW_MS = 60 * 1000; // 1 minute + +function checkRateLimit(ip: string): boolean { + const now = Date.now(); + const record = rateLimits.get(ip); + + if (!record || now > record.resetAt) { + // New window + rateLimits.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS }); + return true; + } + + if (record.count >= RATE_LIMIT) { + console.log(`[Rate Limit] Blocked: ${ip} exceeded ${RATE_LIMIT} requests`); + return false; + } + + record.count++; + return true; +} + +const { handler, dbSchema } = betterStack({ + basePath: "/api/public-chat", + plugins: { + // AI Chat in PUBLIC mode - no persistence + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + mode: "public", // Stateless mode - no database persistence + systemPrompt: + "You are a helpful customer support assistant. Be concise and friendly.", + hooks: { + onBeforeChat: async (messages, ctx) => { + // Example: Rate limiting by IP + const ip = + ctx.headers?.get("x-forwarded-for") || + ctx.headers?.get("x-real-ip") || + "unknown"; + console.log(`[Public Chat] Request from IP: ${ip}`); + + const allowed = checkRateLimit(ip); + if (!allowed) { + console.log(`[Public Chat] Rate limit exceeded for ${ip}`); + return false; + } + + console.log( + `[Public Chat] Processing ${messages.length} message(s)`, + ); + return true; + }, + }, + }), + }, + adapter: (db) => createMemoryAdapter(db)({}), +}); + +export { handler, dbSchema }; diff --git a/examples/nextjs/lib/better-stack.ts b/examples/nextjs/lib/better-stack.ts index fd6ca1f..783e9e5 100644 --- a/examples/nextjs/lib/better-stack.ts +++ b/examples/nextjs/lib/better-stack.ts @@ -3,6 +3,34 @@ import { createMemoryAdapter } from "./adapters-build-check" import { betterStack } from "@btst/stack" import { todosBackendPlugin } from "./plugins/todo/api/backend" import { blogBackendPlugin, type BlogBackendHooks } from "@btst/stack/plugins/blog/api" +import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" +import { openai } from "@ai-sdk/openai" +import { tool } from "ai" +import { z } from "zod" + +// Tool to fetch Better Stack documentation +const betterStackDocsTool = tool({ + description: "Fetch the latest Better Stack documentation. Use this tool when the user asks about Better Stack, @btst/stack, plugins, installation, configuration, database adapters, or any development-related questions about the Better Stack framework.", + inputSchema: z.object({ + query: z.string().describe("The user's question or topic they want to know about"), + }), + execute: async ({ query }) => { + console.log("Fetching Better Stack docs for query:", query) + try { + const response = await fetch("https://www.better-stack.ai/docs/llms-full.txt") + if (!response.ok) { + return { error: `Failed to fetch docs: ${response.statusText}` } + } + const docs = await response.text() + return { + docs, + note: "Use this documentation to answer the user's question accurately. The docs are in markdown format." + } + } catch (error) { + return { error: `Error fetching docs: ${error instanceof Error ? error.message : 'Unknown error'}` } + } + }, +}) // Define blog hooks with proper types // NOTE: This is the main API at /api/data - kept auth-free for regular tests @@ -65,7 +93,33 @@ const { handler, dbSchema } = betterStack({ basePath: "/api/data", plugins: { todos: todosBackendPlugin, - blog: blogBackendPlugin(blogHooks) + blog: blogBackendPlugin(blogHooks), + // AI Chat plugin with authenticated mode (default) + // Conversations are persisted but not user-scoped (no getUserId) + // For user-scoped conversations, add getUserId: + // getUserId: async (ctx) => ctx.headers?.get('x-user-id'), + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + systemPrompt: "You are a helpful assistant that specializes in Better Stack framework. When asked about Better Stack, plugins, installation, or development topics, use the betterStackDocs tool to fetch the latest documentation. Be concise and friendly.", + mode: "authenticated", // Default: persisted conversations + tools: { + betterStackDocs: betterStackDocsTool, + }, + // Optional: Extract userId from headers to scope conversations per user + // getUserId: async (ctx) => { + // const userId = ctx.headers?.get('x-user-id'); + // if (!userId) return null; // Deny access if no user + // return userId; + // }, + hooks: { + onConversationCreated: async (conversation) => { + console.log("Conversation created:", conversation.id, conversation.title); + }, + onAfterChat: async (conversationId, messages) => { + console.log("Chat completed in conversation:", conversationId, "Messages:", messages.length); + }, + }, + }) }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 26f8fb4..65b8d38 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -21,6 +21,9 @@ "drizzle-orm": "^0.41.0", "kysely": "^0.28.0", "mongodb": "^6.0.0", + "ai": "^5.0.94", + "@ai-sdk/react": "^2.0.94", + "@ai-sdk/openai": "^2.0.68", "@next/bundle-analyzer": "^16.0.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -33,7 +36,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.545.0", - "next": "16.0.7", + "next": "16.0.10", "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", diff --git a/examples/react-router/app/lib/better-stack-client.tsx b/examples/react-router/app/lib/better-stack-client.tsx index 6a93c09..d9ace7d 100644 --- a/examples/react-router/app/lib/better-stack-client.tsx +++ b/examples/react-router/app/lib/better-stack-client.tsx @@ -1,5 +1,6 @@ import { createStackClient } from "@btst/stack/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" +import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -73,7 +74,16 @@ export const getStackClient = (queryClient: QueryClient) => { ); } } + }), + // AI Chat plugin with authenticated mode + aiChat: aiChatClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + mode: "authenticated", }) } }) -} \ No newline at end of file +} diff --git a/examples/react-router/app/lib/better-stack.ts b/examples/react-router/app/lib/better-stack.ts index 4ad2f78..9a0493f 100644 --- a/examples/react-router/app/lib/better-stack.ts +++ b/examples/react-router/app/lib/better-stack.ts @@ -1,6 +1,8 @@ import { createMemoryAdapter } from "@btst/adapter-memory" import { betterStack } from "@btst/stack" import { blogBackendPlugin, type BlogBackendHooks } from "@btst/stack/plugins/blog/api" +import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" +import { openai } from "@ai-sdk/openai" // Define blog hooks with proper types const blogHooks: BlogBackendHooks = { @@ -59,7 +61,17 @@ const blogHooks: BlogBackendHooks = { const { handler, dbSchema } = betterStack({ basePath: "/api/data", plugins: { - blog: blogBackendPlugin(blogHooks) + blog: blogBackendPlugin(blogHooks), + // AI Chat plugin with authenticated mode (default) + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + mode: "authenticated", + hooks: { + onConversationCreated: async (conversation) => { + console.log("Conversation created:", conversation.id); + }, + }, + }) }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/react-router/app/routes/pages/_layout.tsx b/examples/react-router/app/routes/pages/_layout.tsx index 8358090..266e162 100644 --- a/examples/react-router/app/routes/pages/_layout.tsx +++ b/examples/react-router/app/routes/pages/_layout.tsx @@ -2,6 +2,7 @@ import { Outlet, Link, useNavigate } from "react-router"; import { BetterStackProvider } from "@btst/stack/context" import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" +import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" // Get base URL function - works on both server and client // On server: uses process.env.BASE_URL @@ -10,10 +11,25 @@ const getBaseURL = () => typeof window !== 'undefined' ? (import.meta.env.VITE_BASE_URL || window.location.origin) : (process.env.BASE_URL || "http://localhost:5173") - - // Define the shape of all plugin overrides + +// Mock file upload URLs +const MOCK_IMAGE_URL = "https://placehold.co/400/png" +const MOCK_FILE_URL = "https://example-files.online-convert.com/document/txt/example.txt" + +// Mock file upload function that returns appropriate URL based on file type +async function mockUploadFile(file: File): Promise { + console.log("uploadFile", file.name, file.type) + // Return image placeholder for images, txt file URL for other file types + if (file.type.startsWith("image/")) { + return MOCK_IMAGE_URL + } + return MOCK_FILE_URL +} + +// Define the shape of all plugin overrides type PluginOverrides = { blog: BlogPluginOverrides, + "ai-chat": AiChatPluginOverrides, } export default function Layout() { @@ -30,10 +46,7 @@ export default function Layout() { apiBaseURL: baseURL, apiBasePath: "/api/data", navigate: (href) => navigate(href), - uploadImage: async (file) => { - console.log("uploadImage", file) - return "https://placehold.co/400/png" - }, + uploadImage: mockUploadFile, Link: ({ href, children, className, ...props }) => ( {children} @@ -66,6 +79,21 @@ export default function Layout() { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforePostPageRendered: checking access for`, slug, context.path); return true; }, + }, + "ai-chat": { + mode: "authenticated", + apiBaseURL: baseURL, + apiBasePath: "/api/data", + navigate: (href) => navigate(href), + uploadFile: mockUploadFile, + Link: ({ href, children, className, ...props }) => ( + + {children} + + ), + onRouteRender: async (routeName, context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] AI Chat route:`, routeName, context.path); + }, } }} > diff --git a/examples/react-router/package.json b/examples/react-router/package.json index 0e20af0..09f37a2 100644 --- a/examples/react-router/package.json +++ b/examples/react-router/package.json @@ -7,12 +7,15 @@ "dev": "react-router dev", "start": "react-router-serve ./build/server/index.js", "typecheck": "react-router typegen && tsc", - "start:e2e": "rm -rf build && rm -rf .react-router && rm -rf node_modules && pnpm install && pnpm build && NODE_ENV=test react-router-serve ./build/server/index.js" + "start:e2e": "rm -rf build && rm -rf .react-router && rm -rf node_modules && pnpm install && pnpm build && NODE_ENV=test dotenv -e .env -- react-router-serve ./build/server/index.js" }, "dependencies": { "@btst/adapter-memory": "^2.0.3", "@btst/db": "^2.0.3", "@btst/stack": "workspace:*", + "ai": "^5.0.94", + "@ai-sdk/react": "^2.0.94", + "@ai-sdk/openai": "^2.0.68", "@btst/yar": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", @@ -39,6 +42,7 @@ "@types/node": "^22", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", + "dotenv-cli": "^7.4.2", "tailwindcss": "^4.1.13", "tw-animate-css": "^1.4.0", "typescript": "catalog:", diff --git a/examples/tanstack/package.json b/examples/tanstack/package.json index b1ec6f3..be9e98b 100644 --- a/examples/tanstack/package.json +++ b/examples/tanstack/package.json @@ -7,7 +7,7 @@ "dev": "vite dev", "build": "vite build", "start": "node .output/server/index.mjs", - "start:e2e": "rm -rf .output && rm -rf .nitro && rm -rf .tanstack && rm -rf node_modules && pnpm install && pnpm build && NODE_ENV=test node .output/server/index.mjs" + "start:e2e": "rm -rf .output && rm -rf .nitro && rm -rf .tanstack && rm -rf node_modules && pnpm install && pnpm build && NODE_ENV=test dotenv -e .env -- node .output/server/index.mjs" }, "keywords": [], "author": "", @@ -17,6 +17,9 @@ "@btst/adapter-memory": "^2.0.3", "@btst/db": "^2.0.3", "@btst/stack": "workspace:*", + "ai": "^5.0.94", + "@ai-sdk/react": "^2.0.94", + "@ai-sdk/openai": "^2.0.68", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/postcss": "^4.1.16", @@ -43,6 +46,7 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", + "dotenv-cli": "^7.4.2", "nitro": "3.0.1-alpha.0", "tw-animate-css": "^1.4.0", "typescript": "catalog:", diff --git a/examples/tanstack/src/lib/better-stack-client.tsx b/examples/tanstack/src/lib/better-stack-client.tsx index 2a41ff6..c863d6e 100644 --- a/examples/tanstack/src/lib/better-stack-client.tsx +++ b/examples/tanstack/src/lib/better-stack-client.tsx @@ -1,5 +1,6 @@ import { createStackClient } from "@btst/stack/client" import { blogClientPlugin } from "@btst/stack/plugins/blog/client" +import { aiChatClientPlugin } from "@btst/stack/plugins/ai-chat/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -73,7 +74,16 @@ export const getStackClient = (queryClient: QueryClient) => { ); } } + }), + // AI Chat plugin with authenticated mode + aiChat: aiChatClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + mode: "authenticated", }) } }) -} \ No newline at end of file +} diff --git a/examples/tanstack/src/lib/better-stack.ts b/examples/tanstack/src/lib/better-stack.ts index d10aad4..b4250fc 100644 --- a/examples/tanstack/src/lib/better-stack.ts +++ b/examples/tanstack/src/lib/better-stack.ts @@ -1,6 +1,8 @@ import { createMemoryAdapter } from "@btst/adapter-memory" import { betterStack } from "@btst/stack" import { blogBackendPlugin, type BlogBackendHooks } from "@btst/stack/plugins/blog/api" +import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" +import { openai } from "@ai-sdk/openai" const blogHooks: BlogBackendHooks = { onBeforeCreatePost: async (data) => { @@ -58,7 +60,17 @@ const blogHooks: BlogBackendHooks = { const { handler, dbSchema } = betterStack({ basePath: "/api/data", plugins: { - blog: blogBackendPlugin(blogHooks) + blog: blogBackendPlugin(blogHooks), + // AI Chat plugin with authenticated mode (default) + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + mode: "authenticated", + hooks: { + onConversationCreated: async (conversation) => { + console.log("Conversation created:", conversation.id); + }, + }, + }) }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/tanstack/src/routes/pages/route.tsx b/examples/tanstack/src/routes/pages/route.tsx index 5257126..a0b960c 100644 --- a/examples/tanstack/src/routes/pages/route.tsx +++ b/examples/tanstack/src/routes/pages/route.tsx @@ -3,6 +3,7 @@ import { BetterStackProvider } from "@btst/stack/context" import { QueryClientProvider } from "@tanstack/react-query" import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" +import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" import { Link, useRouter, Outlet, createFileRoute } from "@tanstack/react-router" // Get base URL function - works on both server and client @@ -13,9 +14,24 @@ const getBaseURL = () => ? (import.meta.env.VITE_BASE_URL || window.location.origin) : (process.env.BASE_URL || "http://localhost:3000") +// Mock file upload URLs +const MOCK_IMAGE_URL = "https://placehold.co/400/png" +const MOCK_FILE_URL = "https://example-files.online-convert.com/document/txt/example.txt" + +// Mock file upload function that returns appropriate URL based on file type +async function mockUploadFile(file: File): Promise { + console.log("uploadFile", file.name, file.type) + // Return image placeholder for images, txt file URL for other file types + if (file.type.startsWith("image/")) { + return MOCK_IMAGE_URL + } + return MOCK_FILE_URL +} + // Define the shape of all plugin overrides type PluginOverrides = { blog: BlogPluginOverrides, + "ai-chat": AiChatPluginOverrides, } export const Route = createFileRoute('/pages')({ @@ -40,10 +56,7 @@ function Layout() { apiBaseURL: baseURL, apiBasePath: "/api/data", navigate: (href) => router.navigate({ href }), - uploadImage: async (file) => { - console.log("uploadImage", file) - return "https://placehold.co/400/png" - }, + uploadImage: mockUploadFile, Link: ({ href, children, className, ...props }) => ( {children} @@ -76,6 +89,21 @@ function Layout() { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforePostPageRendered: checking access for`, slug, context.path); return true; }, + }, + "ai-chat": { + mode: "authenticated", + apiBaseURL: baseURL, + apiBasePath: "/api/data", + navigate: (href) => router.navigate({ href }), + uploadFile: mockUploadFile, + Link: ({ href, children, className, ...props }) => ( + + {children} + + ), + onRouteRender: async (routeName, context) => { + console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] AI Chat route:`, routeName, context.path); + }, } }} > diff --git a/packages/better-stack/build.config.ts b/packages/better-stack/build.config.ts index b43b3a1..d1455e0 100644 --- a/packages/better-stack/build.config.ts +++ b/packages/better-stack/build.config.ts @@ -70,6 +70,12 @@ export default defineBuildConfig({ "./src/plugins/blog/client/components/index.tsx", "./src/plugins/blog/client/hooks/index.tsx", "./src/plugins/blog/query-keys.ts", + // ai-chat plugin entries + "./src/plugins/ai-chat/api/index.ts", + "./src/plugins/ai-chat/client/index.ts", + "./src/plugins/ai-chat/client/components/index.ts", + "./src/plugins/ai-chat/client/hooks/index.tsx", + "./src/plugins/ai-chat/query-keys.ts", ], hooks: { "rollup:options"(_ctx, options) { diff --git a/packages/better-stack/package.json b/packages/better-stack/package.json index 7887beb..bcfe5de 100644 --- a/packages/better-stack/package.json +++ b/packages/better-stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "1.3.1", + "version": "1.4.0", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", @@ -123,6 +123,47 @@ "default": "./dist/plugins/blog/client/index.cjs" } }, + "./plugins/ai-chat/api": { + "import": { + "types": "./dist/plugins/ai-chat/api/index.d.ts", + "default": "./dist/plugins/ai-chat/api/index.mjs" + }, + "require": { + "types": "./dist/plugins/ai-chat/api/index.d.cts", + "default": "./dist/plugins/ai-chat/api/index.cjs" + } + }, + "./plugins/ai-chat/client": { + "import": { + "types": "./dist/plugins/ai-chat/client/index.d.ts", + "default": "./dist/plugins/ai-chat/client/index.mjs" + }, + "require": { + "types": "./dist/plugins/ai-chat/client/index.d.cts", + "default": "./dist/plugins/ai-chat/client/index.cjs" + } + }, + "./plugins/ai-chat/client/components": { + "import": { + "types": "./dist/plugins/ai-chat/client/components/index.d.ts", + "default": "./dist/plugins/ai-chat/client/components/index.mjs" + }, + "require": { + "types": "./dist/plugins/ai-chat/client/components/index.d.cts", + "default": "./dist/plugins/ai-chat/client/components/index.cjs" + } + }, + "./plugins/ai-chat/client/hooks": { + "import": { + "types": "./dist/plugins/ai-chat/client/hooks/index.d.ts", + "default": "./dist/plugins/ai-chat/client/hooks/index.mjs" + }, + "require": { + "types": "./dist/plugins/ai-chat/client/hooks/index.d.cts", + "default": "./dist/plugins/ai-chat/client/hooks/index.cjs" + } + }, + "./plugins/ai-chat/css": "./dist/plugins/ai-chat/style.css", "./plugins/blog/css": "./dist/plugins/blog/style.css", "./dist/*": "./dist/*", "./ui/css": "./dist/ui/components.css", @@ -153,6 +194,18 @@ ], "plugins/blog/client": [ "./dist/plugins/blog/client/index.d.ts" + ], + "plugins/ai-chat/api": [ + "./dist/plugins/ai-chat/api/index.d.ts" + ], + "plugins/ai-chat/client": [ + "./dist/plugins/ai-chat/client/index.d.ts" + ], + "plugins/ai-chat/client/components": [ + "./dist/plugins/ai-chat/client/components/index.d.ts" + ], + "plugins/ai-chat/client/hooks": [ + "./dist/plugins/ai-chat/client/hooks/index.d.ts" ] } }, @@ -161,9 +214,11 @@ "@lukemorales/query-key-factory": "^1.3.4", "@milkdown/crepe": "^7.17.1", "@milkdown/kit": "^7.17.1", + "remend": "^1.0.1", "slug": "^11.0.1" }, "peerDependencies": { + "@ai-sdk/react": ">=2.0.0", "@btst/yar": ">=1.1.0", "@hookform/resolvers": ">=5.0.0", "@radix-ui/react-dialog": ">=1.1.0", @@ -171,6 +226,7 @@ "@radix-ui/react-slot": ">=1.1.0", "@radix-ui/react-switch": ">=1.1.0", "@tanstack/react-query": "^5.0.0", + "ai": ">=5.0.0", "better-call": ">=1.0.0", "class-variance-authority": ">=0.7.0", "clsx": ">=2.1.0", @@ -196,11 +252,13 @@ "zod": ">=3.24.0" }, "devDependencies": { + "@ai-sdk/react": "^2.0.94", "@btst/adapter-memory": "2.0.3", "@btst/yar": "1.1.1", "@types/react": "^19.0.0", "@types/slug": "^5.0.9", "@workspace/ui": "workspace:*", + "ai": "^5.0.94", "better-call": "1.0.19", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/packages/better-stack/src/plugins/ai-chat/api/index.ts b/packages/better-stack/src/plugins/ai-chat/api/index.ts new file mode 100644 index 0000000..957a114 --- /dev/null +++ b/packages/better-stack/src/plugins/ai-chat/api/index.ts @@ -0,0 +1,2 @@ +export * from "./plugin"; +export { createAiChatQueryKeys } from "../query-keys"; diff --git a/packages/better-stack/src/plugins/ai-chat/api/plugin.ts b/packages/better-stack/src/plugins/ai-chat/api/plugin.ts new file mode 100644 index 0000000..92ba513 --- /dev/null +++ b/packages/better-stack/src/plugins/ai-chat/api/plugin.ts @@ -0,0 +1,1083 @@ +import type { Adapter } from "@btst/db"; +import { defineBackendPlugin } from "@btst/stack/plugins/api"; +import { createEndpoint } from "@btst/stack/plugins/api"; +import { + streamText, + convertToModelMessages, + stepCountIs, + type LanguageModel, + type UIMessage, + type Tool, +} from "ai"; +import { aiChatSchema as dbSchema } from "../db"; +import { + chatRequestSchema, + createConversationSchema, + updateConversationSchema, +} from "../schemas"; +import type { Conversation, ConversationWithMessages, Message } from "../types"; + +/** + * Context passed to AI Chat API hooks + */ +export interface ChatApiContext { + body?: TBody; + params?: TParams; + query?: TQuery; + request?: Request; + headers?: Headers; + [key: string]: any; +} + +/** + * Configuration hooks for AI Chat backend plugin + * All hooks are optional and allow consumers to customize behavior + */ +export interface AiChatBackendHooks { + // ============== Authorization Hooks ============== + // Return false to deny access + + /** + * Called before processing a chat message. Return false to deny access. + * @param messages - Array of messages being sent + * @param context - Request context with headers, etc. + */ + onBeforeChat?: ( + messages: Array<{ role: string; content: string }>, + context: ChatApiContext, + ) => Promise | boolean; + + /** + * Called before listing conversations. Return false to deny access. + * @param context - Request context with headers, etc. + */ + onBeforeListConversations?: ( + context: ChatApiContext, + ) => Promise | boolean; + + /** + * Called before getting a single conversation. Return false to deny access. + * @param conversationId - ID of the conversation being accessed + * @param context - Request context with headers, etc. + */ + onBeforeGetConversation?: ( + conversationId: string, + context: ChatApiContext, + ) => Promise | boolean; + + /** + * Called before creating a conversation. Return false to deny access. + * @param data - Conversation data being created + * @param context - Request context with headers, etc. + */ + onBeforeCreateConversation?: ( + data: { id?: string; title?: string }, + context: ChatApiContext, + ) => Promise | boolean; + + /** + * Called before updating a conversation. Return false to deny access. + * @param conversationId - ID of the conversation being updated + * @param data - Updated conversation data + * @param context - Request context with headers, etc. + */ + onBeforeUpdateConversation?: ( + conversationId: string, + data: { title?: string }, + context: ChatApiContext, + ) => Promise | boolean; + + /** + * Called before deleting a conversation. Return false to deny access. + * @param conversationId - ID of the conversation being deleted + * @param context - Request context with headers, etc. + */ + onBeforeDeleteConversation?: ( + conversationId: string, + context: ChatApiContext, + ) => Promise | boolean; + + // ============== Lifecycle Hooks ============== + + /** + * Called after a chat message is processed successfully + * @param conversationId - ID of the conversation + * @param messages - Array of messages in the conversation + * @param context - Request context + */ + onAfterChat?: ( + conversationId: string, + messages: Message[], + context: ChatApiContext, + ) => Promise | void; + + /** + * Called after conversations are read successfully + * @param conversations - Array of conversations that were read + * @param context - Request context + */ + onConversationsRead?: ( + conversations: Conversation[], + context: ChatApiContext, + ) => Promise | void; + + /** + * Called after a single conversation is read successfully + * @param conversation - The conversation with messages + * @param context - Request context + */ + onConversationRead?: ( + conversation: Conversation & { messages: Message[] }, + context: ChatApiContext, + ) => Promise | void; + + /** + * Called after a conversation is created successfully + * @param conversation - The created conversation + * @param context - Request context + */ + onConversationCreated?: ( + conversation: Conversation, + context: ChatApiContext, + ) => Promise | void; + + /** + * Called after a conversation is updated successfully + * @param conversation - The updated conversation + * @param context - Request context + */ + onConversationUpdated?: ( + conversation: Conversation, + context: ChatApiContext, + ) => Promise | void; + + /** + * Called after a conversation is deleted successfully + * @param conversationId - ID of the deleted conversation + * @param context - Request context + */ + onConversationDeleted?: ( + conversationId: string, + context: ChatApiContext, + ) => Promise | void; + + // ============== Error Hooks ============== + + /** + * Called when a chat operation fails + * @param error - The error that occurred + * @param context - Request context + */ + onChatError?: (error: Error, context: ChatApiContext) => Promise | void; + + /** + * Called when listing conversations fails + * @param error - The error that occurred + * @param context - Request context + */ + onListConversationsError?: ( + error: Error, + context: ChatApiContext, + ) => Promise | void; + + /** + * Called when getting a conversation fails + * @param error - The error that occurred + * @param context - Request context + */ + onGetConversationError?: ( + error: Error, + context: ChatApiContext, + ) => Promise | void; + + /** + * Called when creating a conversation fails + * @param error - The error that occurred + * @param context - Request context + */ + onCreateConversationError?: ( + error: Error, + context: ChatApiContext, + ) => Promise | void; + + /** + * Called when updating a conversation fails + * @param error - The error that occurred + * @param context - Request context + */ + onUpdateConversationError?: ( + error: Error, + context: ChatApiContext, + ) => Promise | void; + + /** + * Called when deleting a conversation fails + * @param error - The error that occurred + * @param context - Request context + */ + onDeleteConversationError?: ( + error: Error, + context: ChatApiContext, + ) => Promise | void; +} + +/** + * Plugin mode for AI Chat + * - 'authenticated': Conversations persisted with userId (default) + * - 'public': Stateless chat, no persistence (ideal for public chatbots) + */ +export type AiChatMode = "authenticated" | "public"; + +/** + * Configuration for AI Chat backend plugin + */ +export interface AiChatBackendConfig { + /** + * The language model to use for chat completions. + * Supports any model from AI SDK providers (OpenAI, Anthropic, Google, etc.) + */ + model: LanguageModel; + + /** + * Plugin mode: + * - 'authenticated': Conversations persisted with userId (requires getUserId) + * - 'public': Stateless chat, no persistence (ideal for public chatbots) + * @default 'authenticated' + */ + mode?: AiChatMode; + + /** + * Extract userId from request context (authenticated mode only). + * Return null/undefined to deny access in authenticated mode. + * This function is called for all conversation operations. + * @example (ctx) => ctx.headers?.get('x-user-id') + */ + getUserId?: ( + context: ChatApiContext, + ) => string | null | undefined | Promise; + + /** + * Optional system prompt to prepend to all conversations + */ + systemPrompt?: string; + + /** + * Optional tools to make available to the model. + * Uses AI SDK v5 tool format. + * @see https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling + */ + tools?: Record; + + /** + * Optional hooks for customizing plugin behavior + */ + hooks?: AiChatBackendHooks; +} + +/** + * AI Chat backend plugin + * Provides API endpoints for AI-powered chat with conversation history + * Uses AI SDK v5 for model interactions + * + * @param config - Configuration including model, tools, and optional hooks + */ +export const aiChatBackendPlugin = (config: AiChatBackendConfig) => + defineBackendPlugin({ + name: "ai-chat", + // Always include db schema - in public mode we just don't use it + dbPlugin: dbSchema, + routes: (adapter: Adapter) => { + const mode = config.mode ?? "authenticated"; + const isPublicMode = mode === "public"; + + // Helper to extract text content from UIMessage (for conversation titles, etc.) + const getMessageTextContent = (msg: UIMessage): string => { + if (msg.parts && Array.isArray(msg.parts)) { + return msg.parts + .filter((part: any) => part.type === "text") + .map((part: any) => part.text) + .join(""); + } + return ""; + }; + + // Helper to serialize message parts to JSON (preserves all files) + const serializeMessageParts = (msg: UIMessage): string => { + if (msg.parts && Array.isArray(msg.parts)) { + // Filter to only include text and file parts (images, PDFs, text files, etc.) + const serializableParts = msg.parts.filter( + (part: any) => part.type === "text" || part.type === "file", + ); + return JSON.stringify(serializableParts); + } + return JSON.stringify([]); + }; + + // Helper to get userId in authenticated mode + // Returns null if no getUserId is configured, or the userId if available + // Throws if getUserId returns null/undefined (auth required but not provided) + const resolveUserId = async ( + context: ChatApiContext, + throwOnMissing: () => never, + ): Promise => { + if (isPublicMode) { + return null; + } + if (!config.getUserId) { + // If no getUserId is provided, conversations are not user-scoped + return null; + } + const userId = await config.getUserId(context); + if (!userId) { + throwOnMissing(); + } + return userId; + }; + + // ============== Chat Endpoint ============== + const chat = createEndpoint( + "/chat", + { + method: "POST", + body: chatRequestSchema, + }, + async (ctx) => { + const { messages: rawMessages, conversationId } = ctx.body; + const uiMessages = rawMessages as UIMessage[]; + + const context: ChatApiContext = { + body: ctx.body, + headers: ctx.headers, + request: ctx.request, + }; + + try { + // Authorization hook + if (config.hooks?.onBeforeChat) { + const messagesForHook = uiMessages.map((msg) => ({ + role: msg.role, + content: getMessageTextContent(msg), + })); + const canChat = await config.hooks.onBeforeChat( + messagesForHook, + context, + ); + if (!canChat) { + throw ctx.error(403, { + message: "Unauthorized: Cannot start chat", + }); + } + } + + const firstMessage = uiMessages[0]; + if (!firstMessage) { + throw ctx.error(400, { + message: "At least one message is required", + }); + } + const firstMessageContent = getMessageTextContent(firstMessage); + + // Convert UIMessages to CoreMessages for streamText + const modelMessages = convertToModelMessages(uiMessages); + + // Add system prompt if configured + const messagesWithSystem = config.systemPrompt + ? [ + { role: "system" as const, content: config.systemPrompt }, + ...modelMessages, + ] + : modelMessages; + + // PUBLIC MODE: Stream without persistence + if (isPublicMode) { + const result = streamText({ + model: config.model, + messages: messagesWithSystem, + tools: config.tools, + // Enable multi-step tool calls if tools are configured + ...(config.tools ? { stopWhen: stepCountIs(5) } : {}), + }); + + return result.toUIMessageStreamResponse({ + originalMessages: uiMessages, + }); + } + + // AUTHENTICATED MODE: Persist conversations + // Get userId if getUserId is configured + let userId: string | null = null; + if (config.getUserId) { + const resolvedUserId = await config.getUserId(context); + if (!resolvedUserId) { + throw ctx.error(403, { + message: "Unauthorized: User authentication required", + }); + } + userId = resolvedUserId; + } + + let convId = conversationId; + + // Create or verify conversation + if (!convId) { + const newConv = await adapter.create({ + model: "conversation", + data: { + ...(userId ? { userId } : {}), + title: firstMessageContent.slice(0, 50) || "New Conversation", + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + convId = newConv.id; + } else { + const existing = await adapter.findMany({ + model: "conversation", + where: [{ field: "id", value: convId, operator: "eq" }], + limit: 1, + }); + if (!existing.length) { + const newConv = await adapter.create({ + model: "conversation", + data: { + id: convId, + ...(userId ? { userId } : {}), + title: + firstMessageContent.slice(0, 50) || "New Conversation", + createdAt: new Date(), + updatedAt: new Date(), + } as Conversation, + }); + convId = newConv.id; + } else { + // Verify ownership if userId is set + const conv = existing[0]!; + if (userId && conv.userId && conv.userId !== userId) { + throw ctx.error(403, { + message: "Unauthorized: Cannot access this conversation", + }); + } + } + } + + // Sync database messages with client state + // The client sends all messages for the conversation + // We need to ensure the DB matches this state before adding the assistant response + const existingMessages = await adapter.findMany({ + model: "message", + where: [ + { + field: "conversationId", + value: convId as string, + operator: "eq", + }, + ], + sortBy: { field: "createdAt", direction: "asc" }, + }); + + const lastIncomingMessage = uiMessages[uiMessages.length - 1]; + const isNewUserMessage = lastIncomingMessage?.role === "user"; + + // Determine the expected DB count before we add new messages: + // - If last incoming is a user message: it might be new or existing + // - Compare with what's in DB to determine if it's a new message or regenerate + let expectedDbCount: number; + let shouldAddUserMessage = false; + + if (isNewUserMessage) { + // Check if this user message already exists in DB + // by comparing the last user message in each + const lastDbUserMessage = [...existingMessages] + .reverse() + .find((m) => m.role === "user"); + const incomingUserContent = + serializeMessageParts(lastIncomingMessage); + + if ( + lastDbUserMessage && + lastDbUserMessage.content === incomingUserContent + ) { + // The user message already exists - this is a regenerate + // DB should have all incoming messages (no new user message to add) + expectedDbCount = uiMessages.length; + shouldAddUserMessage = false; + } else { + // New user message - DB should have incoming count - 1 + expectedDbCount = uiMessages.length - 1; + shouldAddUserMessage = true; + } + } else { + // Last message is not user (unusual case) + expectedDbCount = uiMessages.length; + shouldAddUserMessage = false; + } + + // If DB has more messages than expected, delete the excess + // This handles both edit (truncated history) and retry (regenerating last response) + // Use a transaction to ensure atomicity - if create fails, deletions are rolled back + const actualDbCount = existingMessages.length; + const messagesToDelete = + actualDbCount > expectedDbCount + ? existingMessages.slice(expectedDbCount) + : []; + + // Wrap deletion and creation in a transaction for atomicity + // This prevents data loss if the create operation fails after deletions + await adapter.transaction(async (tx) => { + // Delete excess messages + for (const msg of messagesToDelete) { + await tx.delete({ + model: "message", + where: [{ field: "id", value: msg.id }], + }); + } + + // Save user message if it's new + if (shouldAddUserMessage && lastIncomingMessage) { + await tx.create({ + model: "message", + data: { + conversationId: convId as string, + role: "user", + content: serializeMessageParts(lastIncomingMessage), + createdAt: new Date(), + }, + }); + } + }); + + const result = streamText({ + model: config.model, + messages: messagesWithSystem, + tools: config.tools, + // Enable multi-step tool calls if tools are configured + ...(config.tools ? { stopWhen: stepCountIs(5) } : {}), + onFinish: async (completion: { text: string }) => { + // Wrap in try-catch since this runs after the response is sent + // and errors would otherwise become unhandled promise rejections + try { + // Save assistant message (serialize as parts for consistency) + const assistantParts = completion.text + ? [{ type: "text", text: completion.text }] + : []; + await adapter.create({ + model: "message", + data: { + conversationId: convId as string, + role: "assistant", + content: JSON.stringify(assistantParts), + createdAt: new Date(), + }, + }); + + // Update conversation timestamp + await adapter.update({ + model: "conversation", + where: [{ field: "id", value: convId as string }], + update: { updatedAt: new Date() }, + }); + + // Lifecycle hook + if (config.hooks?.onAfterChat) { + const messages = await adapter.findMany({ + model: "message", + where: [ + { + field: "conversationId", + value: convId as string, + operator: "eq", + }, + ], + sortBy: { field: "createdAt", direction: "asc" }, + }); + await config.hooks.onAfterChat( + convId as string, + messages, + context, + ); + } + } catch (error) { + // Log the error since the response is already sent + console.error("[ai-chat] Error in onFinish callback:", error); + // Call error hook if configured + if (config.hooks?.onChatError) { + try { + await config.hooks.onChatError(error as Error, context); + } catch (hookError) { + console.error( + "[ai-chat] Error in onChatError hook:", + hookError, + ); + } + } + } + }, + }); + + // Return the stream response with conversation ID header + // This allows the client to know which conversation was created/used + const response = result.toUIMessageStreamResponse({ + originalMessages: uiMessages, + }); + + // Add the conversation ID header to the response + const headers = new Headers(response.headers); + headers.set("X-Conversation-Id", convId as string); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } catch (error) { + if (config.hooks?.onChatError) { + await config.hooks.onChatError(error as Error, context); + } + throw error; + } + }, + ); + + // ============== Create Conversation ============== + const createConversation = createEndpoint( + "/chat/conversations", + { + method: "POST", + body: createConversationSchema, + }, + async (ctx) => { + // Public mode: conversations are not persisted + if (isPublicMode) { + throw ctx.error(404, { + message: "Conversations not available in public mode", + }); + } + + const { id, title } = ctx.body; + const context: ChatApiContext = { + body: ctx.body, + headers: ctx.headers, + }; + + try { + // Get userId if configured + const userId = await resolveUserId(context, () => { + throw ctx.error(403, { + message: "Unauthorized: User authentication required", + }); + }); + + // Authorization hook + if (config.hooks?.onBeforeCreateConversation) { + const canCreate = await config.hooks.onBeforeCreateConversation( + { id, title }, + context, + ); + if (!canCreate) { + throw ctx.error(403, { + message: "Unauthorized: Cannot create conversation", + }); + } + } + + const newConv = await adapter.create({ + model: "conversation", + data: { + ...(id ? { id } : {}), + ...(userId ? { userId } : {}), + title: title || "New Conversation", + createdAt: new Date(), + updatedAt: new Date(), + } as Conversation, + }); + + // Lifecycle hook + if (config.hooks?.onConversationCreated) { + await config.hooks.onConversationCreated(newConv, context); + } + + return newConv; + } catch (error) { + if (config.hooks?.onCreateConversationError) { + await config.hooks.onCreateConversationError( + error as Error, + context, + ); + } + throw error; + } + }, + ); + + // ============== List Conversations ============== + const listConversations = createEndpoint( + "/chat/conversations", + { + method: "GET", + }, + async (ctx) => { + // Public mode: return empty list + if (isPublicMode) { + return []; + } + + const context: ChatApiContext = { + headers: ctx.headers, + }; + + try { + // Get userId if configured + const userId = await resolveUserId(context, () => { + throw ctx.error(403, { + message: "Unauthorized: User authentication required", + }); + }); + + // Authorization hook + if (config.hooks?.onBeforeListConversations) { + const canList = + await config.hooks.onBeforeListConversations(context); + if (!canList) { + throw ctx.error(403, { + message: "Unauthorized: Cannot list conversations", + }); + } + } + + // Build where conditions - filter by userId if set + const whereConditions: Array<{ + field: string; + value: string; + operator: "eq"; + }> = []; + if (userId) { + whereConditions.push({ + field: "userId", + value: userId, + operator: "eq", + }); + } + + const conversations = await adapter.findMany({ + model: "conversation", + where: whereConditions.length > 0 ? whereConditions : undefined, + sortBy: { field: "updatedAt", direction: "desc" }, + }); + + // Lifecycle hook + if (config.hooks?.onConversationsRead) { + await config.hooks.onConversationsRead(conversations, context); + } + + return conversations; + } catch (error) { + if (config.hooks?.onListConversationsError) { + await config.hooks.onListConversationsError( + error as Error, + context, + ); + } + throw error; + } + }, + ); + + // ============== Get Conversation ============== + const getConversation = createEndpoint( + "/chat/conversations/:id", + { + method: "GET", + }, + async (ctx) => { + // Public mode: conversations are not persisted + if (isPublicMode) { + throw ctx.error(404, { + message: "Conversations not available in public mode", + }); + } + + const { id } = ctx.params; + const context: ChatApiContext = { + params: ctx.params, + headers: ctx.headers, + }; + + try { + // Get userId if configured + const userId = await resolveUserId(context, () => { + throw ctx.error(403, { + message: "Unauthorized: User authentication required", + }); + }); + + // Authorization hook + if (config.hooks?.onBeforeGetConversation) { + const canGet = await config.hooks.onBeforeGetConversation( + id, + context, + ); + if (!canGet) { + throw ctx.error(403, { + message: "Unauthorized: Cannot get conversation", + }); + } + } + + // Fetch conversation with messages in a single query using join + const conversations = + await adapter.findMany({ + model: "conversation", + where: [{ field: "id", value: id, operator: "eq" }], + limit: 1, + join: { + message: true, + }, + }); + + if (!conversations.length) { + throw ctx.error(404, { message: "Conversation not found" }); + } + + const conversation = conversations[0]!; + + // Verify ownership if userId is set + if ( + userId && + conversation.userId && + conversation.userId !== userId + ) { + throw ctx.error(403, { + message: "Unauthorized: Cannot access this conversation", + }); + } + + // Sort messages by createdAt and map to result format + const messages = (conversation.message || []).sort( + (a, b) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime(), + ); + + const { message: _, ...conversationWithoutJoin } = conversation; + const result = { + ...conversationWithoutJoin, + messages, + } as Conversation & { messages: Message[] }; + + // Lifecycle hook + if (config.hooks?.onConversationRead) { + await config.hooks.onConversationRead(result, context); + } + + return result; + } catch (error) { + if (config.hooks?.onGetConversationError) { + await config.hooks.onGetConversationError( + error as Error, + context, + ); + } + throw error; + } + }, + ); + + // ============== Update Conversation ============== + const updateConversation = createEndpoint( + "/chat/conversations/:id", + { + method: "PUT", + body: updateConversationSchema, + }, + async (ctx) => { + // Public mode: conversations are not persisted + if (isPublicMode) { + throw ctx.error(404, { + message: "Conversations not available in public mode", + }); + } + + const { id } = ctx.params; + const { title } = ctx.body; + const context: ChatApiContext = { + params: ctx.params, + body: ctx.body, + headers: ctx.headers, + }; + + try { + // Get userId if configured + const userId = await resolveUserId(context, () => { + throw ctx.error(403, { + message: "Unauthorized: User authentication required", + }); + }); + + // Check ownership before update + const existing = await adapter.findMany({ + model: "conversation", + where: [{ field: "id", value: id, operator: "eq" }], + limit: 1, + }); + + if (!existing.length) { + throw ctx.error(404, { message: "Conversation not found" }); + } + + const conversation = existing[0]!; + if ( + userId && + conversation.userId && + conversation.userId !== userId + ) { + throw ctx.error(403, { + message: "Unauthorized: Cannot update this conversation", + }); + } + + // Authorization hook + if (config.hooks?.onBeforeUpdateConversation) { + const canUpdate = await config.hooks.onBeforeUpdateConversation( + id, + { title }, + context, + ); + if (!canUpdate) { + throw ctx.error(403, { + message: "Unauthorized: Cannot update conversation", + }); + } + } + + const updated = await adapter.update({ + model: "conversation", + where: [{ field: "id", value: id }], + update: { + ...(title !== undefined ? { title } : {}), + updatedAt: new Date(), + }, + }); + + if (!updated) { + throw ctx.error(404, { message: "Conversation not found" }); + } + + // Lifecycle hook + if (config.hooks?.onConversationUpdated) { + await config.hooks.onConversationUpdated(updated, context); + } + + return updated; + } catch (error) { + if (config.hooks?.onUpdateConversationError) { + await config.hooks.onUpdateConversationError( + error as Error, + context, + ); + } + throw error; + } + }, + ); + + // ============== Delete Conversation ============== + const deleteConversation = createEndpoint( + "/chat/conversations/:id", + { + method: "DELETE", + }, + async (ctx) => { + // Public mode: conversations are not persisted + if (isPublicMode) { + throw ctx.error(404, { + message: "Conversations not available in public mode", + }); + } + + const { id } = ctx.params; + const context: ChatApiContext = { + params: ctx.params, + headers: ctx.headers, + }; + + try { + // Get userId if configured + const userId = await resolveUserId(context, () => { + throw ctx.error(403, { + message: "Unauthorized: User authentication required", + }); + }); + + // Check ownership before delete + const existing = await adapter.findMany({ + model: "conversation", + where: [{ field: "id", value: id, operator: "eq" }], + limit: 1, + }); + + if (!existing.length) { + throw ctx.error(404, { message: "Conversation not found" }); + } + + const conversation = existing[0]!; + if ( + userId && + conversation.userId && + conversation.userId !== userId + ) { + throw ctx.error(403, { + message: "Unauthorized: Cannot delete this conversation", + }); + } + + // Authorization hook + if (config.hooks?.onBeforeDeleteConversation) { + const canDelete = await config.hooks.onBeforeDeleteConversation( + id, + context, + ); + if (!canDelete) { + throw ctx.error(403, { + message: "Unauthorized: Cannot delete conversation", + }); + } + } + + // Messages are automatically deleted via cascade (onDelete: "cascade") + await adapter.delete({ + model: "conversation", + where: [{ field: "id", value: id }], + }); + + // Lifecycle hook + if (config.hooks?.onConversationDeleted) { + await config.hooks.onConversationDeleted(id, context); + } + + return { success: true }; + } catch (error) { + if (config.hooks?.onDeleteConversationError) { + await config.hooks.onDeleteConversationError( + error as Error, + context, + ); + } + throw error; + } + }, + ); + + return { + chat, + createConversation, + listConversations, + getConversation, + updateConversation, + deleteConversation, + }; + }, + }); + +export type AiChatApiRouter = ReturnType< + ReturnType["routes"] +>; diff --git a/packages/better-stack/src/plugins/ai-chat/client.css b/packages/better-stack/src/plugins/ai-chat/client.css new file mode 100644 index 0000000..e936d9c --- /dev/null +++ b/packages/better-stack/src/plugins/ai-chat/client.css @@ -0,0 +1,6 @@ +/* AI Chat Plugin Client Styles */ +/* NOTE: + * Markdown + syntax highlighting styles are imported from code (TS/TSX), + * matching the blog plugin approach. This avoids brittle nested @import + * resolution when consumers import `@btst/stack/plugins/ai-chat/css`. + */ diff --git a/packages/better-stack/src/plugins/ai-chat/client/components/chat-input.tsx b/packages/better-stack/src/plugins/ai-chat/client/components/chat-input.tsx new file mode 100644 index 0000000..093b1ab --- /dev/null +++ b/packages/better-stack/src/plugins/ai-chat/client/components/chat-input.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useRef, useState, useMemo } from "react"; +import { Button } from "@workspace/ui/components/button"; +import { Textarea } from "@workspace/ui/components/textarea"; +import { Send, Paperclip, X, Loader2, FileText } from "lucide-react"; +import { cn } from "@workspace/ui/lib/utils"; +import { toast } from "sonner"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { AiChatPluginOverrides } from "../overrides"; +import { DEFAULT_ALLOWED_FILE_TYPES, FILE_TYPE_MIME_MAP } from "../overrides"; +import { AI_CHAT_LOCALIZATION } from "../localization"; +import type { FormEvent } from "react"; + +/** Represents an attached file with metadata */ +export interface AttachedFile { + /** Data URL or uploaded URL */ + url: string; + /** MIME type of the file */ + mediaType: string; + /** Original filename */ + filename: string; +} + +// Max file size: 10MB +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +interface ChatInputProps { + input?: string; + handleInputChange: (e: React.ChangeEvent) => void; + handleSubmit: (e: FormEvent, files?: AttachedFile[]) => void; + isLoading: boolean; + placeholder?: string; + variant?: "default" | "compact"; + /** Callback when files are attached (for controlled mode) */ + onFilesAttached?: (files: AttachedFile[]) => void; + /** Attached files (for controlled mode) */ + attachedFiles?: AttachedFile[]; +} + +export function ChatInput({ + input = "", + handleInputChange, + handleSubmit, + isLoading, + placeholder, + variant = "default", + onFilesAttached, + attachedFiles: controlledFiles, +}: ChatInputProps) { + const { + uploadFile, + localization: customLocalization, + mode, + allowedFileTypes, + } = usePluginOverrides>( + "ai-chat", + {}, + ); + + const localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization }; + + const fileInputRef = useRef(null); + const [internalFiles, setInternalFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + + // Use controlled files if provided, otherwise use internal state + const attachedFiles = controlledFiles ?? internalFiles; + const isControlled = controlledFiles !== undefined; + + // Helper to add a file, using functional update for uncontrolled mode + const addFile = (file: AttachedFile) => { + if (isControlled && onFilesAttached) { + // In controlled mode, parent manages state - pass current + new + onFilesAttached([...attachedFiles, file]); + } else { + // In uncontrolled mode, use functional update to avoid stale closure + setInternalFiles((prev) => [...prev, file]); + } + }; + + // Helper to remove a file by index - uses functional update to avoid stale closure + const removeFileAtIndex = (index: number) => { + if (isControlled && onFilesAttached) { + // For controlled mode, we need to compute the new array based on current state + // The parent should ideally use functional updates too, but we pass the filtered array + // based on the latest controlledFiles prop. If rapid clicks occur before re-render, + // this could still be stale - parent component should handle batched updates appropriately. + onFilesAttached(attachedFiles.filter((_, i) => i !== index)); + } else { + // Uncontrolled mode: use functional update to ensure we always filter from latest state + setInternalFiles((prev) => prev.filter((_, i) => i !== index)); + } + }; + + const isCompact = variant === "compact"; + const isPublicMode = mode === "public"; + + // Get effective allowed file types (default to all if not specified) + const effectiveAllowedTypes = allowedFileTypes ?? DEFAULT_ALLOWED_FILE_TYPES; + + // Compute the accept string for the file input + const acceptString = useMemo(() => { + if (effectiveAllowedTypes.length === 0) return ""; + const mimeTypes = effectiveAllowedTypes.flatMap( + (type) => FILE_TYPE_MIME_MAP[type] || [], + ); + return mimeTypes.join(","); + }, [effectiveAllowedTypes]); + + // File uploads are disabled in public mode or if no file types are allowed + const canUploadFiles = + !isPublicMode && + typeof uploadFile === "function" && + effectiveAllowedTypes.length > 0; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + // Pass files to parent and let parent clear them after processing + handleSubmit( + e as unknown as FormEvent, + attachedFiles.length > 0 ? attachedFiles : undefined, + ); + // Clear internal files if not controlled + if (!controlledFiles) { + setInternalFiles([]); + } + } + }; + + const handleFileUpload = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (file.size > MAX_FILE_SIZE) { + toast.error(localization.FILE_UPLOAD_ERROR_TOO_LARGE); + return; + } + + // Use uploadFile if available, otherwise create data URL + if (uploadFile) { + try { + setIsUploading(true); + const url = await uploadFile(file); + addFile({ url, mediaType: file.type, filename: file.name }); + toast.success(localization.FILE_UPLOAD_SUCCESS); + } catch (error) { + console.error("Failed to upload file:", error); + toast.error(localization.FILE_UPLOAD_FAILURE); + } finally { + setIsUploading(false); + } + } else { + // Fallback: create data URL + setIsUploading(true); + const reader = new FileReader(); + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + addFile({ url: dataUrl, mediaType: file.type, filename: file.name }); + setIsUploading(false); + }; + reader.onerror = () => { + toast.error(localization.FILE_UPLOAD_FAILURE); + setIsUploading(false); + }; + reader.readAsDataURL(file); + } + + // Reset the input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const removeFile = (index: number) => { + removeFileAtIndex(index); + }; + + const handleFormSubmit = (e: FormEvent) => { + // Pass files to parent and let parent clear them after processing + handleSubmit(e, attachedFiles.length > 0 ? attachedFiles : undefined); + // Clear internal files if not controlled + if (!controlledFiles) { + setInternalFiles([]); + } + }; + + // Check if a file is an image based on media type + const isImageFile = (mediaType: string) => mediaType.startsWith("image/"); + + return ( +
+ {/* Attached Files Preview */} + {attachedFiles.length > 0 && ( +
+ {attachedFiles.map((file, index) => ( +
+ {isImageFile(file.mediaType) ? ( + // Image preview + {file.filename} + ) : ( + // File preview with icon +
+ + + {file.filename} + +
+ )} + +
+ ))} +
+ )} + + {/* Input Area */} +
+ {/* File Upload Button - hidden in public mode */} + {canUploadFiles && ( + <> + + + + )} + + {/* Text Input */} +
+