diff --git a/AGENTS.md b/AGENTS.md index 7ad38f5e..01c39361 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,32 @@ const item = await myStack.api["{name}"].getItemById("abc") - Re-export getters from `api/index.ts` for consumers who need direct import (SSG/build-time) - Authorization hooks are **not** called via `stack().api.*` — callers are responsible for access control +### Server-side Mutations (`mutations.ts`) + +Plugins expose write operations in a separate `api/mutations.ts` file — distinct from the read-only `getters.ts`. Both are re-exported from `api/index.ts` and wired into the `api` factory. + +**Rules:** +- Keep mutations in `mutations.ts` — no authorization hooks, no HTTP context +- Document clearly in JSDoc: "Authorization hooks are NOT called" +- Re-export from `api/index.ts` alongside getters +- Common use case: AI tool `execute` callbacks, cron jobs, admin scripts + +**Adapter capture pattern for AI tool `execute` functions:** +```typescript +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _adapter: any + +const myTool = tool({ + execute: async (params) => { + await createKanbanTask(_adapter, { title: params.title, columnId: "col-id" }) + return { success: true } + } +}) + +export const myStack = stack({ plugins: { ..., aiChat: aiChatBackendPlugin({ tools: { myTool } }) } }) +_adapter = myStack.adapter // set after stack() returns, before any HTTP requests +``` + ### SSG Support (`prefetchForRoute`) `route.loader()` makes HTTP requests that **silently fail at `next build`** (no server running). Plugins that support SSG expose `prefetchForRoute` on the `api` factory to seed the React Query cache directly from the DB instead. @@ -426,7 +452,7 @@ export function MyPageComponent({ id }: { id: string }) { **How it works:** - `ComposedRoute` renders nested `` + `` around `PageComponent` -- Loading fallbacks only render on client (`typeof window !== "undefined"`) to avoid hydration mismatch +- Loading fallbacks are always provided to `` on both server and client — never guard them with `typeof window !== "undefined"`, as that creates a different JSX tree on each side and shifts React's `useId()` counter, causing hydration mismatches in descendants (Radix `Select`, `Dialog`, etc.). Since Suspense only emits fallback HTML when the boundary actually suspends during SSR, having a consistent fallback prop is safe. - `resetKeys={[path]}` resets the error boundary on navigation ### Suspense Hooks & Error Throwing @@ -657,6 +683,46 @@ cd docs && pnpm build The `AutoTypeTable` component automatically pulls from TypeScript files, so ensure your types have JSDoc comments for good documentation. +## AI Chat Plugin Integration + +Plugin pages can register AI context so the chat widget understands the current page and can act on it (fill forms, update editors, summarize content). + +**In the `.internal.tsx` page component**, call `useRegisterPageAIContext`: + +```tsx +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; + +// Read-only (content pages — summarization, suggestions only) +useRegisterPageAIContext(item ? { + routeName: "my-plugin-detail", + pageDescription: `Viewing: "${item.title}"\n\n${item.content?.slice(0, 16000)}`, + suggestions: ["Summarize this", "What are the key points?"], +} : null); // pass null while loading + +// With client-side tools (form/editor pages) +const formRef = useRef | null>(null); +useRegisterPageAIContext({ + routeName: "my-plugin-edit", + pageDescription: "User is editing…", + suggestions: ["Fill in the form for me"], + clientTools: { + fillMyForm: async ({ title }) => { + if (!formRef.current) return { success: false, message: "Form not ready" }; + formRef.current.setValue("title", title, { shouldValidate: true }); + return { success: true }; + }, + }, +}); +``` + +**For first-party tools**, add the server-side schema to `BUILT_IN_PAGE_TOOL_SCHEMAS` in `src/plugins/ai-chat/api/page-tools.ts` (no `execute` — handled client-side). Built-ins (`fillBlogForm`, `updatePageLayers`) are already registered there. + +**`PageAIContextProvider` must wrap the root layout** (above all `StackProvider` instances) in all three example apps — it is already wired up there. + +**References:** blog `new/edit-post-page.internal.tsx` (`fillBlogForm`), blog `post-page.internal.tsx` (read-only), ui-builder `page-builder-page.internal.tsx` (`updatePageLayers`). + +--- + ## 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. @@ -690,3 +756,7 @@ The `AutoTypeTable` component automatically pulls from TypeScript files, so ensu 15. **Wrong data shape for infinite queries** - Lists backed by `useInfiniteQuery` need `{ pages: [...], pageParams: [...] }` in `setQueryData`. Flat arrays will break hydration. 16. **Dates not serialized before `setQueryData`** - DB getters return `Date` objects; the HTTP cache holds ISO strings. Always serialize (e.g. `serializePost`) before `setQueryData`. + +17. **Putting write operations in `getters.ts`** - Write functions (create, update, delete) belong in `mutations.ts`, not `getters.ts`. This keeps the naming convention clear and signals to callers that no authorization hooks are invoked. + +18. **Tool `execute` adapter reference not set** - If a tool's `execute` function uses `_adapter` captured from `myStack.adapter`, make sure the assignment `_adapter = myStack.adapter` runs unconditionally at module level (not inside an `if` branch). In Next.js dev mode with hot reload, the global singleton pattern (`globalForStack.__btst_stack__ ??= createStack()`) means `createStack()` only runs once — ensure the assignment happens inside `createStack()` before returning, not outside. diff --git a/docs/content/docs/plugins/ai-chat.mdx b/docs/content/docs/plugins/ai-chat.mdx index 2c9cbc88..fa630ef1 100644 --- a/docs/content/docs/plugins/ai-chat.mdx +++ b/docs/content/docs/plugins/ai-chat.mdx @@ -564,6 +564,92 @@ import { ChatLayout, type ChatLayoutProps, type UIMessage } from "@btst/stack/pl +#### Widget layout — built-in trigger (default) + +The default widget mode manages its own open/close state and renders a floating trigger button. Drop it anywhere in your layout and it just works: + +```tsx + +``` + +#### Widget layout — externally controlled (no trigger) + +Use `defaultOpen` and `showTrigger={false}` when your own UI handles opening and closing — for example, a Next.js [intercepting route](https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes) modal or a custom dialog. The chat panel is immediately visible and the built-in trigger button is not rendered: + +```tsx +{/* Rendered inside a modal/dialog that you control */} + +``` + +**Next.js parallel-routes + intercepting-routes pattern** — a common way to display the widget as a modal overlay while keeping a floating button on every page: + +``` +app/ + @chatWidget/ + default.tsx ← floating button (Link to /chat) + loading.tsx ← loading overlay + (.)chat/ + page.tsx ← intercepting route: renders modal with ChatLayout + chat/ + page.tsx ← full-page fallback (hard nav / refresh) + layout.tsx ← passes chatWidget slot into the body +``` + +```tsx title="app/@chatWidget/default.tsx" +"use client"; +import Link from "next/link"; +import { BotIcon } from "lucide-react"; + +export default function ChatWidgetButton() { + return ( + + + + ); +} +``` + +```tsx title="app/@chatWidget/(.)chat/page.tsx" +"use client"; +import { useRouter } from "next/navigation"; +import { StackProvider } from "@btst/stack/context"; +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client"; + +export default function ChatModal() { + const router = useRouter(); + return ( + {/* Backdrop */} +
router.back()}> + {/* Modal card */} +
e.stopPropagation()}> + + {/* Panel is pre-opened; no trigger button rendered */} + + +
+
+ ); +} +``` + **Example usage with localStorage persistence:** ```tsx @@ -969,3 +1055,152 @@ const conv = await getConversationById(myAdapter, conversationId); |---|---| | `getAllConversations(adapter, userId?)` | Returns all conversations, optionally filtered by userId | | `getConversationById(adapter, id)` | Returns a conversation with messages, or `null` | + +## Route-Aware AI Context + +The AI chat plugin supports **route-aware context** — pages register contextual data and client-side tool handlers that the chat widget reads automatically. This enables: + +- The AI to summarize content from the current page +- The AI to fill in forms or update editors on the user's behalf +- Dynamic suggestion chips that change based on which page is open + +### Setup + +**Step 1 — Add `PageAIContextProvider` to your root layout** (above all `StackProvider` instances): + +```tsx title="app/layout.tsx" +import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context" + +export default function RootLayout({ children }) { + return ( + + + + {/* Everything else, including StackProvider and your chat modal */} + {children} + + + + ) +} +``` + + +Place `PageAIContextProvider` above any `StackProvider` so it spans both the main app tree and any chat modals rendered as Next.js parallel/intercept routes. Both trees need to be descendants of the same context instance for context to flow between them. + + +**Step 2 — Enable page tools in your backend config**: + +```ts title="lib/stack.ts" +aiChatBackendPlugin({ + model: openai("gpt-4o"), + enablePageTools: true, // activates built-in fillBlogForm, updatePageLayers tools +}) +``` + +### Registering Page Context + +Call `useRegisterPageAIContext` in any page component to publish context to the chat. The registration is cleaned up automatically when the component unmounts. + +```tsx +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context" + +// Blog post page — provides content for summarization +function BlogPostPage({ post }) { + useRegisterPageAIContext(post ? { + routeName: "blog-post", + pageDescription: `Blog post: "${post.title}"\n\n${post.content.slice(0, 16000)}`, + suggestions: ["Summarize this post", "What are the key takeaways?"], + } : null) + + // ... +} +``` + +Pass `null` to conditionally disable context (e.g. while data is loading). + +### Client-Side Tools + +Pages can expose **client-side tool handlers** — functions the AI can call to mutate page state. Built-in tools (`fillBlogForm`, `updatePageLayers`) are already wired up in the blog and UI builder plugins. For custom pages: + +**1. Register a tool handler on the page:** + +```tsx +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context" + +function ProductPage({ product, cart }) { + useRegisterPageAIContext({ + routeName: "product-detail", + pageDescription: `Product: ${product.name}. Price: $${product.price}.`, + suggestions: ["Tell me about this product", "Add to cart"], + clientTools: { + addToCart: async ({ quantity }) => { + cart.add(product.id, quantity) + return { success: true, message: `Added ${quantity} to cart` } + } + } + }) +} +``` + +**2. Register the tool schema server-side** (so the LLM knows the parameter shapes): + +```ts title="lib/stack.ts" +import { tool } from "ai" +import { z } from "zod" + +aiChatBackendPlugin({ + model: openai("gpt-4o"), + enablePageTools: true, + clientToolSchemas: { + addToCart: tool({ + description: "Add the current product to the shopping cart", + parameters: z.object({ quantity: z.number().int().min(1) }), + // No execute — this is handled client-side + }), + } +}) +``` + +When the AI calls `addToCart`, the return value from the client handler is sent back to the model as the tool result, allowing the conversation to continue. + +### Built-In Page Tools + +| Tool | Registered by | Description | +|---|---|---| +| `fillBlogForm` | Blog new/edit pages | Fills title, content, excerpt, and tags in the post editor | +| `updatePageLayers` | UI builder edit page | Replaces the component layer tree in the page builder | + +### API Reference + +#### `PageAIContextProvider` + +```tsx +import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context" + + + {children} + +``` + +#### `useRegisterPageAIContext(config)` + +```ts +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context" + +useRegisterPageAIContext({ + routeName: string, // shown as badge in chat header + pageDescription: string, // injected into system prompt (max 8,000 chars) + suggestions?: string[], // quick-action chips in chat empty state + clientTools?: { // handlers the AI can invoke + [toolName: string]: (args: any) => Promise<{ success: boolean; message?: string }> + } +}) +``` + +#### `AiChatBackendConfig` — new options + +| Option | Type | Default | Description | +|---|---|---|---| +| `enablePageTools` | `boolean` | `false` | Activate page tool support | +| `clientToolSchemas` | `Record` | — | Custom tool schemas for non-BTST pages | diff --git a/docs/content/docs/plugins/cms.mdx b/docs/content/docs/plugins/cms.mdx index 31083a43..c86a9281 100644 --- a/docs/content/docs/plugins/cms.mdx +++ b/docs/content/docs/plugins/cms.mdx @@ -1288,6 +1288,45 @@ export async function generateStaticParams() { | `getAllContentItems(adapter, typeSlug, params?)` | Returns paginated items for a content type | | `getContentItemBySlug(adapter, typeSlug, slug)` | Returns a single item by slug, or `null` | +### Server-side mutation — `createContentItem` + +In addition to read-only getters, the CMS plugin exposes a **mutation function** for creating content items directly from server-side code. + + +**`createContentItem` bypasses authorization hooks and Zod schema validation.** Hooks such as `onBeforeCreate` and `onAfterCreate` are **not** called, and the data payload is stored as-is without running the content type's schema validation. The caller is responsible for providing valid, relation-free data and for any access-control checks. For relation fields or schema validation, use the HTTP endpoint instead. + + +**Via `myStack.api.cms`:** + +```ts +await myStack.api.cms.createContentItem("client-profile", { + slug: `intake-${Date.now()}`, + data: { + clientName: "Sarah Chen", + age: 34, + riskTolerance: "moderate", + recommendation: "Rebalance windfall 80% equity, 20% cash.", + amlFlag: false, + confidenceScore: 94, + }, +}) +``` + +**Direct import:** + +```ts +import { createCMSContentItem } from "@btst/stack/plugins/cms/api" + +await createCMSContentItem(myStack.adapter, "client-profile", { + slug: `intake-${Date.now()}`, + data: { clientName: "Sarah Chen", age: 34, amlFlag: false }, +}) +``` + +Throws if: +- The content type slug is not found (run `ensureSynced` first if calling outside a plugin request) +- A content item with the same slug already exists in that content type + ## Static Site Generation (SSG) `route.loader()` makes HTTP requests to `apiBaseURL`, which silently fails during `next build` because no dev server is running. Use `prefetchForRoute()` instead — it reads directly from the database and pre-populates the React Query cache before rendering. diff --git a/docs/content/docs/plugins/development.mdx b/docs/content/docs/plugins/development.mdx index c0e5a285..6faa6914 100644 --- a/docs/content/docs/plugins/development.mdx +++ b/docs/content/docs/plugins/development.mdx @@ -22,7 +22,8 @@ You can create plugins **inside your project** (like the [Todo example](#in-proj your-plugin/ ├── api/ │ ├── backend.ts # Backend plugin (defineBackendPlugin) -│ ├── getters.ts # Pure DB functions — no HTTP context +│ ├── getters.ts # Pure DB read functions — no HTTP context +│ ├── mutations.ts # Server-side write functions — no hooks, no HTTP context │ ├── query-key-defs.ts # Shared query key shapes (prevents SSG/SSR drift) │ └── serializers.ts # Convert Date fields to strings for the query cache ├── client/ @@ -388,6 +389,105 @@ export const todosBackendPlugin = defineBackendPlugin({ export { listTodos } from "./getters" ``` +### Server-side Mutations (`mutations.ts`) + +Plugins can also expose **write operations** that bypass the HTTP layer — useful inside AI tool `execute` callbacks, cron jobs, admin scripts, or any server-side code that needs to create or update records without going through an HTTP endpoint. + +Keep mutations in a **separate `api/mutations.ts`** file, distinct from the read-only `getters.ts`. Both are re-exported from `api/index.ts` and exposed on the `api` factory. + + +**Mutation functions bypass authorization hooks.** Plugin hooks such as `onBeforeCreateTask` are **not** called when you use these functions directly. The caller is responsible for any access-control checks before invoking mutations. Never call them from user-facing request handlers without adding your own authorization logic first. + + +```typescript +// api/mutations.ts — write operations, no hooks, no HTTP context +import type { Adapter } from "@btst/stack/plugins/api" +import type { Todo } from "../types" + +export interface CreateTodoInput { + title: string + description?: string +} + +/** + * Create a todo directly in the database. + * + * @remarks Authorization hooks are NOT called. The caller is responsible for + * access-control checks. + */ +export async function createTodo( + adapter: Adapter, + input: CreateTodoInput, +): Promise { + return adapter.create({ + model: "todo", + data: { + title: input.title, + description: input.description, + completed: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) +} +``` + +Wire mutations into the `api` factory alongside the read methods: + +```typescript +// api/backend.ts +import { listTodos } from "./getters" +import { createTodo, type CreateTodoInput } from "./mutations" + +export const todosBackendPlugin = defineBackendPlugin({ + name: "todos", + dbPlugin: dbSchema, + api: (adapter) => ({ + // Reads + listTodos: () => listTodos(adapter), + // Mutations + createTodo: (input: CreateTodoInput) => createTodo(adapter, input), + }), + routes: (adapter) => { /* HTTP endpoints */ }, +}) + +// api/index.ts — re-export both +export { listTodos } from "./getters" +export { createTodo, type CreateTodoInput } from "./mutations" +``` + +**Common use case — inside an AI tool `execute` function:** + +Because AI tool `execute` functions run at request time (after module initialization), the adapter can safely be captured via a module-level variable set immediately after `stack()` returns: + +```typescript title="lib/stack.ts" +import { createTodo } from "./plugins/todo/api/mutations" + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _adapter: any + +const myTool = tool({ + description: "Create a new task", + inputSchema: z.object({ title: z.string() }), + execute: async ({ title }) => { + await createTodo(_adapter, { title }) + return { success: true } + }, +}) + +export const myStack = stack({ + plugins: { + todos: todosBackendPlugin, + aiChat: aiChatBackendPlugin({ tools: { myTool } }), + }, + adapter: (db) => createMemoryAdapter(db)({}), +}) + +// Adapter is now available — execute() only fires during HTTP requests, +// which occur after module initialization is complete. +_adapter = myStack.adapter +``` + --- ## Client Plugin @@ -1202,6 +1302,139 @@ export function useCreateTodo() { --- +## AI Chat Plugin Integration + +Plugins can participate in the **route-aware AI context** system. When a user opens the chat widget while viewing one of your plugin's pages, it can automatically: + +- Inject a description of the current page into the AI's system prompt +- Expose action chips (quick suggestions) relevant to the page +- Provide **client-side tool handlers** the AI can call to mutate page state (fill forms, update editors, etc.) + +### Step 1 — Register context from the page component + +Call `useRegisterPageAIContext` inside your `.internal.tsx` page component. The registration is automatically cleaned up on unmount. + +```tsx +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context" +import { useRef, useCallback } from "react" +import type { UseFormReturn } from "react-hook-form" + +export function MyPluginEditPage() { + // Capture the form instance via an onFormReady callback from your form component + const formRef = useRef | null>(null) + const handleFormReady = useCallback((form: UseFormReturn) => { + formRef.current = form + }, []) + + useRegisterPageAIContext({ + // Short identifier shown as a badge in the chat widget header + routeName: "my-plugin-edit", + + // Injected into the AI system prompt (capped at 8,000 characters) + pageDescription: "User is editing a My Plugin item. When asked to fill in the form, call the fillMyPluginForm tool.", + + // Quick-action chips shown in the chat empty state (merged with static suggestions) + suggestions: ["Fill in the form for me", "Suggest a title"], + + // Handlers the AI can invoke — keyed by tool name + clientTools: { + fillMyPluginForm: async ({ title, description }) => { + const form = formRef.current + if (!form) return { success: false, message: "Form not ready" } + if (title !== undefined) form.setValue("title", title, { shouldValidate: true }) + if (description !== undefined) form.setValue("description", description) + return { success: true, message: "Form filled" } + }, + }, + }) + + return +} +``` + +Pass `null` to conditionally disable the context while data is loading: + +```tsx +useRegisterPageAIContext(item ? { + routeName: "my-plugin-detail", + pageDescription: `Viewing: "${item.title}"\n\n${item.content?.slice(0, 16000)}`, + suggestions: ["Summarize this", "What are the key points?"], +} : null) +``` + +### Step 2 — Register the tool schema server-side + +Client-side tool handlers need a matching server-side schema so the LLM knows what parameters to send. + +**For first-party BTST plugins**, add the schema to `BUILT_IN_PAGE_TOOL_SCHEMAS` in `src/plugins/ai-chat/api/page-tools.ts`: + +```ts +// packages/stack/src/plugins/ai-chat/api/page-tools.ts +import { tool } from "ai" +import { z } from "zod" + +export const BUILT_IN_PAGE_TOOL_SCHEMAS: Record = { + // ...existing built-in tools (fillBlogForm, updatePageLayers) + + fillMyPluginForm: tool({ + description: "Fill in the my-plugin form fields. Call this when the user asks to populate or draft the form.", + inputSchema: z.object({ + title: z.string().optional().describe("The item title"), + description: z.string().optional().describe("A short description"), + }), + // No execute — this is handled entirely client-side via onToolCall in ChatInterface + }), +} +``` + +**For consumer (third-party) plugins**, instruct users to pass `clientToolSchemas` in `aiChatBackendPlugin`: + +```ts +// Consumer's lib/stack.ts +aiChatBackendPlugin({ + model: openai("gpt-4o"), + enablePageTools: true, + clientToolSchemas: { + fillMyPluginForm: tool({ + description: "Fill in the my-plugin form fields", + parameters: z.object({ title: z.string().optional() }), + }), + }, +}) +``` + +### Step 3 — Ensure PageAIContextProvider is in the root layout + +The `PageAIContextProvider` must be present **above all `StackProvider` instances** in every example app's root layout. It is already wired up in the BTST example apps — you only need to ensure your plugin's pages call `useRegisterPageAIContext` correctly. + + +`useRegisterPageAIContext` silently no-ops when `PageAIContextProvider` is absent from the tree. If context doesn't appear in the chat widget, check that the provider wraps the root layout. + + +### Read-only context (no tools) + +If your page only displays content the AI should be able to read but not mutate, omit `clientTools`: + +```tsx +// Blog post detail page — AI can summarize but not write +useRegisterPageAIContext(post ? { + routeName: "blog-post", + pageDescription: `Blog post: "${post.title}"\n\n${post.content?.slice(0, 16000)}`, + suggestions: ["Summarize this post", "What are the key takeaways?"], +} : null) +``` + +### Reference implementations inside BTST + +| Plugin | File | Tools exposed | +|---|---|---| +| Blog (new post) | `blog/client/components/pages/new-post-page.internal.tsx` | `fillBlogForm` | +| Blog (edit post) | `blog/client/components/pages/edit-post-page.internal.tsx` | `fillBlogForm` | +| Blog (post detail) | `blog/client/components/pages/post-page.internal.tsx` | none (read-only) | +| UI Builder | `ui-builder/client/components/pages/page-builder-page.internal.tsx` | `updatePageLayers` | + +--- + ## Reference Implementations ### Simple Plugin: Todo diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx index 4b7b424d..a417f7aa 100644 --- a/docs/content/docs/plugins/kanban.mdx +++ b/docs/content/docs/plugins/kanban.mdx @@ -856,3 +856,96 @@ const kanbanHooks: KanbanBackendHooks = { ### Query key consistency `prefetchForRoute` uses the same query key shapes as `createKanbanQueryKeys` (the HTTP client). The shared constants live in `@btst/stack/plugins/kanban/api` as `KANBAN_QUERY_KEYS` and `boardsListDiscriminator`, so the two paths can never drift silently. + +## Server-side Mutations + +In addition to the read-only getters, the Kanban plugin exposes **mutation functions** for creating boards, columns, and tasks directly from server-side code — no HTTP roundtrip required. These live in `api/mutations.ts` and are re-exported from `@btst/stack/plugins/kanban/api`. + + +**Mutation functions bypass authorization hooks.** Hooks such as `onBeforeCreateBoard` and `onBeforeCreateTask` are **not** called when you use these functions. They are pure database writes — the caller is responsible for any access-control checks before invoking them. Never call mutations from user-facing request handlers without adding your own authorization logic first. + + +### Available mutations + +| Function | Description | +|---|---| +| `createKanbanTask(adapter, input)` | Create a task in a column; auto-computes insertion order | +| `findOrCreateKanbanBoard(adapter, slug, name, columns)` | Find a board by slug or create it with custom column titles | +| `getKanbanColumnsByBoardId(adapter, boardId)` | List columns for a board sorted by order | + +### `createKanbanTask` + +```ts +import { createKanbanTask } from "@btst/stack/plugins/kanban/api" + +await createKanbanTask(myStack.adapter, { + title: "Review Q1 financials", + columnId: "col-abc", + description: "See attached report", + priority: "HIGH", // "LOW" | "MEDIUM" | "HIGH" | "URGENT" +}) +``` + +### `findOrCreateKanbanBoard` + +Creates a board with custom column titles on first call; returns the existing board on subsequent calls. Safe to call concurrently — uses find-before-create. + +```ts +import { findOrCreateKanbanBoard, getKanbanColumnsByBoardId } from "@btst/stack/plugins/kanban/api" + +const board = await findOrCreateKanbanBoard( + myStack.adapter, + "advisor-review-queue", // slug (URL-safe, unique) + "Advisor Review Queue", // display name (used only on creation) + ["New Intakes", "Under Review", "Escalated"], // column titles (used only on creation) +) + +const columns = await getKanbanColumnsByBoardId(myStack.adapter, board.id) +const target = columns.find((c) => c.title === "New Intakes") +``` + +### Via `myStack.api.kanban` + +All three functions are also available pre-bound to the stack adapter: + +```ts +const board = await myStack.api.kanban.findOrCreateBoard( + "advisor-review-queue", + "Advisor Review Queue", + ["New Intakes", "Under Review", "Escalated"], +) +const columns = await myStack.api.kanban.getColumnsByBoardId(board.id) +await myStack.api.kanban.createTask({ + title: "Sarah Chen — Ready for Review", + columnId: columns[0]!.id, + priority: "MEDIUM", +}) +``` + +### Inside an AI tool `execute` function + +The primary use case for mutations is populating Kanban boards from server-side AI tool callbacks: + +```ts title="lib/stack.ts" +import { tool } from "ai" +import { z } from "zod" +import { createKanbanTask, findOrCreateKanbanBoard, getKanbanColumnsByBoardId } from "@btst/stack/plugins/kanban/api" + +// Adapter captured after stack() returns — safe for tool execute closures +let adapter: any + +const myTool = tool({ + description: "Create a review card", + inputSchema: z.object({ title: z.string(), urgent: z.boolean() }), + execute: async ({ title, urgent }) => { + const board = await findOrCreateKanbanBoard(adapter, "review", "Review Queue", ["To Do", "Urgent"]) + const columns = await getKanbanColumnsByBoardId(adapter, board.id) + const col = urgent ? columns.find((c) => c.title === "Urgent") : columns[0] + await createKanbanTask(adapter, { title, columnId: col!.id, priority: urgent ? "URGENT" : "MEDIUM" }) + return { success: true } + }, +}) + +export const myStack = stack({ plugins: { aiChat: aiChatBackendPlugin({ tools: { myTool } }), kanban: kanbanBackendPlugin() } }) +adapter = myStack.adapter +``` diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index f040d9f4..f9221597 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -99,6 +99,8 @@ export default defineConfig({ "**/*.ui-builder.spec.ts", "**/*.kanban.spec.ts", "**/*.ssg.spec.ts", + "**/*.page-context.spec.ts", + "**/*.wealthreview.spec.ts", ], }, { @@ -112,6 +114,7 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.page-context.spec.ts", ], }, { @@ -125,6 +128,7 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.page-context.spec.ts", ], }, ], diff --git a/e2e/tests/smoke.page-context.spec.ts b/e2e/tests/smoke.page-context.spec.ts new file mode 100644 index 00000000..32526a89 --- /dev/null +++ b/e2e/tests/smoke.page-context.spec.ts @@ -0,0 +1,264 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * Wait for the chat widget to finish streaming and return to "ready" state. + */ +async function waitForChatReady(page: Page, timeout = 30000) { + await expect( + page.locator('[data-testid="chat-interface"][data-chat-status="ready"]'), + ).toBeVisible({ timeout }); +} + +/** + * Open the floating chat widget and wait for it to be fully rendered. + * The widget starts closed — we click the trigger button to open it. + */ +async function waitForChatWidget(page: Page) { + // The outer container is always rendered + await expect(page.locator('[data-testid="chat-widget"]')).toBeVisible({ + timeout: 10000, + }); + // Click the trigger button to open the chat panel if it isn't open yet + const trigger = page.locator('[data-testid="widget-trigger"]'); + await expect(trigger).toBeVisible({ timeout: 10000 }); + await trigger.click(); + // Wait for the chat interface to appear + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible({ + timeout: 10000, + }); +} + +const hasOpenAiKey = + typeof process.env.OPENAI_API_KEY === "string" && + process.env.OPENAI_API_KEY.trim().length > 0; + +// ───────────────────────────────────────────────────────────────────────────── +// Structural tests — always run, no OpenAI key needed +// These verify that context is registered, shown in the UI, and transmitted +// to the server without requiring an actual AI response. +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Page AI Context — structural (no OpenAI key needed)", () => { + test("page context badge appears on blog post page", async ({ + page, + request, + }) => { + // Create a blog post via API so we have a real slug to navigate to + const res = await request.post("/api/data/posts", { + data: { + title: "Context Badge Test Post", + content: "Content for context badge test.", + excerpt: "Badge test excerpt", + slug: `context-badge-test-${Date.now()}`, + published: true, + tags: [], + }, + }); + expect(res.ok()).toBeTruthy(); + const post = await res.json(); + + await page.goto(`/pages/blog/${post.slug}`, { waitUntil: "networkidle" }); + + // Wait for the floating chat widget to render + await waitForChatWidget(page); + + // The page context badge should appear in the chat widget header + // since the blog post page calls useRegisterPageAIContext + await expect( + page.locator('[data-testid="page-context-badge"]'), + ).toBeVisible({ timeout: 5000 }); + + await expect( + page.locator('[data-testid="page-context-badge"]'), + ).toContainText("blog-post"); + }); + + test("dynamic suggestions appear on blog new post page", async ({ page }) => { + await page.goto("/pages/blog/new", { waitUntil: "networkidle" }); + + await waitForChatWidget(page); + + // The chat widget empty state should show page-specific suggestion chips + // registered by the new post page + const chatInterface = page.locator('[data-testid="chat-interface"]'); + + // At least one suggestion chip should match the new-post context + const suggestionChips = chatInterface.getByRole("button", { + name: /write a post|draft|tags/i, + }); + await expect(suggestionChips.first()).toBeVisible({ timeout: 5000 }); + }); + + test("pageContext and availableTools are sent in the chat API request", async ({ + page, + }) => { + await page.goto("/pages/blog/new", { waitUntil: "networkidle" }); + await waitForChatWidget(page); + + // Intercept the chat API call and capture the request body + let capturedBody: Record | null = null; + + await page.route("**/api/data/chat", async (route) => { + const postData = route.request().postDataJSON() as Record< + string, + any + > | null; + capturedBody = postData; + // Abort the request so we don't need a real AI response + await route.abort(); + }); + + // Type and submit any message to trigger the API call + // Use getByPlaceholder to find the chat input reliably (avoids overflow-hidden scoping issues) + const input = page.getByPlaceholder("Type a message...").last(); + await input.fill("hello"); + await page.keyboard.press("Enter"); + + // Wait a moment for the intercepted request to be processed + await page.waitForTimeout(1000); + + // Verify the request body contains page context fields + expect(capturedBody).not.toBeNull(); + expect(typeof capturedBody!.pageContext).toBe("string"); + expect((capturedBody!.pageContext as string).length).toBeGreaterThan(0); + expect(Array.isArray(capturedBody!.availableTools)).toBe(true); + expect(capturedBody!.availableTools as string[]).toContain("fillBlogForm"); + }); + + test("fillBlogForm tool call populates the form fields", async ({ page }) => { + await page.goto("/pages/blog/new", { waitUntil: "networkidle" }); + await waitForChatWidget(page); + + // Mock the chat API to return a deterministic fillBlogForm tool call. + // This tests the client-side tool dispatch mechanism without needing a real AI model. + let requestCount = 0; + await page.route("**/api/data/chat", async (route) => { + requestCount++; + + if (requestCount === 1) { + // First request: respond with a tool call stream for fillBlogForm + const toolInput = JSON.stringify({ + title: "TypeScript Benefits for Frontend", + content: + "# TypeScript Benefits\n\nTypeScript provides type safety and better tooling.", + excerpt: "How TypeScript improves frontend development.", + }); + const stream = [ + `data: {"type":"start","messageId":"mock-msg-1"}\n\n`, + `data: {"type":"start-step"}\n\n`, + `data: {"type":"tool-input-start","toolCallId":"mock-call-1","toolName":"fillBlogForm"}\n\n`, + `data: {"type":"tool-input-delta","toolCallId":"mock-call-1","inputTextDelta":${JSON.stringify(toolInput)}}\n\n`, + `data: {"type":"tool-input-available","toolCallId":"mock-call-1","toolName":"fillBlogForm","input":${toolInput}}\n\n`, + `data: {"type":"finish-step"}\n\n`, + `data: {"type":"finish"}\n\n`, + `data: [DONE]\n\n`, + ].join(""); + + await route.fulfill({ + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "x-vercel-ai-ui-message-stream": "v1", + }, + body: stream, + }); + } else { + // Subsequent requests (after tool result is sent back): simple text response + const stream = [ + `data: {"type":"start","messageId":"mock-msg-2"}\n\n`, + `data: {"type":"start-step"}\n\n`, + `data: {"type":"text-start","id":"text-1"}\n\n`, + `data: {"type":"text-delta","id":"text-1","delta":"I have filled in the blog post form."}\n\n`, + `data: {"type":"text-end","id":"text-1"}\n\n`, + `data: {"type":"finish-step"}\n\n`, + `data: {"type":"finish"}\n\n`, + `data: [DONE]\n\n`, + ].join(""); + + await route.fulfill({ + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "x-vercel-ai-ui-message-stream": "v1", + }, + body: stream, + }); + } + }); + + const input = page.getByPlaceholder("Type a message...").last(); + await input.fill("Write a blog post about TypeScript"); + await page.keyboard.press("Enter"); + + // Wait for user message to appear in chat + await expect( + page + .locator('[data-testid="chat-interface"]') + .getByText(/TypeScript/) + .first(), + ).toBeVisible({ timeout: 15000 }); + + // The fillBlogForm onToolCall handler should populate the title field + const titleField = page.getByLabel(/title/i).first(); + await expect(titleField).toHaveValue("TypeScript Benefits for Frontend", { + timeout: 30000, + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// AI-driven tests — require OPENAI_API_KEY, skipped without it +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Page AI Context — AI-driven (requires OpenAI key)", () => { + test.skip(!hasOpenAiKey, "OPENAI_API_KEY is required for AI-driven tests."); + + test("AI answers questions using the blog post page content", async ({ + page, + request, + }) => { + // Create a post with a unique phrase so we can verify the AI read the page context + const uniquePhrase = `ZephyrCloud2025-${Date.now()}`; + const res = await request.post("/api/data/posts", { + data: { + title: "AI Context Summarization Test", + content: `This post discusses ${uniquePhrase} as a key concept in cloud computing.`, + excerpt: "A test post for AI summarization", + slug: `ai-context-test-${Date.now()}`, + published: true, + tags: [], + }, + }); + expect(res.ok()).toBeTruthy(); + const post = await res.json(); + + await page.goto(`/pages/blog/${post.slug}`, { waitUntil: "networkidle" }); + await waitForChatWidget(page); + + const chatInterface = page.locator('[data-testid="chat-interface"]'); + const input = page.getByPlaceholder("Type a message...").last(); + + // Ask about the post content + await input.fill("What unique concept is mentioned in this post?"); + await page.keyboard.press("Enter"); + + // Wait for user message + await expect( + chatInterface.getByText("What unique concept is mentioned in this post?"), + ).toBeVisible({ timeout: 10000 }); + + // Wait for AI response + await expect( + chatInterface.locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 30000 }); + + await waitForChatReady(page, 60000); + + // The AI response should reference the unique phrase from the page context + await expect( + chatInterface.locator('[aria-label="AI response"]').first(), + ).toContainText(uniquePhrase, { timeout: 10000 }); + }); +}); diff --git a/e2e/tests/smoke.wealthreview.spec.ts b/e2e/tests/smoke.wealthreview.spec.ts new file mode 100644 index 00000000..65a835d1 --- /dev/null +++ b/e2e/tests/smoke.wealthreview.spec.ts @@ -0,0 +1,171 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * WealthReview AI Demo — End-to-End Smoke Tests + * + * Tests the AI-native financial intake workflow: + * 1. Client chats with the AI advisor + * 2. AI calls submitIntakeAssessment (server-side tool with execute) + * 3. A CMS client-profile entry is created + * 4. A Kanban card appears in the Advisor Review Queue board + * + * Requires OPENAI_API_KEY — skipped when the key is absent. + * Targets nextjs:memory project only (port 3003). + */ + +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 + console.warn( + "Skipping WealthReview smoke tests: OPENAI_API_KEY is not available in the environment.", + ); +} + +test.skip( + !hasOpenAiKey, + "OPENAI_API_KEY is required to run WealthReview smoke tests.", +); + +/** + * Wait for the chat interface to return to ready state after streaming. + */ +async function waitForChatReady(page: Page, timeout = 60000) { + await expect( + page.locator('[data-testid="chat-interface"][data-chat-status="ready"]'), + ).toBeVisible({ timeout }); +} + +test.describe("WealthReview AI Demo", () => { + test("should accept a routine intake message and create advisor review card", async ({ + page, + }) => { + await page.goto("/pages/chat"); + + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + await expect(page.getByPlaceholder("Type a message...")).toBeVisible(); + + // Send a routine client intake message (no AML signals) + const input = page.getByPlaceholder("Type a message..."); + await input.fill( + "Hi, I'm Sarah, 34 years old. I'm getting married next year and I just inherited $50,000 from my grandmother. I currently have no debt and about $30,000 in savings. I'd like to know if my current moderate-risk investments still make sense.", + ); + await page.keyboard.press("Enter"); + + // Verify message sent + await expect( + page + .locator('[data-testid="chat-interface"]') + .getByText("Sarah", { exact: false }), + ).toBeVisible({ timeout: 10000 }); + + // Wait for AI to complete the intake conversation and submit assessment + // The AI will ask follow-up questions and eventually call submitIntakeAssessment + await waitForChatReady(page, 90000); + + // Verify an AI response was received + await expect( + page + .locator('[data-testid="chat-interface"]') + .locator('[aria-label="AI response"]'), + ).toBeVisible({ timeout: 10000 }); + + // Navigate to the Kanban board to verify the review card was created + await page.goto("/pages/kanban"); + await expect( + page + .locator('[data-testid="kanban-board"]') + .or(page.getByText("Advisor Review Queue")), + ).toBeVisible({ timeout: 15000 }); + + // The advisor review board should have appeared + await expect(page.getByText("Advisor Review Queue")).toBeVisible({ + timeout: 15000, + }); + }); + + test("should route AML-flagged case to Escalated column", async ({ + page, + }) => { + await page.goto("/pages/chat"); + + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + + // Send a message with clear AML risk signals + const input = page.getByPlaceholder("Type a message..."); + await input.fill( + "Hi, I'm Alex. I run a small import business and I want to invest $200,000. The money came from overseas sales over the past 3 months from accounts in multiple countries. I'd like to move it all into Canadian equities immediately.", + ); + await page.keyboard.press("Enter"); + + await expect( + page + .locator('[data-testid="chat-interface"]') + .getByText("Alex", { exact: false }), + ).toBeVisible({ timeout: 10000 }); + + // Wait for the AI to complete the intake and call submitIntakeAssessment + await waitForChatReady(page, 90000); + + // The AI should mention escalation in its final response + const chatInterface = page.locator('[data-testid="chat-interface"]'); + await expect( + chatInterface.locator('[aria-label="AI response"]'), + ).toBeVisible({ + timeout: 10000, + }); + + // Navigate to Kanban and click into the Advisor Review Queue board + await page.goto("/pages/kanban"); + await expect(page.getByText("Advisor Review Queue")).toBeVisible({ + timeout: 15000, + }); + + // Click through to the board detail so we can see columns and cards + await page.getByText("Advisor Review Queue").click(); + + // The Escalated column header should be visible on the board detail page + await expect(page.getByText("Escalated").first()).toBeVisible({ + timeout: 15000, + }); + + // The AML-flagged card title should contain "ESCALATED" + await expect( + page.getByText("ESCALATED", { exact: false }).first(), + ).toBeVisible({ timeout: 10000 }); + }); + + test("should create a CMS client profile entry after intake", async ({ + page, + }) => { + await page.goto("/pages/chat"); + + await expect(page.locator('[data-testid="chat-interface"]')).toBeVisible(); + + // Send a complete client profile in one message to minimize turns + const input = page.getByPlaceholder("Type a message..."); + await input.fill( + "Hi, I'm Jamie Chen, 45 years old, conservative investor. I have $500,000 in RRSPs and plan to retire in 10 years. No debts. I want a review of whether my allocation is still appropriate.", + ); + await page.keyboard.press("Enter"); + + await expect( + page + .locator('[data-testid="chat-interface"]') + .getByText("Jamie", { exact: false }), + ).toBeVisible({ timeout: 10000 }); + + await waitForChatReady(page, 90000); + + // Navigate to CMS and verify the client-profile content type has an entry + await page.goto("/pages/cms"); + await expect(page.locator("body")).toBeVisible({ timeout: 10000 }); + + // The CMS dashboard should show the Client Profile content type + await expect( + page.getByText("Client Profile").or(page.getByText("client-profile")), + ).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/examples/nextjs/app/layout.tsx b/examples/nextjs/app/layout.tsx index a43e50e9..a170b58f 100644 --- a/examples/nextjs/app/layout.tsx +++ b/examples/nextjs/app/layout.tsx @@ -2,6 +2,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "@/components/ui/sonner" import { ThemeProvider } from "next-themes"; import { Navbar } from "@/components/navbar"; +import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context"; import "./globals.css"; const geistSans = Geist({ @@ -25,16 +26,18 @@ export default function RootLayout({ - - - {children} - - + + + + {children} + + + ); diff --git a/examples/nextjs/app/pages/layout.tsx b/examples/nextjs/app/pages/layout.tsx index d2e61ea8..a8a4eba3 100644 --- a/examples/nextjs/app/pages/layout.tsx +++ b/examples/nextjs/app/pages/layout.tsx @@ -10,6 +10,7 @@ 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" +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import type { UIBuilderPluginOverrides } from "@btst/stack/plugins/ui-builder/client" @@ -277,6 +278,19 @@ export default function ExampleLayout({ }} > {children} + {/* Floating AI chat widget — visible on all /pages/* routes for route-aware AI context */} +
+ +
) diff --git a/examples/nextjs/lib/cms-schemas.ts b/examples/nextjs/lib/cms-schemas.ts index 1cd97c2b..2cf96c6d 100644 --- a/examples/nextjs/lib/cms-schemas.ts +++ b/examples/nextjs/lib/cms-schemas.ts @@ -119,6 +119,39 @@ export const CommentSchema = z.object({ }), }) +// ========== Client Profile Schema (WealthReview Demo) ========== +export const ClientProfileSchema = z.object({ + clientName: z.string().min(1).meta({ description: "Client full name" }), + age: z.coerce.number().min(18).max(120).meta({ description: "Client age" }), + riskTolerance: z.enum(["conservative", "moderate", "aggressive"]).meta({ + description: "Risk tolerance level", + }), + totalAssets: z.coerce.number().min(0).optional().meta({ + description: "Total declared assets (CAD)", + }), + windfallAmount: z.coerce.number().min(0).optional().meta({ + description: "Incoming windfall amount, if applicable (CAD)", + }), + lifeEvents: z.string().optional().meta({ + description: "Upcoming or recent life events (comma-separated)", + fieldType: "textarea", + }), + recommendation: z.string().meta({ + description: "AI-generated advisor recommendation", + fieldType: "textarea", + }), + amlFlag: z.boolean().default(false).meta({ + description: "AML escalation flag — requires compliance review if true", + }), + amlReason: z.string().optional().meta({ + description: "Reason for AML flag, if applicable", + fieldType: "textarea", + }), + confidenceScore: z.coerce.number().min(0).max(100).meta({ + description: "AI confidence score (0–100)", + }), +}) + // ========== Type Exports ========== /** Inferred type for Product data */ @@ -136,6 +169,9 @@ export type ResourceData = z.infer /** Inferred type for Comment data */ export type CommentData = z.infer +/** Inferred type for ClientProfile data */ +export type ClientProfileData = z.infer + /** * Type map for all CMS content types. * Use this with CMS hooks for type-safe parsedData. @@ -165,4 +201,5 @@ export type CMSTypes = { category: CategoryData resource: ResourceData comment: CommentData + "client-profile": ClientProfileData } diff --git a/examples/nextjs/lib/stack.ts b/examples/nextjs/lib/stack.ts index c7341ecd..131a72b9 100644 --- a/examples/nextjs/lib/stack.ts +++ b/examples/nextjs/lib/stack.ts @@ -15,7 +15,12 @@ import { z } from "zod" import { revalidateTag, revalidatePath } from "next/cache" // Import shared CMS schemas - these are used for both backend validation and client type inference -import { ProductSchema, TestimonialSchema, CategorySchema, ResourceSchema, CommentSchema } from "./cms-schemas" +import { ProductSchema, TestimonialSchema, CategorySchema, ResourceSchema, CommentSchema, ClientProfileSchema } from "./cms-schemas" +import { + createKanbanTask, + findOrCreateKanbanBoard, + getKanbanColumnsByBoardId, +} from "@btst/stack/plugins/kanban/api" // Tool to fetch BTST documentation const stackDocsTool = tool({ @@ -109,8 +114,112 @@ const blogHooks: BlogBackendHooks = { // separately, but all run in the same Node.js process). const globalForStack = global as typeof global & { __btst_stack__?: ReturnType }; +// WealthReview Demo — AI-native financial intake tool +// Both references are set inside createStack() before any HTTP request fires. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let wealthReviewAdapter: any +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let wealthReviewCmsApi: any + +const submitIntakeAssessment = tool({ + description: + "Submit the completed client intake assessment. Call this once you have gathered sufficient information about the client's financial situation. Creates a client profile record and adds a card to the advisor review queue.", + inputSchema: z.object({ + clientName: z.string().describe("Full name of the client"), + age: z.number().int().min(18).describe("Client age"), + riskTolerance: z + .enum(["conservative", "moderate", "aggressive"]) + .describe("Assessed risk tolerance"), + totalAssets: z + .number() + .min(0) + .optional() + .describe("Total declared assets in CAD"), + windfallAmount: z + .number() + .min(0) + .optional() + .describe("Incoming windfall amount in CAD, if applicable"), + lifeEvents: z + .array(z.string()) + .describe("Upcoming or recent life events (marriage, retirement, etc.)"), + recommendation: z + .string() + .describe("AI-generated recommendation for the human advisor"), + amlFlag: z + .boolean() + .describe( + "Set true if the case shows AML risk signals (large international transfers, unusual source of funds, etc.)", + ), + amlReason: z + .string() + .optional() + .describe("Explanation of the AML flag — required when amlFlag is true"), + confidenceScore: z + .number() + .min(0) + .max(100) + .describe("Your confidence in the recommendation (0–100)"), + }), + execute: async (params) => { + if (!wealthReviewAdapter) { + throw new Error("[WealthReview] Adapter not initialized") + } + const adapter = wealthReviewAdapter + + // 1. Persist client profile in CMS + // Use api.cms.createContentItem (not the standalone mutation) so that + // ensureSynced() runs first — required if no CMS HTTP request has been + // made before this tool call (the content type won't exist otherwise). + const slug = `client-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` + await wealthReviewCmsApi.createContentItem("client-profile", { + slug, + data: { + ...params, + lifeEvents: params.lifeEvents.join(", "), + }, + }) + + // 2. Ensure the advisor review board exists (idempotent) + const board = await findOrCreateKanbanBoard( + adapter, + "advisor-review-queue", + "Advisor Review Queue", + ["New Intakes", "Under Review", "Escalated"], + ) + + // 3. Route to the correct column + const columns = await getKanbanColumnsByBoardId(adapter, board.id) + const targetColumn = params.amlFlag + ? (columns.find((c: { title: string }) => c.title === "Escalated") ?? columns[columns.length - 1]) + : (columns.find((c: { title: string }) => c.title === "New Intakes") ?? columns[0]) + + if (!targetColumn) { + throw new Error("[WealthReview] No columns found on review board") + } + + // 4. Create the Kanban review card + await createKanbanTask(adapter, { + title: `${params.clientName}${params.amlFlag ? " — ⚠️ ESCALATED" : " — Ready for Review"}`, + columnId: targetColumn.id, + priority: params.amlFlag ? "URGENT" : "MEDIUM", + description: params.amlFlag + ? `AML FLAG: ${params.amlReason ?? "See assessment"}\nConfidence: ${params.confidenceScore}%\n\n${params.recommendation}` + : `Confidence: ${params.confidenceScore}%\n\n${params.recommendation}`, + }) + + return { + success: true, + escalated: params.amlFlag, + message: params.amlFlag + ? "This case has been flagged and routed to the Escalated column. A licensed compliance officer must review before proceeding." + : "Assessment complete. Your case has been added to the advisor review queue — you'll hear back shortly.", + } + }, +}) + function createStack() { - return stack({ + const s = stack({ basePath: "/api/data", plugins: { todos: todosBackendPlugin, @@ -125,7 +234,10 @@ function createStack() { mode: "authenticated", // Default: persisted conversations tools: { stackDocs: stackDocsTool, + submitIntakeAssessment, }, + // Enable route-aware page tools (fillBlogForm, updatePageLayers, etc.) + enablePageTools: true, // Optional: Extract userId from headers to scope conversations per user // getUserId: async (ctx) => { // const userId = ctx.headers?.get('x-user-id'); @@ -175,6 +287,12 @@ function createStack() { description: "Comments on resources (one-to-many relationship)", schema: CommentSchema, }, + { + name: "Client Profile", + slug: "client-profile", + description: "WealthReview AI — financial intake assessments submitted by the AI advisor", + schema: ClientProfileSchema, + }, // UI Builder pages - stored as CMS content items UI_BUILDER_CONTENT_TYPE, ], @@ -236,6 +354,14 @@ function createStack() { }, adapter: (db) => createMemoryAdapter(db)({}) }) + + // Capture adapter and CMS api for the WealthReview tool's execute function. + // Safe to assign here — execute only runs during HTTP requests, which + // occur after module initialization is complete. + wealthReviewAdapter = s.adapter + wealthReviewCmsApi = s.api.cms + + return s } export const myStack = globalForStack.__btst_stack__ ??= createStack() diff --git a/examples/nextjs/next.config.ts b/examples/nextjs/next.config.ts index c17a0605..56299e46 100644 --- a/examples/nextjs/next.config.ts +++ b/examples/nextjs/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - reactCompiler: true, + reactCompiler: false, images: { remotePatterns: [ { diff --git a/examples/react-router/app/root.tsx b/examples/react-router/app/root.tsx index 382eb36d..e098dec3 100644 --- a/examples/react-router/app/root.tsx +++ b/examples/react-router/app/root.tsx @@ -16,6 +16,7 @@ import "./app.css"; import { ThemeProvider } from "next-themes"; import { Navbar } from "./components/navbar"; import { Toaster } from "sonner"; +import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context"; export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, @@ -40,16 +41,18 @@ export function Layout({ children }: { children: React.ReactNode }) { - - - {children} - - + + + + {children} + + + diff --git a/examples/react-router/app/routes/pages/_layout.tsx b/examples/react-router/app/routes/pages/_layout.tsx index 9a93dd0f..225e752e 100644 --- a/examples/react-router/app/routes/pages/_layout.tsx +++ b/examples/react-router/app/routes/pages/_layout.tsx @@ -4,6 +4,7 @@ import { Outlet, Link, useNavigate } from "react-router"; import { StackProvider } from "@btst/stack/context" import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" +import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" @@ -209,6 +210,19 @@ export default function Layout() { }} > + {/* Floating AI chat widget — visible on all /pages/* routes for route-aware AI context */} +
+ +
); diff --git a/examples/tanstack/package.json b/examples/tanstack/package.json index 592ba589..04bb4edc 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 dotenv -e .env -- node .output/server/index.mjs" + "start:e2e": "rm -rf .output && rm -rf .nitro && rm -rf .tanstack && rm -rf node_modules && pnpm install && NODE_OPTIONS=--max-old-space-size=8192 pnpm build && NODE_ENV=test dotenv -e .env -- node .output/server/index.mjs" }, "keywords": [], "author": "", diff --git a/examples/tanstack/src/routes/__root.tsx b/examples/tanstack/src/routes/__root.tsx index 8b745bd1..059ae1bf 100644 --- a/examples/tanstack/src/routes/__root.tsx +++ b/examples/tanstack/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { MyRouterContext } from '@/router' import { ThemeProvider } from 'next-themes' import { Navbar } from '@/components/navbar' import { Toaster } from 'sonner' +import { PageAIContextProvider } from '@btst/stack/plugins/ai-chat/client/context' export const Route = createRootRouteWithContext()({ head: () => ({ @@ -109,16 +110,18 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) { - - - {children} - - + + + + {children} + + + diff --git a/examples/tanstack/src/routes/pages/route.tsx b/examples/tanstack/src/routes/pages/route.tsx index c3a28731..cc2bac81 100644 --- a/examples/tanstack/src/routes/pages/route.tsx +++ b/examples/tanstack/src/routes/pages/route.tsx @@ -4,6 +4,7 @@ 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 { ChatLayout } from "@btst/stack/plugins/ai-chat/client" import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client" import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builder/client" import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" @@ -218,6 +219,19 @@ function Layout() { }} > + {/* Floating AI chat widget — visible on all /pages/* routes for route-aware AI context */} +
+ +
) diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index ba3cf6c6..aa69d4ca 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -75,6 +75,7 @@ export default defineBuildConfig({ "./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/client/context/page-ai-context.tsx", "./src/plugins/ai-chat/query-keys.ts", // cms plugin entries "./src/plugins/cms/api/index.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index cd56aa93..31b23fe8 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -163,6 +163,16 @@ "default": "./dist/plugins/ai-chat/client/hooks/index.cjs" } }, + "./plugins/ai-chat/client/context": { + "import": { + "types": "./dist/plugins/ai-chat/client/context/page-ai-context.d.ts", + "default": "./dist/plugins/ai-chat/client/context/page-ai-context.mjs" + }, + "require": { + "types": "./dist/plugins/ai-chat/client/context/page-ai-context.d.cts", + "default": "./dist/plugins/ai-chat/client/context/page-ai-context.cjs" + } + }, "./plugins/ai-chat/css": "./dist/plugins/ai-chat/style.css", "./plugins/blog/css": "./dist/plugins/blog/style.css", "./plugins/cms/api": { @@ -392,6 +402,9 @@ "plugins/ai-chat/client/hooks": [ "./dist/plugins/ai-chat/client/hooks/index.d.ts" ], + "plugins/ai-chat/client/context": [ + "./dist/plugins/ai-chat/client/context/page-ai-context.d.ts" + ], "plugins/cms/api": [ "./dist/plugins/cms/api/index.d.ts" ], diff --git a/packages/stack/src/client/components/compose.tsx b/packages/stack/src/client/components/compose.tsx index 35121b7a..7116a31e 100644 --- a/packages/stack/src/client/components/compose.tsx +++ b/packages/stack/src/client/components/compose.tsx @@ -89,10 +89,13 @@ export function ComposedRoute({ }) { if (PageComponent) { const content = ; - // Avoid server-side skeletons: only show loading fallback in the browser - const isBrowser = typeof window !== "undefined"; - const suspenseFallback = - isBrowser && LoadingComponent ? : null; + // Always provide the same fallback on server and client — using + // `typeof window !== "undefined"` here would produce a different JSX tree + // on each side, shifting React's useId() counter and causing hydration + // mismatches in any descendant that uses Radix (Select, Dialog, etc.). + // If the Suspense boundary never actually suspends during SSR (data is + // prefetched), React won't emit the fallback into the HTML anyway. + const suspenseFallback = LoadingComponent ? : null; // If an ErrorComponent is provided (which itself may be lazy), ensure we have // a Suspense boundary that can handle both the page content and the lazy error UI diff --git a/packages/stack/src/plugins/ai-chat/api/page-tools.ts b/packages/stack/src/plugins/ai-chat/api/page-tools.ts new file mode 100644 index 00000000..be6b5f6c --- /dev/null +++ b/packages/stack/src/plugins/ai-chat/api/page-tools.ts @@ -0,0 +1,94 @@ +import { tool } from "ai"; +import type { Tool } from "ai"; +import { z } from "zod"; + +/** + * Built-in client-side-only tool schemas for route-aware AI context. + * + * These tools have no `execute` function — they are handled on the client side + * via the onToolCall handler in ChatInterface, which dispatches to handlers + * registered by pages via useRegisterPageAIContext. + * + * Consumers can add their own tool schemas via clientToolSchemas in AiChatBackendConfig. + * The server merges built-ins + consumer schemas and filters by the availableTools + * list sent with each request. + */ +export const BUILT_IN_PAGE_TOOL_SCHEMAS: Record = { + /** + * Fill in the blog post editor form fields. + * Registered by blog new/edit page via useRegisterPageAIContext. + */ + fillBlogForm: tool({ + description: + "Fill in the blog post editor form fields. Call this when the user asks to write, draft, or populate a blog post. You can fill any combination of title, content, excerpt, and tags.", + inputSchema: z.object({ + title: z.string().optional().describe("The post title"), + content: z + .string() + .optional() + .describe( + "Full markdown content for the post body. Use proper markdown formatting with headings, lists, etc.", + ), + excerpt: z + .string() + .optional() + .describe("A short summary/excerpt of the post (1-2 sentences)"), + tags: z + .array(z.string()) + .optional() + .describe("Array of tag names to apply to the post"), + }), + }), + + /** + * Replace the UI builder page layers with new ones. + * Registered by the UI builder edit page via useRegisterPageAIContext. + */ + updatePageLayers: tool({ + description: `Replace the UI builder page component layers. Call this when the user asks to change, add, redesign, or update the page layout and components. + +Rules: +- Provide the COMPLETE layer tree, not a partial diff. The entire tree will replace the current layers. +- Only use component types that appear in the "Available Component Types" list in the page context. +- Every layer must have a unique \`id\` string (e.g. "hero-section", "card-title-1"). +- The \`type\` field must exactly match a name from the component registry (e.g. "div", "Button", "Card", "Flexbox"). +- The \`name\` field is the human-readable label shown in the layers panel. +- \`props\` contains component-specific props (className uses Tailwind classes). +- \`children\` is either an array of child ComponentLayer objects, or a plain string for text content. +- Use \`Flexbox\` or \`Grid\` for layout instead of raw div flex/grid when possible. +- Preserve any layers the user has not asked to change — read the current layers from the page context first. +- ALWAYS use shadcn/ui semantic color tokens in className (e.g. bg-background, bg-card, bg-primary, text-foreground, text-muted-foreground, text-primary-foreground, border-border) instead of hardcoded Tailwind colors like bg-white, bg-gray-*, text-black, etc. This ensures the UI automatically adapts to light and dark themes.`, + inputSchema: z.object({ + layers: z + .array( + z.object({ + id: z.string().describe("Unique identifier for this layer"), + type: z + .string() + .describe( + "Component type — must match a key in the component registry (e.g. 'div', 'Button', 'Card', 'Flexbox')", + ), + name: z + .string() + .describe( + "Human-readable display name shown in the layers panel", + ), + props: z + .record(z.string(), z.any()) + .describe( + "Component props object. Use Tailwind classes for className. See the component registry for valid props per type.", + ), + children: z + .any() + .optional() + .describe( + "Child layers (array of ComponentLayer) or plain text string", + ), + }), + ) + .describe( + "Complete replacement layer tree. Must include ALL layers for the page, not just changed ones.", + ), + }), + }), +}; diff --git a/packages/stack/src/plugins/ai-chat/api/plugin.ts b/packages/stack/src/plugins/ai-chat/api/plugin.ts index 439001da..4957e6b3 100644 --- a/packages/stack/src/plugins/ai-chat/api/plugin.ts +++ b/packages/stack/src/plugins/ai-chat/api/plugin.ts @@ -17,6 +17,7 @@ import { } from "../schemas"; import type { Conversation, ConversationWithMessages, Message } from "../types"; import { getAllConversations, getConversationById } from "./getters"; +import { BUILT_IN_PAGE_TOOL_SCHEMAS } from "./page-tools"; /** * Context passed to AI Chat API hooks @@ -269,6 +270,31 @@ export interface AiChatBackendConfig { */ tools?: Record; + /** + * Enable route-aware page tools. + * When true, the server will include tool schemas for client-side page tools + * (e.g. fillBlogForm, updatePageLayers) based on the availableTools list + * sent with each request. + * @default false + */ + enablePageTools?: boolean; + + /** + * Custom client-side tool schemas for non-BTST pages. + * Merged with built-in page tool schemas (fillBlogForm, updatePageLayers). + * Only included when enablePageTools is true and the tool name appears in + * the availableTools list sent with the request. + * + * @example + * clientToolSchemas: { + * addToCart: tool({ + * description: "Add current product to cart", + * parameters: z.object({ quantity: z.number().int().min(1) }), + * }), + * } + */ + clientToolSchemas?: Record; + /** * Optional hooks for customizing plugin behavior */ @@ -350,7 +376,12 @@ export const aiChatBackendPlugin = (config: AiChatBackendConfig) => body: chatRequestSchema, }, async (ctx) => { - const { messages: rawMessages, conversationId } = ctx.body; + const { + messages: rawMessages, + conversationId, + pageContext, + availableTools, + } = ctx.body; const uiMessages = rawMessages as UIMessage[]; const context: ChatApiContext = { @@ -388,22 +419,54 @@ export const aiChatBackendPlugin = (config: AiChatBackendConfig) => // Convert UIMessages to CoreMessages for streamText const modelMessages = convertToModelMessages(uiMessages); - // Add system prompt if configured - const messagesWithSystem = config.systemPrompt + // Build system prompt: base config + optional page context + const pageContextContent = + pageContext && pageContext.trim() + ? `\n\nCurrent page context:\n${pageContext}` + : ""; + const systemContent = config.systemPrompt + ? `${config.systemPrompt}${pageContextContent}` + : pageContextContent || undefined; + + const messagesWithSystem = systemContent ? [ - { role: "system" as const, content: config.systemPrompt }, + { role: "system" as const, content: systemContent }, ...modelMessages, ] : modelMessages; + // Merge page tool schemas when enablePageTools is on + // Built-in schemas + consumer custom schemas, filtered by availableTools from request + const activePageTools: Record = + config.enablePageTools && + availableTools && + availableTools.length > 0 + ? (() => { + const allPageSchemas = { + ...BUILT_IN_PAGE_TOOL_SCHEMAS, + ...(config.clientToolSchemas ?? {}), + }; + return Object.fromEntries( + availableTools + .filter((name) => name in allPageSchemas) + .map((name) => [name, allPageSchemas[name]!]), + ); + })() + : {}; + + const mergedTools = + Object.keys(activePageTools).length > 0 + ? { ...config.tools, ...activePageTools } + : config.tools; + // PUBLIC MODE: Stream without persistence if (isPublicMode) { const result = streamText({ model: config.model, messages: messagesWithSystem, - tools: config.tools, + tools: mergedTools, // Enable multi-step tool calls if tools are configured - ...(config.tools ? { stopWhen: stepCountIs(5) } : {}), + ...(mergedTools ? { stopWhen: stepCountIs(5) } : {}), }); return result.toUIMessageStreamResponse({ @@ -557,9 +620,9 @@ export const aiChatBackendPlugin = (config: AiChatBackendConfig) => const result = streamText({ model: config.model, messages: messagesWithSystem, - tools: config.tools, + tools: mergedTools, // Enable multi-step tool calls if tools are configured - ...(config.tools ? { stopWhen: stepCountIs(5) } : {}), + ...(mergedTools ? { 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 diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx index eb4edfce..912fb09f 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-interface.tsx @@ -7,7 +7,11 @@ import { ChatMessage } from "./chat-message"; import { ChatInput, type AttachedFile } from "./chat-input"; import { StackAttribution } from "@workspace/ui/components/stack-attribution"; import { ScrollArea } from "@workspace/ui/components/scroll-area"; -import { DefaultChatTransport, type UIMessage } from "ai"; +import { + DefaultChatTransport, + lastAssistantMessageIsCompleteWithToolCalls, + type UIMessage, +} from "ai"; import { cn } from "@workspace/ui/lib/utils"; import { usePluginOverrides, useBasePath } from "@btst/stack/context"; import type { AiChatPluginOverrides } from "../overrides"; @@ -20,6 +24,7 @@ import { useConversations, type SerializedConversation, } from "../hooks/chat-hooks"; +import { usePageAIContext } from "../context/page-ai-context"; interface ChatInterfaceProps { apiPath?: string; @@ -56,6 +61,9 @@ export function ChatInterface({ const basePath = useBasePath(); const isPublicMode = mode === "public"; + // Read page AI context registered by the current page + const pageAIContext = usePageAIContext(); + const localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization }; const queryClient = useQueryClient(); @@ -126,6 +134,13 @@ export function ChatInterface({ !initialMessages || initialMessages.length === 0, ); + // Ref to always have the latest pageAIContext in the transport callback + // without recreating the transport on every context change + const pageAIContextRef = useRef(pageAIContext); + useEffect(() => { + pageAIContextRef.current = pageAIContext; + }, [pageAIContext]); + // Memoize the transport to prevent recreation on every render const transport = useMemo( () => @@ -135,8 +150,20 @@ export function ChatInterface({ body: isPublicMode ? undefined : () => ({ conversationId: conversationIdRef.current }), - // Handle edit operations by using truncated messages from the ref + // Handle edit operations and inject page context prepareSendMessagesRequest: ({ messages: hookMessages }) => { + const currentPageContext = pageAIContextRef.current; + + // Build page context fields to include in every request + const pageContextBody = currentPageContext?.pageDescription + ? { + pageContext: currentPageContext.pageDescription, + availableTools: Object.keys( + currentPageContext.clientTools ?? {}, + ), + } + : {}; + // If we're in an edit operation, use the truncated messages + new user message if (editMessagesRef.current !== null) { const newUserMessage = hookMessages[hookMessages.length - 1]; @@ -150,6 +177,7 @@ export function ChatInterface({ body: { messages: messagesToSend, conversationId: conversationIdRef.current, + ...pageContextBody, }, }; } @@ -158,6 +186,7 @@ export function ChatInterface({ body: { messages: hookMessages, conversationId: conversationIdRef.current, + ...pageContextBody, }, }; }, @@ -165,48 +194,99 @@ export function ChatInterface({ [apiPath, isPublicMode], ); - const { messages, sendMessage, status, error, setMessages, regenerate } = - useChat({ - transport, - onError: (err) => { - console.error("useChat onError:", err); - // Reset first-message tracking if the send failed before a conversation was created. - // Without this, isFirstMessageSentRef stays true and the next successful send - // skips the "first message" navigation logic, corrupting the conversation flow. - if (!id && !hasNavigatedRef.current) { - isFirstMessageSentRef.current = false; - } - }, - onFinish: async () => { - // In public mode, skip all persistence-related operations - if (isPublicMode) return; + // Use a ref so addToolOutput is always current inside the onToolCall closure + const addToolOutputRef = useRef< + ReturnType["addToolOutput"] | null + >(null); - // Invalidate conversation list to show new/updated conversations - await queryClient.invalidateQueries({ + const { + messages, + sendMessage, + status, + error, + setMessages, + regenerate, + addToolOutput, + } = useChat({ + transport, + // Automatically resubmit after all client-side tool results are provided + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + onToolCall: async ({ toolCall }) => { + // Dispatch client-side tool calls to the handler registered by the current page. + // In AI SDK v5, onToolCall returns void — addToolOutput must be called explicitly. + const toolName = toolCall.toolName; + const handler = pageAIContextRef.current?.clientTools?.[toolName]; + if (handler) { + try { + const result = await handler(toolCall.input); + // No await — avoids potential deadlocks with sendAutomaticallyWhen + addToolOutputRef.current?.({ + tool: toolName, + toolCallId: toolCall.toolCallId, + output: result, + }); + } catch (err) { + addToolOutputRef.current?.({ + tool: toolName, + toolCallId: toolCall.toolCallId, + state: "output-error", + errorText: + err instanceof Error ? err.message : "Tool execution failed", + }); + } + } else { + // No handler found — this happens when the user navigates away while a + // tool-call response is streaming and the page context changes. Always + // call addToolOutput so sendAutomaticallyWhen can unblock; without this + // the conversation gets permanently stuck waiting for a missing output. + addToolOutputRef.current?.({ + tool: toolName, + toolCallId: toolCall.toolCallId, + state: "output-error", + errorText: `No client-side handler registered for tool "${toolName}". The page context may have changed while the response was streaming.`, + }); + } + }, + onError: (err) => { + console.error("useChat onError:", err); + // Reset first-message tracking if the send failed before a conversation was created. + // Without this, isFirstMessageSentRef stays true and the next successful send + // skips the "first message" navigation logic, corrupting the conversation flow. + if (!id && !hasNavigatedRef.current) { + isFirstMessageSentRef.current = false; + } + }, + onFinish: async () => { + // In public mode, skip all persistence-related operations + if (isPublicMode) return; + + // Invalidate conversation list to show new/updated conversations + await queryClient.invalidateQueries({ + queryKey: conversationsListQueryKey, + }); + + // If this was the first message on a new chat, update the URL without full navigation + // This avoids losing the in-memory messages during component remount + if (isFirstMessageSentRef.current && !id && !hasNavigatedRef.current) { + hasNavigatedRef.current = true; + // Wait for the invalidation to complete and refetch conversations + await queryClient.refetchQueries({ queryKey: conversationsListQueryKey, }); - - // If this was the first message on a new chat, update the URL without full navigation - // This avoids losing the in-memory messages during component remount - if (isFirstMessageSentRef.current && !id && !hasNavigatedRef.current) { - hasNavigatedRef.current = true; - // Wait for the invalidation to complete and refetch conversations - await queryClient.refetchQueries({ - queryKey: conversationsListQueryKey, - }); - // Get the updated conversations from cache - const cachedConversations = queryClient.getQueryData< - SerializedConversation[] - >(conversationsListQueryKey); - if (cachedConversations && cachedConversations.length > 0) { - // The most recently updated conversation should be the one we just created - const newConversation = cachedConversations[0]; - if (newConversation) { - // Update our local state - setCurrentConversationId(newConversation.id); - conversationIdRef.current = newConversation.id; - // Update URL without navigation to preserve in-memory messages - // Use replaceState to avoid adding to history stack + // Get the updated conversations from cache + const cachedConversations = queryClient.getQueryData< + SerializedConversation[] + >(conversationsListQueryKey); + if (cachedConversations && cachedConversations.length > 0) { + // The most recently updated conversation should be the one we just created + const newConversation = cachedConversations[0]; + if (newConversation) { + // Update our local state + setCurrentConversationId(newConversation.id); + conversationIdRef.current = newConversation.id; + // Only update the URL in full-page mode; in widget mode the chat is + // embedded in another page and clobbering the URL is disruptive. + if (variant === "full") { const newUrl = `${basePath}/chat/${newConversation.id}`; if (typeof window !== "undefined") { window.history.replaceState( @@ -218,8 +298,14 @@ export function ChatInterface({ } } } - }, - }); + } + }, + }); + + // Keep addToolOutputRef in sync so onToolCall always has the latest reference + useEffect(() => { + addToolOutputRef.current = addToolOutput; + }, [addToolOutput]); // Load existing conversation messages when navigating to a conversation useEffect(() => { @@ -487,20 +573,29 @@ export function ChatInterface({

{localization.CHAT_EMPTY_STATE}

- {chatSuggestions && chatSuggestions.length > 0 && ( -
- {chatSuggestions.map((suggestion, index) => ( - - ))} -
- )} + {(() => { + // Merge static suggestions from overrides with dynamic ones from page context. + // Page context suggestions appear first (most relevant to current page). + const pageSuggestions = pageAIContext?.suggestions ?? []; + const allSuggestions = [ + ...pageSuggestions, + ...(chatSuggestions ?? []), + ]; + return allSuggestions.length > 0 ? ( +
+ {allSuggestions.map((suggestion, index) => ( + + ))} +
+ ) : null; + })()} ) : ( messages.map((m, index) => ( diff --git a/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx b/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx index 368ffeb4..50012cc1 100644 --- a/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx +++ b/packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx @@ -2,57 +2,116 @@ import { useState, useCallback } from "react"; import { Button } from "@workspace/ui/components/button"; +import { Badge } from "@workspace/ui/components/badge"; import { Sheet, SheetContent, SheetTrigger, } from "@workspace/ui/components/sheet"; -import { Menu, PanelLeftClose, PanelLeft } from "lucide-react"; +import { + Menu, + PanelLeftClose, + PanelLeft, + Sparkles, + Trash2, + X, +} from "lucide-react"; import { cn } from "@workspace/ui/lib/utils"; import { ChatSidebar } from "./chat-sidebar"; import { ChatInterface } from "./chat-interface"; import type { UIMessage } from "ai"; +import { usePageAIContext } from "../context/page-ai-context"; -export interface ChatLayoutProps { +interface ChatLayoutBaseProps { /** API base URL */ apiBaseURL: string; /** API base path */ apiBasePath: string; /** Current conversation ID (if viewing existing conversation) */ conversationId?: string; - /** Layout mode: 'full' for full page with sidebar, 'widget' for embeddable widget */ - layout?: "full" | "widget"; /** Additional class name for the container */ className?: string; - /** Whether to show the sidebar (default: true for full layout) */ + /** Whether to show the sidebar */ showSidebar?: boolean; - /** Height of the widget (only applies to widget layout) */ - widgetHeight?: string | number; /** Initial messages to populate the chat (useful for localStorage persistence in public mode) */ initialMessages?: UIMessage[]; /** Called whenever messages change (for persistence). Only fires in public mode. */ onMessagesChange?: (messages: UIMessage[]) => void; } +interface ChatLayoutWidgetProps extends ChatLayoutBaseProps { + /** Widget mode: compact embeddable panel with a floating trigger button */ + layout: "widget"; + /** Height of the widget panel. Default: `"600px"` */ + widgetHeight?: string | number; + /** Width of the widget panel. Default: `"380px"` */ + widgetWidth?: string | number; + /** + * Whether the widget panel starts open. Default: `false`. + * Set to `true` when embedding inside an already-open container such as a + * Next.js intercepting-route modal — the panel will be immediately visible + * without the user needing to click the trigger button. + */ + defaultOpen?: boolean; + /** + * Whether to render the built-in floating trigger button. Default: `true`. + * Set to `false` when you control open/close externally (e.g. a Next.js + * parallel-route slot, a custom button, or a `router.back()` dismiss action) + * so that the built-in button does not appear alongside your own UI. + */ + showTrigger?: boolean; +} + +interface ChatLayoutFullProps extends ChatLayoutBaseProps { + /** Full-page mode with sidebar navigation (default) */ + layout?: "full"; +} + +/** Props for the ChatLayout component */ +export type ChatLayoutProps = ChatLayoutWidgetProps | ChatLayoutFullProps; + /** * ChatLayout component that provides a full-page chat experience with sidebar * or a compact widget mode for embedding. */ -export function ChatLayout({ - apiBaseURL, - apiBasePath, - conversationId, - layout = "full", - className, - showSidebar = true, - widgetHeight = "600px", - initialMessages, - onMessagesChange, -}: ChatLayoutProps) { +export function ChatLayout(props: ChatLayoutProps) { + const { + apiBaseURL, + apiBasePath, + conversationId, + layout = "full", + className, + showSidebar = true, + initialMessages, + onMessagesChange, + } = props; + + // Widget-specific props — TypeScript narrows props to ChatLayoutWidgetProps here + const widgetHeight = + props.layout === "widget" ? (props.widgetHeight ?? "600px") : "600px"; + const widgetWidth = + props.layout === "widget" ? (props.widgetWidth ?? "380px") : "380px"; + const defaultOpen = + props.layout === "widget" ? (props.defaultOpen ?? false) : false; + const showTrigger = + props.layout === "widget" ? (props.showTrigger ?? true) : true; + const [sidebarOpen, setSidebarOpen] = useState(true); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); // Key to force ChatInterface remount when starting a new chat const [chatResetKey, setChatResetKey] = useState(0); + // Widget open/closed state — starts with defaultOpen value + const [widgetOpen, setWidgetOpen] = useState(defaultOpen); + // Key to force widget ChatInterface remount on clear + const [widgetResetKey, setWidgetResetKey] = useState(0); + // Only mount the widget ChatInterface after the widget has been opened at least once. + // This ensures pageAIContext is already registered before ChatInterface first renders, + // so suggestion chips and tool hints appear immediately on first open. + // When defaultOpen is true the widget is pre-opened, so we mark it as ever-opened immediately. + const [widgetEverOpened, setWidgetEverOpened] = useState(defaultOpen); + + // Read page AI context to show badge in header + const pageAIContext = usePageAIContext(); const apiPath = `${apiBaseURL}${apiBasePath}/chat`; @@ -67,20 +126,83 @@ export function ChatLayout({ if (layout === "widget") { return ( -
+ {/* Chat panel — always mounted to preserve conversation state, hidden when closed */} +
+ {/* Widget header with page context badge and action buttons */} +
+ + {pageAIContext ? ( + + {pageAIContext.routeName} + + ) : ( + + AI Chat + + )} +
+ + +
+ {widgetEverOpened && ( + + )} +
+ + {/* Trigger button — rendered only when showTrigger is true */} + {showTrigger && ( + )} - style={{ height: widgetHeight }} - > -
); } @@ -115,7 +237,7 @@ export function ChatLayout({ {/* Main Chat Area */}
{/* Header */} -
+
{/* Mobile menu button */} {showSidebar && ( @@ -159,6 +281,18 @@ export function ChatLayout({ )}
+ + {/* Page context badge — shown when a page has registered AI context */} + {pageAIContext && ( + + + {pageAIContext.routeName} + + )}
Promise<{ success: boolean; message?: string }>; + +/** + * Configuration registered by a page to provide AI context and capabilities. + * Any component in the tree can call useRegisterPageAIContext with this config. + */ +export interface PageAIContextConfig { + /** + * Identifier for the current route/page (e.g. "blog-post", "ui-builder-edit-page"). + * Shown as a badge in the chat header. + */ + routeName: string; + + /** + * Human-readable description of the current page and its content. + * Injected into the AI system prompt so it understands what the user is looking at. + * Capped at 8,000 characters server-side. + */ + pageDescription: string; + + /** + * Optional suggested prompts shown as quick-action chips in the chat empty state. + * These augment (not replace) any static suggestions configured in plugin overrides. + */ + suggestions?: string[]; + + /** + * Client-side tool handlers keyed by tool name. + * When the AI calls a tool by this name, the handler is invoked with the tool args. + * The result is sent back to the model via addToolResult. + * + * Tool schemas must be registered server-side via enablePageTools + clientToolSchemas + * in aiChatBackendPlugin (built-in tools like fillBlogForm are pre-registered). + */ + clientTools?: Record; +} + +interface PageAIAPIContextValue { + register: (id: string, config: PageAIContextConfig) => void; + unregister: (id: string) => void; + getActive: () => PageAIContextConfig | null; +} + +/** + * Stable API context — holds register/unregister/getActive. + * Never changes reference, so useRegisterPageAIContext effects don't re-run + * simply because the provider re-rendered after a bumpVersion call. + */ +const PageAIAPIContext = createContext(null); + +/** + * Reactive version context — incremented on every register/unregister. + * Consumers of usePageAIContext subscribe here so they re-render when + * registrations change and re-call getActive() to pick up the latest config. + */ +const PageAIVersionContext = createContext(0); + +/** + * Provider that enables route-aware AI context across the app. + * + * Place this at the root layout — above all StackProviders — so it spans + * both your main app tree and any chat modals rendered as parallel/intercept routes. + * + * @example + * // app/layout.tsx + * import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context" + * + * export default function RootLayout({ children }) { + * return {children} + * } + */ +export function PageAIContextProvider({ + children, +}: { + children: React.ReactNode; +}) { + // Map from stable registration id → config + // Using useRef so mutations don't trigger re-renders of the provider itself + const registrationsRef = useRef>(new Map()); + // Track insertion order so the last-registered (most specific) wins + const insertionOrderRef = useRef([]); + + // Version counter — bumped on every register/unregister so consumers re-read + const [version, setVersion] = useState(0); + const bumpVersion = useCallback(() => setVersion((v) => v + 1), []); + + const register = useCallback( + (id: string, config: PageAIContextConfig) => { + registrationsRef.current.set(id, config); + // Move to end to mark as most recent + insertionOrderRef.current = insertionOrderRef.current.filter( + (k) => k !== id, + ); + insertionOrderRef.current.push(id); + bumpVersion(); + }, + [bumpVersion], + ); + + const unregister = useCallback( + (id: string) => { + registrationsRef.current.delete(id); + insertionOrderRef.current = insertionOrderRef.current.filter( + (k) => k !== id, + ); + bumpVersion(); + }, + [bumpVersion], + ); + + const getActive = useCallback((): PageAIContextConfig | null => { + const order = insertionOrderRef.current; + if (order.length === 0) return null; + // Last registered wins (most deeply nested / most recently mounted) + const lastId = order[order.length - 1]; + if (!lastId) return null; + return registrationsRef.current.get(lastId) ?? null; + }, []); + + // Memoize the API object so its reference never changes — this is what + // breaks the infinite loop: useRegisterPageAIContext has `ctx` (the API) + // in its effect deps, and a stable reference means the effect won't re-run + // just because the provider re-rendered after bumpVersion(). + const api = useMemo( + () => ({ register, unregister, getActive }), + [register, unregister, getActive], + ); + + return ( + + + {children} + + + ); +} + +/** + * Register page AI context from any component. + * The registration is cleaned up automatically when the component unmounts. + * + * Pass `null` to conditionally disable context (e.g. while data is loading). + * + * @example + * // Blog post page + * useRegisterPageAIContext(post ? { + * routeName: "blog-post", + * pageDescription: `Blog post: "${post.title}"\n\n${post.content?.slice(0, 16000)}`, + * suggestions: ["Summarize this post", "What are the key takeaways?"], + * } : null) + */ +export function useRegisterPageAIContext( + config: PageAIContextConfig | null, +): void { + // Use the stable API context — its reference never changes, so adding it + // to the dependency array below does NOT cause the effect to re-run after + // bumpVersion() fires. This breaks the register → bumpVersion → re-render + // → effect re-run → register loop that caused "Maximum update depth exceeded". + const ctx = useContext(PageAIAPIContext); + const id = useId(); + + // Always keep the ref current so clientTools handlers are never stale. + // Updating a ref during render is safe — the value is visible to any effect + // that runs in the same commit. + const configRef = useRef(config); + configRef.current = config; + + useEffect(() => { + if (!ctx || !configRef.current) return; + // Register a live proxy that always reads from configRef. This ensures + // clientTools handlers are fresh even when the effect doesn't re-run — + // for example when a handler's closure captures new state but the + // serializable fields (routeName, pageDescription, suggestions) are unchanged. + // JSON.stringify silently strips function values, so clientTools would be + // invisible to a plain JSON.stringify(config) dependency check. + ctx.register(id, { + get routeName() { + return configRef.current?.routeName ?? ""; + }, + get pageDescription() { + return configRef.current?.pageDescription ?? ""; + }, + get suggestions() { + return configRef.current?.suggestions; + }, + get clientTools() { + return configRef.current?.clientTools; + }, + }); + return () => { + ctx.unregister(id); + }; + // Track serializable fields individually. JSON.stringify on the whole config + // would silently strip clientTools (functions), making handler changes invisible. + // Handler freshness is provided by the ref-proxy above instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + ctx, + id, + config === null, + config?.routeName, + config?.pageDescription, + JSON.stringify(config?.suggestions), + ]); +} + +/** + * Read the currently active page AI context. + * Returns null when no page has registered context, or when PageAIContextProvider + * is not in the tree. + * + * Used internally by ChatInterface to inject context into requests. + */ +export function usePageAIContext(): PageAIContextConfig | null { + // Subscribe to the version counter so this hook re-runs whenever a page + // registers or unregisters context, then read the latest active config. + useContext(PageAIVersionContext); + const ctx = useContext(PageAIAPIContext); + if (!ctx) return null; + return ctx.getActive(); +} diff --git a/packages/stack/src/plugins/ai-chat/schemas.ts b/packages/stack/src/plugins/ai-chat/schemas.ts index a4077265..bf7da39f 100644 --- a/packages/stack/src/plugins/ai-chat/schemas.ts +++ b/packages/stack/src/plugins/ai-chat/schemas.ts @@ -37,4 +37,14 @@ export const chatRequestSchema = z.object({ ), conversationId: z.string().optional(), model: z.string().optional(), + /** + * Description of the current page context, injected into the AI system prompt. + * Sent by ChatInterface when a page has registered context via useRegisterPageAIContext. + */ + pageContext: z.string().max(16000).optional(), + /** + * Names of client-side tools currently available on the page. + * The server includes matching tool schemas in the streamText call. + */ + availableTools: z.array(z.string()).optional(), }); diff --git a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx index 6fa3af7f..9fa60717 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx @@ -40,7 +40,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { Loader2 } from "lucide-react"; -import { lazy, memo, Suspense, useMemo, useState } from "react"; +import { lazy, memo, Suspense, useEffect, useMemo, useState } from "react"; import { type FieldPath, type SubmitHandler, @@ -325,6 +325,10 @@ const CustomPostUpdateSchema = PostUpdateSchema.omit({ type AddPostFormProps = { onClose: () => void; onSuccess: (post: { published: boolean }) => void; + /** Called once with the form instance so parent components can access form state */ + onFormReady?: ( + form: UseFormReturn>, + ) => void; }; const addPostFormPropsAreEqual = ( @@ -333,10 +337,15 @@ const addPostFormPropsAreEqual = ( ): boolean => { if (prevProps.onClose !== nextProps.onClose) return false; if (prevProps.onSuccess !== nextProps.onSuccess) return false; + if (prevProps.onFormReady !== nextProps.onFormReady) return false; return true; }; -const AddPostFormComponent = ({ onClose, onSuccess }: AddPostFormProps) => { +const AddPostFormComponent = ({ + onClose, + onSuccess, + onFormReady, +}: AddPostFormProps) => { const [featuredImageUploading, setFeaturedImageUploading] = useState(false); const { localization } = usePluginOverrides< BlogPluginOverrides, @@ -393,6 +402,12 @@ const AddPostFormComponent = ({ onClose, onSuccess }: AddPostFormProps) => { }, }); + // Expose form instance to parent for AI context integration + useEffect(() => { + onFormReady?.(form); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( void; onSuccess: (post: { slug: string; published: boolean }) => void; onDelete?: () => void; + /** Called once with the form instance so parent components can access form state */ + onFormReady?: ( + form: UseFormReturn>, + ) => void; }; const editPostFormPropsAreEqual = ( @@ -427,6 +446,7 @@ const editPostFormPropsAreEqual = ( if (prevProps.onClose !== nextProps.onClose) return false; if (prevProps.onSuccess !== nextProps.onSuccess) return false; if (prevProps.onDelete !== nextProps.onDelete) return false; + if (prevProps.onFormReady !== nextProps.onFormReady) return false; return true; }; @@ -435,6 +455,7 @@ const EditPostFormComponent = ({ onClose, onSuccess, onDelete, + onFormReady, }: EditPostFormProps) => { const [featuredImageUploading, setFeaturedImageUploading] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -537,6 +558,12 @@ const EditPostFormComponent = ({ values: initialData as z.input, }); + // Expose form instance to parent for AI context integration + useEffect(() => { + onFormReady?.(form); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + if (!post) { return ; } diff --git a/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx b/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx index a5228f60..f79fc60d 100644 --- a/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +++ b/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx @@ -7,6 +7,10 @@ import { PageWrapper } from "../shared/page-wrapper"; import { BLOG_LOCALIZATION } from "../../localization"; import type { BlogPluginOverrides } from "../../overrides"; import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; +import { useRef, useCallback } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { createFillBlogFormHandler } from "./fill-blog-form-handler"; // Internal component with actual page content export function EditPostPage({ slug }: { slug: string }) { @@ -36,6 +40,29 @@ export function EditPostPage({ slug }: { slug: string }) { }, }); + // Ref to capture the form instance from EditPostForm via onFormReady callback + const formRef = useRef | null>(null); + const handleFormReady = useCallback((form: UseFormReturn) => { + formRef.current = form; + }, []); + + // Register AI context so the chat can fill in the edit form + useRegisterPageAIContext({ + routeName: "blog-edit-post", + pageDescription: `User is editing a blog post (slug: "${slug}") in the admin editor.`, + suggestions: [ + "Improve this post's title", + "Rewrite the intro paragraph", + "Suggest better tags", + ], + clientTools: { + fillBlogForm: createFillBlogFormHandler( + formRef, + "Form updated successfully", + ), + }, + }); + const handleClose = () => { navigate(`${basePath}/blog`); }; @@ -61,6 +88,7 @@ export function EditPostPage({ slug }: { slug: string }) { onClose={handleClose} onSuccess={handleSuccess} onDelete={handleDelete} + onFormReady={handleFormReady} /> ); diff --git a/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts b/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts new file mode 100644 index 00000000..8867b24a --- /dev/null +++ b/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts @@ -0,0 +1,38 @@ +import type { RefObject } from "react"; +import type { UseFormReturn } from "react-hook-form"; + +/** + * Returns a `fillBlogForm` client tool handler bound to a form ref. + * Used by both the new-post and edit-post pages so the field-mapping + * logic stays in one place when the form schema changes. + */ +export function createFillBlogFormHandler( + formRef: RefObject | null>, + successMessage: string, +) { + return async ({ + title, + content, + excerpt, + tags, + }: { + title?: string; + content?: string; + excerpt?: string; + tags?: string[]; + }) => { + const form = formRef.current; + if (!form) return { success: false, message: "Form not ready" }; + if (title !== undefined) + form.setValue("title", title, { shouldValidate: true }); + if (content !== undefined) + form.setValue("content", content, { shouldValidate: true }); + if (excerpt !== undefined) form.setValue("excerpt", excerpt); + if (tags !== undefined) + form.setValue( + "tags", + tags.map((name: string) => ({ name })), + ); + return { success: true, message: successMessage }; + }; +} diff --git a/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.tsx b/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.tsx index 85b503fd..985b51e6 100644 --- a/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +++ b/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.tsx @@ -7,6 +7,10 @@ import { PageWrapper } from "../shared/page-wrapper"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_LOCALIZATION } from "../../localization"; import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; +import { useRef, useCallback } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { createFillBlogFormHandler } from "./fill-blog-form-handler"; // Internal component with actual page content export function NewPostPage() { @@ -35,6 +39,30 @@ export function NewPostPage() { }, }); + // Ref to capture the form instance from AddPostForm via onFormReady callback + const formRef = useRef | null>(null); + const handleFormReady = useCallback((form: UseFormReturn) => { + formRef.current = form; + }, []); + + // Register AI context so the chat can fill in the new post form + useRegisterPageAIContext({ + routeName: "blog-new-post", + pageDescription: + "User is creating a new blog post in the admin editor. IMPORTANT: When asked to write, draft, or create a blog post, you MUST call the fillBlogForm tool to populate the form fields directly — do NOT just output the text in your response.", + suggestions: [ + "Write a post about AI trends", + "Draft an intro paragraph", + "Suggest 5 tags for this post", + ], + clientTools: { + fillBlogForm: createFillBlogFormHandler( + formRef, + "Form filled successfully", + ), + }, + }); + const handleClose = () => { navigate(`${basePath}/blog`); }; @@ -54,7 +82,11 @@ export function NewPostPage() { title={localization.BLOG_POST_ADD_TITLE} description={localization.BLOG_POST_ADD_DESCRIPTION} /> - + ); } diff --git a/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx b/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx index f6fdffe3..387c1772 100644 --- a/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx +++ b/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx @@ -20,6 +20,7 @@ import { Badge } from "@workspace/ui/components/badge"; import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; import { OnThisPage, OnThisPageSelect } from "../shared/on-this-page"; import type { SerializedPost } from "../../../types"; +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; // Internal component with actual page content export function PostPage({ slug }: { slug: string }) { @@ -64,6 +65,25 @@ export function PostPage({ slug }: { slug: string }) { enabled: !!post, }); + // Register page AI context so the chat can summarize and discuss this post + useRegisterPageAIContext( + post + ? { + routeName: "blog-post", + pageDescription: + `Blog post: "${post.title}"\nAuthor: ${post.authorId ?? "Unknown"}\n\n${post.content ?? ""}`.slice( + 0, + 16000, + ), + suggestions: [ + "Summarize this post", + "What are the key takeaways?", + "Explain this in simpler terms", + ], + } + : null, + ); + if (!slug || !post) { return ( diff --git a/packages/stack/src/plugins/cms/api/index.ts b/packages/stack/src/plugins/cms/api/index.ts index dd5fc743..30426edb 100644 --- a/packages/stack/src/plugins/cms/api/index.ts +++ b/packages/stack/src/plugins/cms/api/index.ts @@ -12,4 +12,8 @@ export { serializeContentItem, serializeContentItemWithType, } from "./getters"; +export { + createCMSContentItem, + type CreateCMSContentItemInput, +} from "./mutations"; export { CMS_QUERY_KEYS } from "./query-key-defs"; diff --git a/packages/stack/src/plugins/cms/api/mutations.ts b/packages/stack/src/plugins/cms/api/mutations.ts new file mode 100644 index 00000000..69e15782 --- /dev/null +++ b/packages/stack/src/plugins/cms/api/mutations.ts @@ -0,0 +1,84 @@ +import type { Adapter } from "@btst/db"; +import type { ContentType, ContentItem } from "../types"; +import { serializeContentItem } from "./getters"; +import type { SerializedContentItem } from "../types"; + +/** + * Input for creating a new CMS content item. + */ +export interface CreateCMSContentItemInput { + /** URL-safe slug for the item */ + slug: string; + /** Arbitrary data payload — should match the content type schema */ + data: Record; +} + +/** + * Create a new content item for a content type (looked up by slug). + * + * Bypasses Zod schema validation and relation processing — the caller is + * responsible for providing valid, relation-free data. For relation fields or + * schema validation, use the HTTP endpoint instead. + * + * Throws if the content type is not found or the slug is already taken within + * that content type. + * + * @remarks **Security:** No authorization hooks (`onBeforeCreate`, `onAfterCreate`) + * are called. The caller is responsible for any access-control checks before + * invoking this function. + * + * @param adapter - The database adapter + * @param contentTypeSlug - Slug of the target content type + * @param input - Item slug and data payload + */ +export async function createCMSContentItem( + adapter: Adapter, + contentTypeSlug: string, + input: CreateCMSContentItemInput, +): Promise { + const contentType = await adapter.findOne({ + model: "contentType", + where: [ + { + field: "slug", + value: contentTypeSlug, + operator: "eq" as const, + }, + ], + }); + + if (!contentType) { + throw new Error(`Content type "${contentTypeSlug}" not found`); + } + + const existing = await adapter.findOne({ + model: "contentItem", + where: [ + { + field: "contentTypeId", + value: contentType.id, + operator: "eq" as const, + }, + { field: "slug", value: input.slug, operator: "eq" as const }, + ], + }); + + if (existing) { + throw new Error( + `Content item with slug "${input.slug}" already exists in type "${contentTypeSlug}"`, + ); + } + + const item = await adapter.create({ + model: "contentItem", + data: { + contentTypeId: contentType.id, + slug: input.slug, + data: JSON.stringify(input.data), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + return serializeContentItem(item); +} diff --git a/packages/stack/src/plugins/cms/api/plugin.ts b/packages/stack/src/plugins/cms/api/plugin.ts index 5d09a4a4..91f92e6d 100644 --- a/packages/stack/src/plugins/cms/api/plugin.ts +++ b/packages/stack/src/plugins/cms/api/plugin.ts @@ -30,6 +30,7 @@ import { serializeContentItem, serializeContentItemWithType, } from "./getters"; +import { createCMSContentItem } from "./mutations"; import type { QueryClient } from "@tanstack/react-query"; import { CMS_QUERY_KEYS } from "./query-key-defs"; @@ -569,6 +570,14 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => { return getContentItemById(adapter, id); }, prefetchForRoute: createCMSPrefetchForRoute(adapter), + // Mutations + createContentItem: async ( + typeSlug: string, + input: Parameters[2], + ) => { + await ensureSynced(adapter); + return createCMSContentItem(adapter, typeSlug, input); + }, }), routes: (adapter: Adapter) => { diff --git a/packages/stack/src/plugins/kanban/api/index.ts b/packages/stack/src/plugins/kanban/api/index.ts index 0dbfa8d6..726b3fd2 100644 --- a/packages/stack/src/plugins/kanban/api/index.ts +++ b/packages/stack/src/plugins/kanban/api/index.ts @@ -6,5 +6,11 @@ export { type KanbanBackendHooks, } from "./plugin"; export { getAllBoards, getBoardById, type BoardListResult } from "./getters"; +export { + createKanbanTask, + findOrCreateKanbanBoard, + getKanbanColumnsByBoardId, + type CreateKanbanTaskInput, +} from "./mutations"; export { serializeBoard, serializeColumn, serializeTask } from "./serializers"; export { KANBAN_QUERY_KEYS } from "./query-key-defs"; diff --git a/packages/stack/src/plugins/kanban/api/mutations.ts b/packages/stack/src/plugins/kanban/api/mutations.ts new file mode 100644 index 00000000..e0076f0a --- /dev/null +++ b/packages/stack/src/plugins/kanban/api/mutations.ts @@ -0,0 +1,134 @@ +import type { Adapter } from "@btst/db"; +import type { Board, Column, Task, Priority } from "../types"; + +/** + * Input for creating a new Kanban task. + */ +export interface CreateKanbanTaskInput { + title: string; + columnId: string; + description?: string; + priority?: Priority; + assigneeId?: string; +} + +/** + * Create a new task in a Kanban column. + * Computes the next order value from existing tasks in the column. + * + * @remarks **Security:** No authorization hooks (onBeforeCreateTask) are called. + * The caller is responsible for any access-control checks before invoking this + * function. + * + * @param adapter - The database adapter + * @param input - Task creation input + */ +export async function createKanbanTask( + adapter: Adapter, + input: CreateKanbanTaskInput, +): Promise { + const existingTasks = await adapter.findMany({ + model: "kanbanTask", + where: [ + { + field: "columnId", + value: input.columnId, + operator: "eq" as const, + }, + ], + }); + + const nextOrder = + existingTasks.length > 0 + ? Math.max(...existingTasks.map((t) => t.order)) + 1 + : 0; + + return adapter.create({ + model: "kanbanTask", + data: { + title: input.title, + columnId: input.columnId, + description: input.description, + priority: input.priority ?? "MEDIUM", + order: nextOrder, + assigneeId: input.assigneeId, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +/** + * Find a board by slug, or create it with the given name and custom column titles. + * Safe to call concurrently — uses a find-first pattern before creating. + * + * @remarks **Security:** No authorization hooks are called. The caller is + * responsible for any access-control checks before invoking this function. + * + * @param adapter - The database adapter + * @param slug - Unique URL-safe slug for the board + * @param name - Display name for the board (used only on creation) + * @param columnTitles - Ordered list of column names to create (used only on creation) + */ +export async function findOrCreateKanbanBoard( + adapter: Adapter, + slug: string, + name: string, + columnTitles: string[], +): Promise { + const existing = await adapter.findOne({ + model: "kanbanBoard", + where: [{ field: "slug", value: slug, operator: "eq" as const }], + }); + + if (existing) return existing; + + const board = await adapter.create({ + model: "kanbanBoard", + data: { + name, + slug, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + await Promise.all( + columnTitles.map((title, index) => + adapter.create({ + model: "kanbanColumn", + data: { + title, + boardId: board.id, + order: index, + createdAt: new Date(), + updatedAt: new Date(), + }, + }), + ), + ); + + return board; +} + +/** + * Retrieve all columns for a given board, sorted by order. + * Co-located with mutations because it is primarily used alongside + * {@link createKanbanTask} to resolve column IDs before task creation. + * + * @remarks **Security:** No authorization hooks are called. + * + * @param adapter - The database adapter + * @param boardId - The board ID + */ +export async function getKanbanColumnsByBoardId( + adapter: Adapter, + boardId: string, +): Promise { + return adapter.findMany({ + model: "kanbanColumn", + where: [{ field: "boardId", value: boardId, operator: "eq" as const }], + sortBy: { field: "order", direction: "asc" }, + }); +} diff --git a/packages/stack/src/plugins/kanban/api/plugin.ts b/packages/stack/src/plugins/kanban/api/plugin.ts index 6a9f962e..19fe04f2 100644 --- a/packages/stack/src/plugins/kanban/api/plugin.ts +++ b/packages/stack/src/plugins/kanban/api/plugin.ts @@ -24,6 +24,11 @@ import { updateTaskSchema, } from "../schemas"; import { getAllBoards, getBoardById } from "./getters"; +import { + createKanbanTask, + findOrCreateKanbanBoard, + getKanbanColumnsByBoardId, +} from "./mutations"; import { KANBAN_QUERY_KEYS } from "./query-key-defs"; import { serializeBoard } from "./serializers"; import type { QueryClient } from "@tanstack/react-query"; @@ -317,6 +322,13 @@ export const kanbanBackendPlugin = (hooks?: KanbanBackendHooks) => getAllBoards(adapter, params), getBoardById: (id: string) => getBoardById(adapter, id), prefetchForRoute: createKanbanPrefetchForRoute(adapter), + // Mutations + createTask: (input: Parameters[1]) => + createKanbanTask(adapter, input), + findOrCreateBoard: (slug: string, name: string, columnTitles: string[]) => + findOrCreateKanbanBoard(adapter, slug, name, columnTitles), + getColumnsByBoardId: (boardId: string) => + getKanbanColumnsByBoardId(adapter, boardId), }), routes: (adapter: Adapter) => { diff --git a/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx b/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx index 84e03add..f26857ac 100644 --- a/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +++ b/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx @@ -22,9 +22,12 @@ import { toast } from "sonner"; import UIBuilder from "@workspace/ui/components/ui-builder"; import type { ComponentLayer, + ComponentRegistry, Variable, } from "@workspace/ui/components/ui-builder/types"; +import { useLayerStore } from "@workspace/ui/lib/ui-builder/store/layer-store"; +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; import { useSuspenseUIBuilderPage, useCreateUIBuilderPage, @@ -39,6 +42,104 @@ export interface PageBuilderPageProps { id?: string; } +/** + * Generate a concise AI-readable description of the available components + * in the component registry, including their prop names. + */ +function buildRegistryDescription(registry: ComponentRegistry): string { + const lines: string[] = []; + for (const [name, entry] of Object.entries(registry) as [ + string, + { schema?: unknown }, + ][]) { + let propsLine = ""; + try { + const shape = (entry.schema as any)?.shape as + | Record + | undefined; + if (shape) { + const fields = Object.keys(shape).join(", "); + propsLine = ` — props: ${fields}`; + } + } catch { + // ignore schema introspection errors + } + lines.push(`- ${name}${propsLine}`); + } + return lines.join("\n"); +} + +/** + * Build the full page description string for the AI context. + * Stays within the 8,000-character pageContext limit. + */ +function buildPageDescription( + id: string | undefined, + slug: string, + layers: ComponentLayer[], + registry: ComponentRegistry, +): string { + const header = id + ? `UI Builder — editing page (slug: "${slug}")` + : "UI Builder — creating new page"; + + const layersJson = JSON.stringify(layers, null, 2); + + const registryDesc = buildRegistryDescription(registry); + + const layerFormat = `Each layer: { id: string, type: string, name: string, props: Record, children?: ComponentLayer[] | string }`; + + const full = [ + header, + "", + `## Current Layers (${layers.length})`, + layersJson, + "", + `## Available Component Types`, + registryDesc, + "", + `## ComponentLayer format`, + layerFormat, + ].join("\n"); + + // Trim to fit the 16,000-char server-side limit, cutting the layers JSON if needed + if (full.length <= 16000) return full; + + // Re-build with truncated layers JSON + const overhead = + [ + header, + "", + `## Current Layers (${layers.length})`, + "", + "", + `## Available Component Types`, + registryDesc, + "", + `## ComponentLayer format`, + layerFormat, + ].join("\n").length + 30; // 30-char buffer for "...(truncated)" + + const budget = Math.max(0, 16000 - overhead); + const truncatedLayers = + layersJson.length > budget + ? layersJson.slice(0, budget) + "\n...(truncated)" + : layersJson; + + return [ + header, + "", + `## Current Layers (${layers.length})`, + truncatedLayers, + "", + `## Available Component Types`, + registryDesc, + "", + `## ComponentLayer format`, + layerFormat, + ].join("\n"); +} + /** * Slugify a string for URL-friendly slugs */ @@ -139,6 +240,37 @@ function PageBuilderPageContent({ // Auto-generate slug from first page name const [autoSlug, setAutoSlug] = useState(!id); + // Register AI context so the chat can update the page layout + useRegisterPageAIContext({ + routeName: id ? "ui-builder-edit-page" : "ui-builder-new-page", + pageDescription: buildPageDescription(id, slug, layers, componentRegistry), + suggestions: [ + "Add a hero section", + "Add a 3-column feature grid", + "Make the layout full-width", + "Add a card with a title, description, and button", + "Replace the layout with a centered single-column design", + ], + clientTools: { + updatePageLayers: async ({ layers: newLayers }) => { + // Drive the UIBuilder's Zustand store directly so the editor + // and layers panel update immediately. The store's onChange + // callback will propagate back to the parent's `layers` state. + const store = useLayerStore.getState(); + store.initialize( + newLayers, + store.selectedPageId || newLayers[0]?.id, + undefined, + store.variables, + ); + return { + success: true, + message: `Applied ${newLayers.length} layer(s) to the page`, + }; + }, + }, + }); + // Handle layers change from UIBuilder const handleLayersChange = useCallback( (newLayers: ComponentLayer[]) => {