diff --git a/AGENTS.md b/AGENTS.md index 7ad38f5e..7a8eb482 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -426,7 +426,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 +657,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. 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/development.mdx b/docs/content/docs/plugins/development.mdx index c0e5a285..d65174c9 100644 --- a/docs/content/docs/plugins/development.mdx +++ b/docs/content/docs/plugins/development.mdx @@ -1202,6 +1202,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/e2e/playwright.config.ts b/e2e/playwright.config.ts index f040d9f4..9d6172ad 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -99,6 +99,7 @@ export default defineConfig({ "**/*.ui-builder.spec.ts", "**/*.kanban.spec.ts", "**/*.ssg.spec.ts", + "**/*.page-context.spec.ts", ], }, { @@ -112,6 +113,7 @@ export default defineConfig({ "**/*.cms.spec.ts", "**/*.relations-cms.spec.ts", "**/*.form-builder.spec.ts", + "**/*.page-context.spec.ts", ], }, { @@ -125,6 +127,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/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..868f19fd 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/stack.ts b/examples/nextjs/lib/stack.ts index c7341ecd..0e988743 100644 --- a/examples/nextjs/lib/stack.ts +++ b/examples/nextjs/lib/stack.ts @@ -126,6 +126,8 @@ function createStack() { tools: { stackDocs: stackDocsTool, }, + // 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'); 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..1b4b6d79 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..e55b0331 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..a0cdebd9 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,112 @@ 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 */ + widgetHeight?: 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 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 +122,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 +233,7 @@ export function ChatLayout({ {/* Main Chat Area */}
{/* Header */} -
+
{/* Mobile menu button */} {showSidebar && ( @@ -159,6 +277,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(); + + useEffect(() => { + if (!ctx || !config) return; + ctx.register(id, config); + return () => { + ctx.unregister(id); + }; + // Stringify to deep-compare config without referential instability + // (inline objects and functions are recreated on every render). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ctx, id, JSON.stringify(config)]); +} + +/** + * 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/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[]) => {