From 4ef30f0be3d6dca5e166a6a8a6ff9995110e974a Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 21 Nov 2025 12:24:50 -0500 Subject: [PATCH 1/3] feat: ai chatbot plugin --- .github/workflows/e2e.yml | 2 + docs/content/docs/plugins/ai-chat.mdx | 201 + docs/content/docs/plugins/index.mdx | 6 + e2e/playwright.config.ts | 5 +- e2e/tests/smoke.chat.spec.ts | 29 + examples/nextjs/app/page.tsx | 3 + examples/nextjs/lib/better-stack-client.tsx | 7 +- examples/nextjs/lib/better-stack.ts | 7 +- examples/nextjs/package.json | 3 + .../app/lib/better-stack-client.tsx | 7 +- examples/react-router/app/lib/better-stack.ts | 7 +- examples/react-router/package.json | 6 +- examples/tanstack/package.json | 6 +- .../tanstack/src/lib/better-stack-client.tsx | 7 +- examples/tanstack/src/lib/better-stack.ts | 7 +- packages/better-stack/build.config.ts | 3 + packages/better-stack/package.json | 32 +- .../src/plugins/ai-chat/api/index.ts | 1 + .../src/plugins/ai-chat/api/plugin.ts | 282 ++ .../ai-chat/client/components/chat-input.tsx | 48 + .../client/components/chat-interface.tsx | 127 + .../client/components/chat-message.tsx | 56 + .../src/plugins/ai-chat/client/index.ts | 2 + .../src/plugins/ai-chat/client/plugin.tsx | 82 + .../better-stack/src/plugins/ai-chat/db.ts | 56 + .../src/plugins/ai-chat/schemas.ts | 36 + .../better-stack/src/plugins/ai-chat/types.ts | 24 + pnpm-lock.yaml | 3551 ++++++++--------- 28 files changed, 2786 insertions(+), 1817 deletions(-) create mode 100644 docs/content/docs/plugins/ai-chat.mdx create mode 100644 e2e/tests/smoke.chat.spec.ts create mode 100644 packages/better-stack/src/plugins/ai-chat/api/index.ts create mode 100644 packages/better-stack/src/plugins/ai-chat/api/plugin.ts create mode 100644 packages/better-stack/src/plugins/ai-chat/client/components/chat-input.tsx create mode 100644 packages/better-stack/src/plugins/ai-chat/client/components/chat-interface.tsx create mode 100644 packages/better-stack/src/plugins/ai-chat/client/components/chat-message.tsx create mode 100644 packages/better-stack/src/plugins/ai-chat/client/index.ts create mode 100644 packages/better-stack/src/plugins/ai-chat/client/plugin.tsx create mode 100644 packages/better-stack/src/plugins/ai-chat/db.ts create mode 100644 packages/better-stack/src/plugins/ai-chat/schemas.ts create mode 100644 packages/better-stack/src/plugins/ai-chat/types.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d3d9a33..9d41b22 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 diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx new file mode 100644 index 0000000..c407a30 --- /dev/null +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -0,0 +1,201 @@ +--- +title: AI Chat Plugin +description: Add AI-powered chat functionality with conversation history, streaming, and customizable models +--- + +import { Tabs, Tab } from "fumadocs-ui/components/tabs"; +import { Callout } from "fumadocs-ui/components/callout"; + +## Installation + +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 + hooks: { + onBeforeChat: async (messages, context) => { + // Optional: Add authorization logic + return true + }, + } + }) + }, + adapter: (db) => createMemoryAdapter(db)({}) +}) + +export { handler, dbSchema } +``` + +The `aiChatBackendPlugin()` requires a `model` parameter (from AI SDK) and 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" + +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) => { + const baseURL = getBaseURL() + return createStackClient({ + plugins: { + aiChat: aiChatClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + }) + } + }) +} +``` + +**Required configuration:** +- `apiBaseURL`: Base URL for API calls +- `apiBasePath`: Path where your API is mounted (e.g., `/api/data`) + +### 3. 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 +``` + +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). + +## Usage + +The AI Chat plugin provides two routes: + +- `/chat` - Start a new conversation +- `/chat/:id` - Resume an existing conversation + +The plugin automatically handles: +- Creating and managing conversations +- Saving messages to the database +- Streaming AI responses in real-time +- Conversation history persistence + +## Customization + +### Backend Hooks + +Customize backend behavior with optional hooks: + + + +**Example usage:** + +```ts title="lib/better-stack.ts" +import { aiChatBackendPlugin, type AiChatBackendHooks } from "@btst/stack/plugins/ai-chat/api" + +const chatHooks: AiChatBackendHooks = { + onBeforeChat: async (messages, context) => { + // Add authorization logic + const authHeader = context.headers?.get("authorization") + if (!authHeader) { + return false // Deny access + } + return true + }, + onAfterChat: async (conversationId, messages, context) => { + // Log conversation or trigger webhooks + console.log("Chat completed:", conversationId) + }, +} + +const { handler, dbSchema } = betterStack({ + plugins: { + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + hooks: chatHooks + }) + }, + // ... +}) +``` + +### Model Configuration + +You can configure different models and tools: + +```ts title="lib/better-stack.ts" +import { openai } from "@ai-sdk/openai" +import { anthropic } from "@ai-sdk/anthropic" + +// Use OpenAI +aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), +}) + +// Or use Anthropic +aiChat: aiChatBackendPlugin({ + model: anthropic("claude-3-5-sonnet-20241022"), +}) + +// With tools (if your model supports it) +aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + // Tools configuration would go here if supported +}) +``` + +## API Endpoints + +The plugin provides the following endpoints: + +- `POST /api/data/chat` - Send a message and receive streaming response +- `GET /api/data/conversations` - List all conversations +- `GET /api/data/conversations/:id` - Get a conversation with messages +- `POST /api/data/conversations` - Create a new conversation +- `DELETE /api/data/conversations/:id` - Delete a conversation + +## Client Components + +The plugin exports a `ChatInterface` component that you can use directly: + +```tsx +import { ChatInterface } from "@btst/stack/plugins/ai-chat/client" + +export default function ChatPage() { + return ( + + ) +} +``` + +## Features + +- **Streaming Responses**: Real-time streaming of AI responses using AI SDK v5 +- **Conversation History**: Automatic persistence of conversations and messages +- **Customizable Models**: Use any LanguageModel from the AI SDK +- **Authorization Hooks**: Add custom authentication and authorization logic +- **Type-Safe**: Full TypeScript support with proper types from AI SDK + diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx index e05b62e..1fcb716 100644 --- a/docs/content/docs/plugins/index.mdx +++ b/docs/content/docs/plugins/index.mdx @@ -16,6 +16,12 @@ Better Stack provides a collection of full-stack plugins that you can easily int icon={} description="Content management, editor, drafts, publishing, SEO, RSS feeds." /> + } + description="AI-powered chat with conversation history, streaming, and customizable models." + /> { + 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 + await expect(page.getByText("Hello, world!")).toBeVisible({ + timeout: 5000, + }); + + // 5. Verify AI response appears (using real OpenAI, so response content varies, but should exist) + // We wait for the AI message container - look for prose class in assistant messages + await expect(page.locator(".prose").nth(1)).toBeVisible({ timeout: 30000 }); + }); +}); 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/lib/better-stack-client.tsx b/examples/nextjs/lib/better-stack-client.tsx index 777caff..5a15299 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 @@ -84,7 +85,11 @@ export const getStackClient = ( ); }, } + }), + aiChat: aiChatClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", }) } }) -} \ No newline at end of file +} diff --git a/examples/nextjs/lib/better-stack.ts b/examples/nextjs/lib/better-stack.ts index 755e67d..4cac1d4 100644 --- a/examples/nextjs/lib/better-stack.ts +++ b/examples/nextjs/lib/better-stack.ts @@ -2,6 +2,8 @@ import { createMemoryAdapter } from "@btst/adapter-memory" 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" // Define blog hooks with proper types // NOTE: This is the main API at /api/data - kept auth-free for regular tests @@ -64,7 +66,10 @@ const { handler, dbSchema } = betterStack({ basePath: "/api/data", plugins: { todos: todosBackendPlugin, - blog: blogBackendPlugin(blogHooks) + blog: blogBackendPlugin(blogHooks), + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + }) }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 968912c..430a520 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -13,6 +13,9 @@ "dependencies": { "@btst/adapter-memory": "^1.0.2", "@btst/stack": "workspace:*", + "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", diff --git a/examples/react-router/app/lib/better-stack-client.tsx b/examples/react-router/app/lib/better-stack-client.tsx index 3ea2c46..f09acb9 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 @@ -71,7 +72,11 @@ export const getStackClient = (queryClient: QueryClient) => { ); } } + }), + aiChat: aiChatClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", }) } }) -} \ 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..af3a211 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,10 @@ const blogHooks: BlogBackendHooks = { const { handler, dbSchema } = betterStack({ basePath: "/api/data", plugins: { - blog: blogBackendPlugin(blogHooks) + blog: blogBackendPlugin(blogHooks), + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + }) }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/react-router/package.json b/examples/react-router/package.json index 66ce539..f63242e 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": "^1.0.2", "@btst/db": "^1.0.2", "@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 0d9e6d3..9db5e33 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": "^1.0.2", "@btst/db": "^1.0.2", "@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 c67feb2..6713f48 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 @@ -71,7 +72,11 @@ export const getStackClient = (queryClient: QueryClient) => { ); } } + }), + aiChat: aiChatClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", }) } }) -} \ 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..575b4dd 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,10 @@ const blogHooks: BlogBackendHooks = { const { handler, dbSchema } = betterStack({ basePath: "/api/data", plugins: { - blog: blogBackendPlugin(blogHooks) + blog: blogBackendPlugin(blogHooks), + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + }) }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/packages/better-stack/build.config.ts b/packages/better-stack/build.config.ts index b43b3a1..89ed832 100644 --- a/packages/better-stack/build.config.ts +++ b/packages/better-stack/build.config.ts @@ -70,6 +70,9 @@ 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", ], hooks: { "rollup:options"(_ctx, options) { diff --git a/packages/better-stack/package.json b/packages/better-stack/package.json index f9d9976..16ca88c 100644 --- a/packages/better-stack/package.json +++ b/packages/better-stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "1.1.7", + "version": "1.2.0", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", @@ -123,6 +123,26 @@ "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/blog/css": "./dist/plugins/blog/style.css", "./dist/*": "./dist/*", "./ui/css": "./dist/ui/components.css", @@ -153,6 +173,12 @@ ], "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" ] } }, @@ -171,6 +197,8 @@ "@radix-ui/react-slot": ">=1.1.0", "@radix-ui/react-switch": ">=1.1.0", "@tanstack/react-query": "^5.0.0", + "ai": ">=5.0.0", + "@ai-sdk/react": ">=2.0.0", "better-call": ">=1.0.0", "class-variance-authority": ">=0.7.0", "clsx": ">=2.1.0", @@ -201,6 +229,8 @@ "@types/react": "^19.0.0", "@types/slug": "^5.0.9", "@workspace/ui": "workspace:*", + "ai": "^5.0.94", + "@ai-sdk/react": "^2.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..39b9a61 --- /dev/null +++ b/packages/better-stack/src/plugins/ai-chat/api/index.ts @@ -0,0 +1 @@ +export * from "./plugin"; 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..0c365a2 --- /dev/null +++ b/packages/better-stack/src/plugins/ai-chat/api/plugin.ts @@ -0,0 +1,282 @@ +import type { Adapter } from "@btst/db"; +import { defineBackendPlugin } from "@btst/stack/plugins/api"; +import { createEndpoint } from "@btst/stack/plugins/api"; +import { + streamText, + convertToModelMessages, + type LanguageModel, + type UIMessage, +} from "ai"; +import { aiChatSchema as dbSchema } from "../db"; +import { chatRequestSchema, createConversationSchema } from "../schemas"; +import type { Conversation, Message } from "../types"; + +export interface ChatApiContext { + body?: any; + params?: any; + query?: any; + request?: Request; + headers?: Headers; + [key: string]: any; +} + +export interface AiChatBackendHooks { + onBeforeChat?: ( + messages: any[], + context: ChatApiContext, + ) => Promise | boolean; + onAfterChat?: ( + conversationId: string, + messages: Message[], + context: ChatApiContext, + ) => Promise | void; +} + +export interface AiChatBackendConfig { + model: LanguageModel; + hooks?: AiChatBackendHooks; +} + +export const aiChatBackendPlugin = (config: AiChatBackendConfig) => + defineBackendPlugin({ + name: "ai-chat", + dbPlugin: dbSchema, + routes: (adapter: Adapter) => { + const chat = createEndpoint( + "/chat", + { + method: "POST", + body: chatRequestSchema, + }, + async (ctx) => { + const { messages: rawMessages, conversationId } = ctx.body; + + // Convert UIMessages to the format expected by the schema + // The messages come as UIMessage[] with parts, we need to handle them properly + const uiMessages = rawMessages as UIMessage[]; + + // Extract content for database operations (get first text part) + const getMessageContent = (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 ""; + }; + + const context: ChatApiContext = { + body: ctx.body, + headers: ctx.headers, + request: ctx.request, + }; + + if (config.hooks?.onBeforeChat) { + // Convert to content format for hooks + const messagesForHook = uiMessages.map((msg) => ({ + role: msg.role, + content: getMessageContent(msg), + })); + const canChat = await config.hooks.onBeforeChat( + messagesForHook, + context, + ); + if (!canChat) { + throw ctx.error(403, { + message: "Unauthorized: Cannot start chat", + }); + } + } + + let convId = conversationId; + const firstMessage = uiMessages[0]; + if (!firstMessage) { + throw ctx.error(400, { + message: "At least one message is required", + }); + } + const firstMessageContent = getMessageContent(firstMessage); + if (!convId) { + const newConv = await adapter.create({ + model: "conversation", + data: { + title: firstMessageContent.slice(0, 50) || "New Conversation", + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + convId = newConv.id; + } else { + // Verify conversation exists + const existing = await adapter.findMany({ + model: "conversation", + where: [{ field: "id", value: convId, operator: "eq" }], + limit: 1, + }); + if (!existing.length) { + // Cast to any to allow passing ID if the adapter supports it + const newConv = await adapter.create({ + model: "conversation", + data: { + id: convId, + title: firstMessageContent.slice(0, 50) || "New Conversation", + createdAt: new Date(), + updatedAt: new Date(), + } as Conversation, + }); + convId = newConv.id; + } + } + + // Save user message + const lastMessage = uiMessages[uiMessages.length - 1]; + if (lastMessage && lastMessage.role === "user") { + await adapter.create({ + model: "message", + data: { + conversationId: convId as string, + role: "user", + content: getMessageContent(lastMessage), + createdAt: new Date(), + }, + }); + } + + // Convert UIMessages to CoreMessages for streamText + // See: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot + const modelMessages = convertToModelMessages(uiMessages); + + const result = streamText({ + model: config.model, + messages: modelMessages, + onFinish: async (completion: { text: string }) => { + // Save assistant message + await adapter.create({ + model: "message", + data: { + conversationId: convId as string, + role: "assistant", + content: completion.text, + createdAt: new Date(), + }, + }); + + // Update conversation timestamp + await adapter.update({ + model: "conversation", + where: [{ field: "id", value: convId as string }], + update: { updatedAt: new Date() }, + }); + }, + }); + + // Return the stream response directly + // Note: originalMessages prevents duplicate messages in the stream + return result.toUIMessageStreamResponse({ + originalMessages: uiMessages, + }); + }, + ); + + const createConversation = createEndpoint( + "/conversations", + { + method: "POST", + body: createConversationSchema, + }, + async (ctx) => { + const { id, title } = ctx.body; + const newConv = await adapter.create({ + model: "conversation", + data: { + ...(id ? { id } : {}), + title: title || "New Conversation", + createdAt: new Date(), + updatedAt: new Date(), + } as Conversation, + }); + return newConv; + }, + ); + + const listConversations = createEndpoint( + "/conversations", + { + method: "GET", + }, + async () => { + const conversations = await adapter.findMany({ + model: "conversation", + sortBy: { field: "updatedAt", direction: "desc" }, + }); + return conversations; + }, + ); + + const getConversation = createEndpoint( + "/conversations/:id", + { + method: "GET", + }, + async (ctx) => { + const { id } = ctx.params; + const conversations = await adapter.findMany({ + model: "conversation", + where: [{ field: "id", value: id, operator: "eq" }], + limit: 1, + }); + + if (!conversations.length) { + throw ctx.error(404, { message: "Conversation not found" }); + } + + const messages = await adapter.findMany({ + model: "message", + where: [{ field: "conversationId", value: id, operator: "eq" }], + sortBy: { field: "createdAt", direction: "asc" }, + }); + + return { + ...conversations[0], + messages, + }; + }, + ); + + const deleteConversation = createEndpoint( + "/conversations/:id", + { + method: "DELETE", + }, + async (ctx) => { + const { id } = ctx.params; + + await adapter.transaction(async (tx) => { + await tx.delete({ + model: "message", + where: [{ field: "conversationId", value: id }], + }); + await tx.delete({ + model: "conversation", + where: [{ field: "id", value: id }], + }); + }); + + return { success: true }; + }, + ); + + return { + chat, + createConversation, + listConversations, + getConversation, + deleteConversation, + }; + }, + }); + +export type AiChatApiRouter = ReturnType< + ReturnType["routes"] +>; 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..df22b49 --- /dev/null +++ b/packages/better-stack/src/plugins/ai-chat/client/components/chat-input.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Button } from "@workspace/ui/components/button"; +import { Textarea } from "@workspace/ui/components/textarea"; +import { Send } from "lucide-react"; +import type { FormEvent } from "react"; + +interface ChatInputProps { + input?: string; + handleInputChange: (e: React.ChangeEvent) => void; + handleSubmit: (e: FormEvent) => void; + isLoading: boolean; +} + +export function ChatInput({ + input = "", + handleInputChange, + handleSubmit, + isLoading, +}: ChatInputProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e as any); + } + }; + + return ( +
+