From e332e4e1a88cb2de27c345f420d018251b8f30bf Mon Sep 17 00:00:00 2001 From: James Borlase Date: Mon, 14 Sep 2026 17:39:00 +0100 Subject: [PATCH] Add the Snowplow Assistant chat drawer Adds an "Ask AI" button beside the search box that opens a resizable right-side drawer answering questions from the documentation. The chat is built on the Vercel AI SDK (useChat) with AI Elements components vendored from the Console and ported to Tailwind 3.4, rendering answers with streamdown. Internal links open in the same tab and the drawer survives navigation; a single conversation is kept in sessionStorage. The widget posts to the same-origin /api/assistant/chat, served by a new Cloudflare Worker handler (worker/assistant.js) that checks method and body size, applies a 10-requests-per-minute per-IP rate limit, adds the shared secret header and forwards to the console-agent docs route. The secret is a Worker secret; nothing sensitive ships in the browser bundle. Also: Tailwind 3.3 -> 3.4, a dev-server proxy plugin for local testing, Snowplow assistant_interaction events matching the Console, the prompt form excluded from form tracking, and ARCHITECTURE, WORKFLOWS and CONTRIBUTING updated. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 5 + ARCHITECTURE.md | 25 +- CONTRIBUTING.md | 13 + WORKFLOWS.md | 4 +- docusaurus.config.ts | 4 + package.json | 14 +- .../index.js | 32 + snowplow.js | 10 +- src/components/Assistant/AskAiNavbarItem.tsx | 28 + src/components/Assistant/AssistantChat.tsx | 245 +++ src/components/Assistant/AssistantContext.tsx | 62 + src/components/Assistant/AssistantDrawer.tsx | 140 ++ src/components/Assistant/AssistantHost.tsx | 19 + src/components/Assistant/AssistantLink.tsx | 59 + src/components/Assistant/MessageParts.tsx | 84 + src/components/Assistant/ToolActivity.tsx | 111 + src/components/Assistant/assistantConfig.ts | 13 + .../Assistant/conversationStorage.ts | 57 + src/components/Assistant/starterPrompts.ts | 23 + src/components/Assistant/tracking.ts | 70 + src/components/Assistant/useAssistantChat.ts | 109 + src/components/Assistant/usePageContext.ts | 42 + src/components/Assistant/useResizableWidth.ts | 109 + src/components/ai-elements/conversation.tsx | 101 + src/components/ai-elements/message.tsx | 63 + src/components/ai-elements/prompt-input.tsx | 182 ++ src/components/ai-elements/shimmer.tsx | 84 + src/components/ai-elements/sources.tsx | 76 + src/components/ai-elements/suggestion.tsx | 54 + src/components/ai-elements/tool.tsx | 78 + src/components/ui/button.tsx | 1 + src/components/ui/collapsible.tsx | 7 + src/components/ui/textarea.tsx | 20 + src/css/custom.css | 144 +- src/theme/Root.js | 9 +- src/theme/SearchBar/index.js | 20 +- tailwind.config.js | 6 +- worker/assistant.js | 73 + worker/index.js | 7 + wrangler.jsonc | 10 + yarn.lock | 1878 +++++++++++++++-- 41 files changed, 3871 insertions(+), 220 deletions(-) create mode 100644 plugins/docusaurus-plugin-assistant-dev-proxy/index.js create mode 100644 src/components/Assistant/AskAiNavbarItem.tsx create mode 100644 src/components/Assistant/AssistantChat.tsx create mode 100644 src/components/Assistant/AssistantContext.tsx create mode 100644 src/components/Assistant/AssistantDrawer.tsx create mode 100644 src/components/Assistant/AssistantHost.tsx create mode 100644 src/components/Assistant/AssistantLink.tsx create mode 100644 src/components/Assistant/MessageParts.tsx create mode 100644 src/components/Assistant/ToolActivity.tsx create mode 100644 src/components/Assistant/assistantConfig.ts create mode 100644 src/components/Assistant/conversationStorage.ts create mode 100644 src/components/Assistant/starterPrompts.ts create mode 100644 src/components/Assistant/tracking.ts create mode 100644 src/components/Assistant/useAssistantChat.ts create mode 100644 src/components/Assistant/usePageContext.ts create mode 100644 src/components/Assistant/useResizableWidth.ts create mode 100644 src/components/ai-elements/conversation.tsx create mode 100644 src/components/ai-elements/message.tsx create mode 100644 src/components/ai-elements/prompt-input.tsx create mode 100644 src/components/ai-elements/shimmer.tsx create mode 100644 src/components/ai-elements/sources.tsx create mode 100644 src/components/ai-elements/suggestion.tsx create mode 100644 src/components/ai-elements/tool.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 worker/assistant.js diff --git a/.gitignore b/.gitignore index d8cd24f65..fa4235f51 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ .env.development.local .env.test.local .env.production.local +.dev.vars +.dev.vars.* npm-debug.log* yarn-debug.log* @@ -43,3 +45,6 @@ manifests # Local Netlify folder .netlify .claude + +# Wrangler local state +.wrangler diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2d009a095..778d9eb8d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,6 +5,7 @@ An overview of how the docs site is built. For writing guidelines see [`CONTRIBU - [Overview](#overview) - [Gotchas](#gotchas) - [Cloudflare Worker](#cloudflare-worker) +- [Snowplow Assistant](#snowplow-assistant) - [Custom plugins](#custom-plugins) - [LLMs.txt and Markdown generation](#llmstxt-and-markdown-generation) - [JSON-LD schema](#json-ld-schema) @@ -20,7 +21,7 @@ An overview of how the docs site is built. For writing guidelines see [`CONTRIBU ## Overview -This is a Docusaurus project, deployed on Cloudflare Pages. The Docusaurus configuration is in `docusaurus.config.js`. Search is provided by [Algolia DocSearch](https://docsearch.algolia.com). +This is a Docusaurus project, deployed as a Cloudflare Worker with static assets. The Docusaurus configuration is in `docusaurus.config.js`. Search is provided by [Algolia DocSearch](https://docsearch.algolia.com). Pages are MDX under the hood, but mostly carry `.md` extensions for Docusaurus legacy reasons. @@ -54,13 +55,26 @@ Non-obvious things that could cause confusion if you don't know about them: ## Cloudflare Worker -`worker/index.js` runs on every request and does three things: -1. **Server-side Snowplow tracking** -2. **Forced redirects** via `findForcedRedirect(pathname)`, checked before asset fetch, returns 301 on match -3. **Fallback redirects** via `findFallbackRedirect(pathname)`, checked only after a 404 +`worker/index.js` runs on every request and does four things: +1. **Assistant API proxy** for `/api/*` requests, handled first so they never fire a server-side page view (see [Snowplow Assistant](#snowplow-assistant)) +2. **Server-side Snowplow tracking** +3. **Forced redirects** via `findForcedRedirect(pathname)`, checked before asset fetch, returns 301 on match +4. **Fallback redirects** via `findFallbackRedirect(pathname)`, checked only after a 404 Both redirect tiers are defined in `worker/redirects.js`. `move.sh` appends to it automatically. +## Snowplow Assistant + +The "Ask AI" button in the navbar opens a chat drawer that answers questions from the documentation. It is the Snowplow Console's assistant (the `console-agent` service) running in a documentation-only mode: the agent only has the two documentation tools, and the current-version `llms.txt` index is loaded into its prompt up front so it can pick pages without an extra round trip. + +**Request path.** The widget posts to the same-origin `POST /api/assistant/chat`. [`worker/assistant.js`](worker/assistant.js) checks the method and body size, applies a per-IP rate limit (the `ASSISTANT_RATE_LIMITER` binding in `wrangler.jsonc`, 10 requests per minute), then forwards the body to `${DOCS_ASSISTANT_AGENT_URL}/api/agent/docs/chat` with the `X-Docs-Assistant-Secret` header and streams the response back unchanged. The browser never talks to the agent directly and never sees the secret. JSON error bodies carry a `status` field so the widget can show a rate-limit countdown or a size message. + +**Configuration.** `DOCS_ASSISTANT_AGENT_URL` is a plain var in `wrangler.jsonc`. `DOCS_ASSISTANT_SHARED_SECRET` is a Worker secret set in the Cloudflare dashboard (or `npx wrangler secret put DOCS_ASSISTANT_SHARED_SECRET`); it must match the agent's `DOCS_ASSISTANT_SHARED_SECRET`. Without both the Worker answers 503. For local development see [Run the assistant locally](CONTRIBUTING.md#run-the-assistant-locally). + +**Frontend.** `src/components/Assistant/` holds the widget: `AssistantProvider` and `AssistantHost` are mounted in `src/theme/Root.js` (above the per-route layout, so an open conversation survives navigating to a linked page), `AskAiNavbarItem` is registered as the `custom-askAi` navbar item, and `AssistantDrawer` is lazy-loaded on first open so docs pages do not download the chat bundle. The chat is built on the Vercel AI SDK (`useChat` + `DefaultChatTransport`) and AI Elements components vendored into `src/components/ai-elements/` and ported to Tailwind 3; markdown answers render with `streamdown`. Internal links open in the same tab through Docusaurus routing. A single conversation is kept in `sessionStorage`. + +**Analytics.** The widget emits `assistant_interaction` events (`open`, `close`, `message_submit`, `suggestion_click`) using the same schema and event specifications as the Console. The prompt form carries the `sp-assistant-form` class, which `snowplow.js` excludes from form tracking so questions are never sent as form payloads. + ## Custom plugins Live under `plugins/`. @@ -105,6 +119,7 @@ The repo has multiple tracking implementations. Each tracking script manages its | `reoTracking.js` | [Reo.dev](https://reo.dev) tracker | Loaded unconditionally | N/A | N/A | | `src/qualified.js` | [Qualified](https://www.qualified.com) chat/conversion tracking | Loaded unconditionally | N/A | N/A | | `worker/index.js` | Page view tracking for `.md` and `llms.txt` requests | Anonymous tracking | N/A | N/A | +| `src/components/Assistant/tracking.ts` | `assistant_interaction` events from the AI assistant drawer | Follows `snowplow.js` consent state | N/A | N/A | ## Styling and CSS diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 998dd4969..346fac9bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -314,6 +314,19 @@ Release notes live in `release-notes//index.md`. Read [`release-notes/_REA Notes carry a slug that becomes the published URL, so keep it short and don't change it after the note ships. Link out to the docs with absolute paths such as `/docs/signals/`; the build fails on a broken link, which is how a moved docs page gets caught. +## Run the assistant locally + +The "Ask AI" drawer calls `/api/assistant/chat`, which only exists when the Cloudflare Worker is in front of the site. To try it locally you need the `console-agent` service running (`npm run dev` in that repo, port 3001) and the Worker: + +1. Create `.dev.vars` in this repo (it is gitignored): + ``` + DOCS_ASSISTANT_AGENT_URL=http://localhost:3001 + DOCS_ASSISTANT_SHARED_SECRET= + ``` +2. Either build the site and serve it through the Worker with `yarn build && npx wrangler dev` (everything on `http://localhost:8787`), or keep the hot-reloading dev server and proxy only the assistant calls to the Worker: run `npx wrangler dev` in one terminal and `ASSISTANT_PROXY_TARGET=http://localhost:8787 yarn start` in another. + +Without the Worker the drawer still opens, but sending a message shows "The assistant is unavailable right now". + ## Submit changes Before opening a PR, run `yarn build` locally. This runs the full production build, and catches errors, broken internal links, and broken anchors before they get to CI. diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 3bd8439ca..048e4c511 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -8,12 +8,14 @@ CI and deployment. For writing guidelines see [`CONTRIBUTING.md`](CONTRIBUTING.m ## Build and deploy -The site is deployed by **Cloudflare Pages** on push to `main`. Cloudflare runs the `yarn build:cf` script, which: +The site is deployed as a **Cloudflare Worker with static assets** (see `wrangler.jsonc`) on push to `main`, through Cloudflare's git integration. Cloudflare runs the `yarn build:cf` script, which: 1. Sets `NODE_OPTIONS='--max-old-space-size=4096'` (the build needs more than the default heap). 2. Runs `docusaurus build`. 3. Deletes `build/_redirects` — this is the file Docusaurus auto-generates from any installed redirect plugin. Removing it means nothing leaks into the deployed bundle. All redirects live in the [Cloudflare Worker](ARCHITECTURE.md#cloudflare-worker) instead. +The Worker needs one secret, `DOCS_ASSISTANT_SHARED_SECRET`, for the [Snowplow Assistant](ARCHITECTURE.md#snowplow-assistant) proxy. Set it once in the Cloudflare dashboard (Workers & Pages → documentation → Settings → Variables and Secrets) or with `npx wrangler secret put DOCS_ASSISTANT_SHARED_SECRET`. The agent URL is a plain var in `wrangler.jsonc`. Preview deployments share the same secret and var, so they talk to the same agent as production. + Other `package.json` scripts: - `yarn start`: dev server. Does not run the broken-link check. diff --git a/docusaurus.config.ts b/docusaurus.config.ts index ea6b0069c..b8985460d 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -116,6 +116,7 @@ const config: Config = { ], './plugins/docusaurus-plugin-release-notes', './plugins/docusaurus-plugin-snowplow-schema', + './plugins/docusaurus-plugin-assistant-dev-proxy', [ './plugins/docusaurus-plugin-llms-txt', { @@ -304,6 +305,9 @@ const config: Config = { ], customFields: { + // Where the Snowplow Assistant widget sends chat requests. Same-origin by + // default (served by the Cloudflare Worker); override for local testing. + assistantApiUrl: process.env.ASSISTANT_API_URL ?? '/api/assistant/chat', webpack: { configure: (config) => { // Add JSX runtime resolution diff --git a/package.json b/package.json index db6d01d96..1326a95dd 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "format": "prettier --write ." }, "dependencies": { + "@ai-sdk/react": "^4", "@braintree/sanitize-url": "^6.0.1", "@docusaurus/core": "^3.10.0", "@docusaurus/faster": "^3.10.0", @@ -36,6 +37,7 @@ "@mui/x-data-grid-premium": "6.20.4", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.7", @@ -52,6 +54,8 @@ "@snowplow/browser-plugin-link-click-tracking": "^4.7.0", "@snowplow/browser-plugin-media": "^4.7.0", "@snowplow/browser-tracker": "^4.7.0", + "@streamdown/code": "^1", + "ai": "^7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3-color": "^3.1.0", @@ -78,13 +82,15 @@ "remark-gfm": "^4.0.1", "remark-math": "3", "semver": "^7.3.8", + "streamdown": "^2", "tailwind-merge": "^3.0.2", "ua-parser-js": "^0.7.33", "unist-util-flatmap": "^1.0.0", "url": "^0.11.0", + "use-stick-to-bottom": "^1", "uuid": "^10.0.0", "webpack": "^5.76.0", - "zod": "^3.23.8" + "zod": "^3.25.76" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.10.0", @@ -109,10 +115,12 @@ "rehype-remark": "^10.0.1", "remark-stringify": "^11.0.0", "style-loader": "3.3.3", - "tailwindcss": "3.3.0", + "tailwindcss": "^3.4.19", + "tailwindcss-animate": "^1.0.7", "typescript": "^5.5.4", "unified": "^11.0.5", - "unist-util-visit": "^5.1.0" + "unist-util-visit": "^5.1.0", + "wrangler": "^4.36.0" }, "browserslist": { "production": [ diff --git a/plugins/docusaurus-plugin-assistant-dev-proxy/index.js b/plugins/docusaurus-plugin-assistant-dev-proxy/index.js new file mode 100644 index 000000000..83d35c48f --- /dev/null +++ b/plugins/docusaurus-plugin-assistant-dev-proxy/index.js @@ -0,0 +1,32 @@ +/** + * Dev-only proxy for the Snowplow Assistant widget. + * + * In production the Cloudflare Worker serves `/api/assistant/*` on the same + * origin as the site. `docusaurus start` has no Worker, so when + * `ASSISTANT_PROXY_TARGET` is set (for example `http://localhost:8787` from + * `wrangler dev`) the dev server forwards `/api/assistant` requests there. + * Without the variable the plugin does nothing. + */ +module.exports = function assistantDevProxyPlugin() { + return { + name: 'docusaurus-plugin-assistant-dev-proxy', + configureWebpack() { + const target = process.env.ASSISTANT_PROXY_TARGET + if (!target) { + return {} + } + return { + devServer: { + proxy: [ + { + context: ['/api/assistant'], + target, + changeOrigin: true, + secure: false, + }, + ], + }, + } + }, + } +} diff --git a/snowplow.js b/snowplow.js index 8b7c666bc..daae0d42e 100644 --- a/snowplow.js +++ b/snowplow.js @@ -104,7 +104,15 @@ const setupBrowserTracker = () => { }) // precise tracking for the unified log enableButtonClickTracking() - enableFormTracking() + // The assistant's prompt form is excluded so user questions are never sent + // as form-tracking payloads. + enableFormTracking({ + options: { + forms: { + filter: (form) => !form.classList.contains('sp-assistant-form'), + }, + }, + }) } if (ExecutionEnvironment.canUseDOM) { diff --git a/src/components/Assistant/AskAiNavbarItem.tsx b/src/components/Assistant/AskAiNavbarItem.tsx new file mode 100644 index 000000000..b76fb2500 --- /dev/null +++ b/src/components/Assistant/AskAiNavbarItem.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { SparklesIcon } from 'lucide-react' +import { + prefetchAssistantDrawer, + useOptionalAssistant, +} from './AssistantContext' + +export default function AskAiNavbarItem() { + const assistant = useOptionalAssistant() + if (!assistant) return null + + return ( + + ) +} diff --git a/src/components/Assistant/AssistantChat.tsx b/src/components/Assistant/AssistantChat.tsx new file mode 100644 index 000000000..43ef02234 --- /dev/null +++ b/src/components/Assistant/AssistantChat.tsx @@ -0,0 +1,245 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { SparklesIcon } from 'lucide-react' +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from '@site/src/components/ai-elements/conversation' +import { + Message, + MessageContent, +} from '@site/src/components/ai-elements/message' +import { + PromptInput, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, +} from '@site/src/components/ai-elements/prompt-input' +import { Shimmer } from '@site/src/components/ai-elements/shimmer' +import { + Suggestion, + Suggestions, +} from '@site/src/components/ai-elements/suggestion' +import { Button } from '@site/src/components/ui/button' +import { MessageParts } from './MessageParts' +import { STARTER_PROMPTS } from './starterPrompts' +import { trackPromptSubmitted, trackSuggestionClicked } from './tracking' +import type { AssistantChatState } from './useAssistantChat' + +type ParsedError = { + status?: number + message: string + retryAfter?: number +} + +const parseError = (error: Error): ParsedError => { + try { + const parsed: unknown = JSON.parse(error.message) + if (typeof parsed === 'object' && parsed !== null) { + const body = parsed as { + error?: unknown + status?: unknown + retryAfter?: unknown + } + return { + message: + typeof body.error === 'string' ? body.error : 'Something went wrong.', + ...(typeof body.status === 'number' ? { status: body.status } : {}), + ...(typeof body.retryAfter === 'number' + ? { retryAfter: body.retryAfter } + : {}), + } + } + } catch { + // Not a JSON error body from the worker. + } + return { message: 'Something went wrong.' } +} + +const useCountdown = (seconds: number | undefined, onDone: () => void) => { + const [remaining, setRemaining] = useState(seconds ?? 0) + useEffect(() => { + if (seconds === undefined) return + setRemaining(seconds) + const timer = window.setInterval(() => { + setRemaining((current) => { + if (current <= 1) { + window.clearInterval(timer) + onDone() + return 0 + } + return current - 1 + }) + }, 1000) + return () => window.clearInterval(timer) + }, [seconds, onDone]) + return remaining +} + +const AssistantError = ({ + error, + onRetry, + onDismiss, +}: { + error: Error + onRetry: () => void + onDismiss: () => void +}) => { + const parsed = useMemo(() => parseError(error), [error]) + const remaining = useCountdown( + parsed.status === 429 ? parsed.retryAfter ?? 60 : undefined, + onDismiss + ) + + if (parsed.status === 429) { + return ( +
+ You've sent a lot of questions in a short time. You can ask again in{' '} + {remaining}s. +
+ ) + } + + const message = + parsed.status === 413 + ? 'That message is too long. Try a shorter question.' + : parsed.status === 503 + ? 'The assistant is unavailable right now. Please try again later.' + : 'Something went wrong.' + + return ( +
+ {message} +
+ + +
+
+ ) +} + +export const AssistantChat = ({ chat }: { chat: AssistantChatState }) => { + const { + conversationId, + messages, + sendMessage, + status, + error, + regenerate, + stop, + clearError, + } = chat + const isBusy = status === 'submitted' || status === 'streaming' + const rateLimited = useMemo( + () => (error ? parseError(error).status === 429 : false), + [error] + ) + + const submit = (text: string) => { + if (status !== 'ready' || !text.trim()) return + trackPromptSubmitted(conversationId) + void sendMessage({ text }) + } + + return ( + <> + + + {messages.length === 0 ? ( +
+ } + title="Ask about Snowplow" + description="Answers are grounded in these docs and link to the pages they used." + /> + + {STARTER_PROMPTS.map((starter) => ( + { + trackSuggestionClicked(conversationId, starter.label) + submit(prompt) + }} + > + {starter.label} + + ))} + +
+ ) : ( + messages.map((message, index) => ( + + + + + + )) + )} + {status === 'submitted' && ( + + + + Thinking… + + + + )} + {error && ( + void regenerate()} + onDismiss={clearError} + /> + )} +
+ +
+ +
+ submit(message.text)} + > + { + if (e.key === 'Enter' && !e.shiftKey && status !== 'ready') { + e.preventDefault() + } + }} + /> + + + Enter to send, Shift+Enter for a new line + + + + +

+ AI-generated answers can be wrong. Check the linked documentation + before relying on them. +

+
+ + ) +} diff --git a/src/components/Assistant/AssistantContext.tsx b/src/components/Assistant/AssistantContext.tsx new file mode 100644 index 000000000..43ad650a6 --- /dev/null +++ b/src/components/Assistant/AssistantContext.tsx @@ -0,0 +1,62 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' + +type AssistantContextValue = { + isOpen: boolean + hasOpened: boolean + open: () => void + close: () => void + toggle: () => void +} + +const AssistantContext = createContext(null) + +export const AssistantProvider = ({ + children, +}: { + children: React.ReactNode +}) => { + const [isOpen, setIsOpen] = useState(false) + const [hasOpened, setHasOpened] = useState(false) + + const open = useCallback(() => { + setHasOpened(true) + setIsOpen(true) + }, []) + const close = useCallback(() => setIsOpen(false), []) + const toggle = useCallback(() => { + setHasOpened(true) + setIsOpen((current) => !current) + }, []) + + const value = useMemo( + () => ({ isOpen, hasOpened, open, close, toggle }), + [isOpen, hasOpened, open, close, toggle] + ) + + return ( + + {children} + + ) +} + +export const useAssistant = (): AssistantContextValue => { + const value = useContext(AssistantContext) + if (!value) { + throw new Error('useAssistant must be used within AssistantProvider') + } + return value +} + +export const useOptionalAssistant = (): AssistantContextValue | null => + useContext(AssistantContext) + +export const prefetchAssistantDrawer = () => { + void import('./AssistantDrawer') +} diff --git a/src/components/Assistant/AssistantDrawer.tsx b/src/components/Assistant/AssistantDrawer.tsx new file mode 100644 index 000000000..71cb2a8e8 --- /dev/null +++ b/src/components/Assistant/AssistantDrawer.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useRef } from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { useWindowSize } from '@docusaurus/theme-common' +import { SquarePenIcon, XIcon } from 'lucide-react' +import { Button } from '@site/src/components/ui/button' +import { cn } from '@site/src/lib/utils.js' +import { AssistantChat } from './AssistantChat' +import { useAssistant } from './AssistantContext' +import { trackAssistantClosed, trackAssistantOpened } from './tracking' +import { useAssistantChat } from './useAssistantChat' +import { useResizableWidth } from './useResizableWidth' + +const OPEN_CLASS = 'sp-assistant-open' + +export default function AssistantDrawer() { + const { isOpen, close } = useAssistant() + const chat = useAssistantChat() + const windowSize = useWindowSize() + const isMobile = windowSize === 'mobile' + const resize = useResizableWidth() + const previousOpen = useRef(false) + + useEffect(() => { + if (isOpen && !previousOpen.current) { + trackAssistantOpened(chat.conversationId) + } else if (!isOpen && previousOpen.current) { + trackAssistantClosed(chat.conversationId) + } + previousOpen.current = isOpen + }, [isOpen, chat.conversationId]) + + useEffect(() => { + document.documentElement.classList.toggle(OPEN_CLASS, isOpen) + return () => document.documentElement.classList.remove(OPEN_CLASS) + }, [isOpen]) + + const isBusy = chat.status === 'submitted' || chat.status === 'streaming' + + return ( + { + if (!open) close() + }} + modal={isMobile} + > + + {isMobile && ( + + )} + { + if (!isMobile) event.preventDefault() + }} + onOpenAutoFocus={(event) => { + event.preventDefault() + const content = event.currentTarget + if (content instanceof HTMLElement) { + content.querySelector('textarea')?.focus() + } + }} + > + {!isMobile && ( +
+ )} +
+
+ + Snowplow Assistant + + + Beta + +
+ + Ask questions about Snowplow and get answers grounded in the + documentation. + +
+ {chat.messages.length > 0 && ( + + )} + + + +
+
+ + + + + ) +} diff --git a/src/components/Assistant/AssistantHost.tsx b/src/components/Assistant/AssistantHost.tsx new file mode 100644 index 000000000..add48c74e --- /dev/null +++ b/src/components/Assistant/AssistantHost.tsx @@ -0,0 +1,19 @@ +import React, { Suspense, lazy } from 'react' +import BrowserOnly from '@docusaurus/BrowserOnly' +import { useAssistant } from './AssistantContext' + +const AssistantDrawer = lazy(() => import('./AssistantDrawer')) + +export default function AssistantHost() { + const { hasOpened } = useAssistant() + if (!hasOpened) return null + return ( + + {() => ( + + + + )} + + ) +} diff --git a/src/components/Assistant/AssistantLink.tsx b/src/components/Assistant/AssistantLink.tsx new file mode 100644 index 000000000..6a53bd301 --- /dev/null +++ b/src/components/Assistant/AssistantLink.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import Link from '@docusaurus/Link' + +const INTERNAL_HOSTS = new Set([ + 'docs.snowplow.io', + 'docs.snowplowanalytics.com', +]) +const INTERNAL_PREFIXES = ['/docs', '/tutorials', '/release-notes'] + +const toInternalPath = (href: string): string | null => { + if (href.startsWith('#')) return null + if (INTERNAL_PREFIXES.some((prefix) => href.startsWith(prefix))) { + return href.replace(/\.md(?=$|[#?])/, '/') + } + try { + const url = new URL(href) + const sameOrigin = + typeof window !== 'undefined' && url.origin === window.location.origin + if (sameOrigin || INTERNAL_HOSTS.has(url.hostname)) { + const path = `${url.pathname}${url.search}${url.hash}` + return INTERNAL_PREFIXES.some((prefix) => path.startsWith(prefix)) + ? path.replace(/\.md(?=$|[#?])/, '/') + : null + } + } catch { + return null + } + return null +} + +type AssistantLinkProps = React.AnchorHTMLAttributes & { + node?: unknown +} + +export const AssistantLink = ({ + href, + children, + node: _node, + target: _target, + rel: _rel, + ...props +}: AssistantLinkProps) => { + if (!href) { + return {children} + } + const internalPath = toInternalPath(href) + if (internalPath) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} diff --git a/src/components/Assistant/MessageParts.tsx b/src/components/Assistant/MessageParts.tsx new file mode 100644 index 000000000..ee9d4486e --- /dev/null +++ b/src/components/Assistant/MessageParts.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import type { UIMessage } from 'ai' +import { isToolUIPart } from 'ai' +import { MessageResponse } from '@site/src/components/ai-elements/message' +import { + Source, + Sources, + SourcesContent, + SourcesTrigger, +} from '@site/src/components/ai-elements/sources' +import { AssistantLink } from './AssistantLink' +import { ToolActivity } from './ToolActivity' + +type MessagePartsProps = { + message: UIMessage + isLastMessage: boolean + isStreaming: boolean +} + +const markdownComponents = { a: AssistantLink } + +export const MessageParts = ({ + message, + isLastMessage, + isStreaming, +}: MessagePartsProps) => { + const sourceParts = message.parts.filter((part) => part.type === 'source-url') + const lastPart = message.parts.at(-1) + const isTextStreaming = + isLastMessage && isStreaming && lastPart?.type === 'text' + + return ( + <> + {sourceParts.length > 0 && ( + + + + {sourceParts.map((part) => ( + + ))} + + + )} + + {message.parts.map((part, index) => { + const key = `${message.id}-${index}` + + if (part.type === 'text') { + if (message.role !== 'assistant') { + return ( + + {part.text} + + ) + } + return ( + + {part.text} + + ) + } + + if (isToolUIPart(part)) { + return + } + + return null + })} + + ) +} diff --git a/src/components/Assistant/ToolActivity.tsx b/src/components/Assistant/ToolActivity.tsx new file mode 100644 index 000000000..97895136b --- /dev/null +++ b/src/components/Assistant/ToolActivity.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import type { DynamicToolUIPart, ToolUIPart } from 'ai' +import { getToolName } from 'ai' +import { CircleCheckIcon, CircleXIcon, LoaderCircleIcon } from 'lucide-react' +import { + Tool, + ToolContent, + ToolHeader, +} from '@site/src/components/ai-elements/tool' +import { Shimmer } from '@site/src/components/ai-elements/shimmer' +import { AssistantLink } from './AssistantLink' + +type ToolPart = ToolUIPart | DynamicToolUIPart + +const DOCS_ORIGIN = 'https://docs.snowplow.io' + +const readPath = (input: unknown): string | undefined => { + if (typeof input !== 'object' || input === null) return undefined + const path = (input as { path?: unknown }).path + return typeof path === 'string' ? path : undefined +} + +const titleFromOutput = (output: unknown): string | undefined => { + if (typeof output !== 'string') return undefined + const heading = output.split('\n').find((line) => line.startsWith('# ')) + return heading?.slice(2).trim() || undefined +} + +const titleFromPath = (path: string | undefined): string | undefined => { + if (!path) return undefined + const segment = path + .replace(/\.md$/, '') + .split('/') + .filter((part) => part && part !== 'index') + .at(-1) + return segment + ? segment.replace(/-/g, ' ').replace(/^\w/, (c) => c.toUpperCase()) + : undefined +} + +const pageUrl = (path: string | undefined): string | undefined => + path ? `${DOCS_ORIGIN}${path.replace(/\.md$/, '/')}` : undefined + +export const ToolActivity = ({ part }: { part: ToolPart }) => { + const toolName = getToolName(part) + const isIndex = toolName === 'fetch_documentation_index' + const path = readPath(part.input) + const title = + (part.state === 'output-available' + ? titleFromOutput(part.output) + : undefined) ?? titleFromPath(path) + const url = pageUrl(path) + + if (part.state === 'input-streaming' || part.state === 'input-available') { + return ( +
+ + + {isIndex + ? 'Loading the documentation index…' + : title + ? `Searching documentation: ${title}…` + : 'Searching documentation…'} + +
+ ) + } + + if (part.state === 'output-error') { + return ( +
+ + + {isIndex + ? "Couldn't load the documentation index" + : "Couldn't load a documentation page"} + +
+ ) + } + + if (part.state === 'output-available') { + const label = isIndex + ? 'Loaded the documentation index' + : `Searched documentation: ${title ?? 'Snowplow docs'}` + if (!url || isIndex) { + return ( +
+ + {label} +
+ ) + } + return ( + + } + /> + + + {url} + + + + ) + } + + return null +} diff --git a/src/components/Assistant/assistantConfig.ts b/src/components/Assistant/assistantConfig.ts new file mode 100644 index 000000000..418b26cf8 --- /dev/null +++ b/src/components/Assistant/assistantConfig.ts @@ -0,0 +1,13 @@ +import useDocusaurusContext from '@docusaurus/useDocusaurusContext' + +export const DEFAULT_ASSISTANT_API_URL = '/api/assistant/chat' +export const CONVERSATION_STORAGE_KEY = 'snowplow-docs-assistant:v1' +export const MAX_STORED_BYTES = 200 * 1024 + +export const useAssistantApiUrl = (): string => { + const { siteConfig } = useDocusaurusContext() + const configured = siteConfig.customFields?.assistantApiUrl + return typeof configured === 'string' && configured !== '' + ? configured + : DEFAULT_ASSISTANT_API_URL +} diff --git a/src/components/Assistant/conversationStorage.ts b/src/components/Assistant/conversationStorage.ts new file mode 100644 index 000000000..33224f317 --- /dev/null +++ b/src/components/Assistant/conversationStorage.ts @@ -0,0 +1,57 @@ +import type { UIMessage } from 'ai' +import { CONVERSATION_STORAGE_KEY, MAX_STORED_BYTES } from './assistantConfig' + +export type StoredConversation = { + id: string + messages: UIMessage[] + updatedAt: number +} + +const isBrowser = () => typeof window !== 'undefined' + +export const loadConversation = (): StoredConversation | null => { + if (!isBrowser()) return null + try { + const raw = window.sessionStorage.getItem(CONVERSATION_STORAGE_KEY) + if (!raw) return null + const parsed: unknown = JSON.parse(raw) + if ( + typeof parsed === 'object' && + parsed !== null && + typeof (parsed as StoredConversation).id === 'string' && + Array.isArray((parsed as StoredConversation).messages) + ) { + return parsed as StoredConversation + } + return null + } catch { + return null + } +} + +const serialize = (conversation: StoredConversation): string => + JSON.stringify(conversation) + +export const saveConversation = (id: string, messages: UIMessage[]): void => { + if (!isBrowser()) return + let trimmed = [...messages] + let payload = serialize({ id, messages: trimmed, updatedAt: Date.now() }) + while (payload.length > MAX_STORED_BYTES && trimmed.length > 2) { + trimmed = trimmed.slice(2) + payload = serialize({ id, messages: trimmed, updatedAt: Date.now() }) + } + try { + window.sessionStorage.setItem(CONVERSATION_STORAGE_KEY, payload) + } catch { + // Quota exceeded or storage disabled: the conversation simply is not persisted. + } +} + +export const clearConversation = (): void => { + if (!isBrowser()) return + try { + window.sessionStorage.removeItem(CONVERSATION_STORAGE_KEY) + } catch { + // Ignore storage errors. + } +} diff --git a/src/components/Assistant/starterPrompts.ts b/src/components/Assistant/starterPrompts.ts new file mode 100644 index 000000000..4f6065b94 --- /dev/null +++ b/src/components/Assistant/starterPrompts.ts @@ -0,0 +1,23 @@ +export type StarterPrompt = { label: string; prompt: string } + +export const STARTER_PROMPTS: StarterPrompt[] = [ + { + label: 'Track a custom event in the browser', + prompt: + 'How do I track a custom self-describing event with the JavaScript tracker?', + }, + { + label: 'Events vs entities', + prompt: + 'What is the difference between an event and an entity in Snowplow?', + }, + { + label: 'Load data into Snowflake', + prompt: 'How do I set up the Snowflake loader?', + }, + { + label: 'Debug failed events', + prompt: + 'Why do events fail validation and how can I inspect failed events?', + }, +] diff --git a/src/components/Assistant/tracking.ts b/src/components/Assistant/tracking.ts new file mode 100644 index 000000000..0c771f7be --- /dev/null +++ b/src/components/Assistant/tracking.ts @@ -0,0 +1,70 @@ +import { trackSelfDescribingEvent } from '@snowplow/browser-tracker' + +const ASSISTANT_INTERACTION_SCHEMA = + 'iglu:com.snowplowanalytics.console/assistant_interaction/jsonschema/2-0-1' +const EVENT_SPECIFICATION_SCHEMA = + 'iglu:com.snowplowanalytics.snowplow/event_specification/jsonschema/1-0-4' +const DATA_PRODUCT = { + data_product_id: '980aa611-2b78-4b76-8a83-f6a6878c22b6', + data_product_name: 'Snowplow Assistant Engagement', +} + +type InteractionType = 'open' | 'close' | 'message_submit' | 'suggestion_click' + +type EventSpec = { id: string; version: number; name: string } + +const SPECS: Record = { + open: { + id: 'c867af2d-9225-4944-abd7-967cbb48af0d', + version: 2, + name: 'Assistant Opened', + }, + close: { + id: '9baa6f50-a34f-4bc5-8dbe-8eadba7c43da', + version: 2, + name: 'Assistant Closed', + }, + message_submit: { + id: '529ea7cc-856d-4565-82e4-62c9466b81a5', + version: 2, + name: 'Prompt Submitted', + }, + suggestion_click: { + id: '89be7f12-c617-45a8-ab03-d1ebf893380a', + version: 2, + name: 'Suggestion Clicked', + }, +} + +const track = ( + agentSessionId: string, + interactionType: InteractionType, + target?: string +) => { + try { + trackSelfDescribingEvent({ + event: { + schema: ASSISTANT_INTERACTION_SCHEMA, + data: { + agent_session_id: agentSessionId, + interaction_type: interactionType, + ...(target !== undefined ? { target } : {}), + }, + }, + context: [ + { + schema: EVENT_SPECIFICATION_SCHEMA, + data: { ...SPECS[interactionType], ...DATA_PRODUCT }, + }, + ], + }) + } catch { + // Tracking must never break the assistant. + } +} + +export const trackAssistantOpened = (id: string) => track(id, 'open') +export const trackAssistantClosed = (id: string) => track(id, 'close') +export const trackPromptSubmitted = (id: string) => track(id, 'message_submit') +export const trackSuggestionClicked = (id: string, label: string) => + track(id, 'suggestion_click', label) diff --git a/src/components/Assistant/useAssistantChat.ts b/src/components/Assistant/useAssistantChat.ts new file mode 100644 index 000000000..43a7ec660 --- /dev/null +++ b/src/components/Assistant/useAssistantChat.ts @@ -0,0 +1,109 @@ +import { useChat } from '@ai-sdk/react' +import type { UIMessage } from 'ai' +import { DefaultChatTransport, generateId, isToolUIPart } from 'ai' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAssistantApiUrl } from './assistantConfig' +import { + clearConversation, + loadConversation, + saveConversation, +} from './conversationStorage' +import { usePageContext } from './usePageContext' + +const TOOL_OUTPUT_PLACEHOLDER = '[documentation content omitted]' + +const stripToolOutputs = (messages: UIMessage[]): UIMessage[] => + messages.map((message) => + message.role === 'assistant' + ? { + ...message, + parts: message.parts.map((part) => + isToolUIPart(part) && part.state === 'output-available' + ? { ...part, output: TOOL_OUTPUT_PLACEHOLDER } + : part + ), + } + : message + ) + +export const useAssistantChat = () => { + const apiUrl = useAssistantApiUrl() + const pageContext = usePageContext() + const pageContextRef = useRef(pageContext) + pageContextRef.current = pageContext + + const [stored] = useState(() => loadConversation()) + const [conversationId, setConversationId] = useState( + () => stored?.id ?? generateId() + ) + const conversationIdRef = useRef(conversationId) + conversationIdRef.current = conversationId + + const transportRef = useRef( + new DefaultChatTransport({ + api: apiUrl, + credentials: 'same-origin', + body: () => ({ pageContext: pageContextRef.current }), + prepareSendMessagesRequest: ({ + id, + messages, + body, + trigger, + messageId, + }) => ({ + body: { + ...body, + id, + trigger, + messageId, + messages: stripToolOutputs(messages), + }, + }), + }) + ) + + const { + messages, + sendMessage, + setMessages, + status, + error, + regenerate, + stop, + clearError, + } = useChat({ + id: conversationId, + messages: stored?.messages ?? [], + transport: transportRef.current, + onFinish({ messages: finished }) { + saveConversation(conversationIdRef.current, finished) + }, + }) + + useEffect(() => { + if (messages.length > 0 && messages[messages.length - 1]?.role === 'user') { + saveConversation(conversationIdRef.current, messages) + } + }, [messages]) + + const startNewConversation = useCallback(() => { + stop() + clearConversation() + setMessages([]) + setConversationId(generateId()) + }, [setMessages, stop]) + + return { + conversationId, + messages, + sendMessage, + status, + error, + regenerate, + stop, + clearError, + startNewConversation, + } +} + +export type AssistantChatState = ReturnType diff --git a/src/components/Assistant/usePageContext.ts b/src/components/Assistant/usePageContext.ts new file mode 100644 index 000000000..48b514502 --- /dev/null +++ b/src/components/Assistant/usePageContext.ts @@ -0,0 +1,42 @@ +import { useLocation } from '@docusaurus/router' +import { useMemo } from 'react' +import { BIZ1_COOKIE_NAME } from '@site/src/constants/config' + +export type PageContext = { + url: string + pageTitle: string + snowplowDomainUserId?: string + snowplowDomainSessionId?: string +} + +const readSnowplowIds = (): { + snowplowDomainUserId?: string + snowplowDomainSessionId?: string +} => { + if (typeof document === 'undefined' || !document.cookie) return {} + const value = decodeURIComponent(document.cookie) + .split('; ') + .find((row) => row.startsWith(`${BIZ1_COOKIE_NAME}id`)) + ?.split('=')[1] + if (!value) return {} + const parts = value.split('.') + return { + ...(parts[0] ? { snowplowDomainUserId: parts[0] } : {}), + ...(parts[5] ? { snowplowDomainSessionId: parts[5] } : {}), + } +} + +export const usePageContext = (): PageContext => { + const { pathname, search } = useLocation() + return useMemo(() => { + const isBrowser = typeof window !== 'undefined' + const url = isBrowser + ? `${window.location.origin}${pathname}${search}` + : `${pathname}${search}` + return { + url, + pageTitle: isBrowser ? document.title : '', + ...readSnowplowIds(), + } + }, [pathname, search]) +} diff --git a/src/components/Assistant/useResizableWidth.ts b/src/components/Assistant/useResizableWidth.ts new file mode 100644 index 000000000..c04e5f768 --- /dev/null +++ b/src/components/Assistant/useResizableWidth.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export const DEFAULT_DRAWER_WIDTH = 560 +const MIN_WIDTH = 380 +const MAX_WIDTH = 1100 +const VIEWPORT_MARGIN = 240 +const KEYBOARD_STEP = 32 +const STORAGE_KEY = 'snowplow-docs-assistant:width' + +const readStoredWidth = (): number | null => { + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + const value = raw === null ? NaN : Number(raw) + return Number.isFinite(value) ? value : null + } catch { + return null + } +} + +const storeWidth = (width: number) => { + try { + window.localStorage.setItem(STORAGE_KEY, String(Math.round(width))) + } catch { + // Storage disabled: the width simply resets next time. + } +} + +const maxWidth = () => + Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, window.innerWidth - VIEWPORT_MARGIN)) + +const clamp = (width: number) => + Math.min(maxWidth(), Math.max(MIN_WIDTH, Math.round(width))) + +export const useResizableWidth = () => { + const [width, setWidth] = useState(() => + typeof window === 'undefined' + ? DEFAULT_DRAWER_WIDTH + : clamp(readStoredWidth() ?? DEFAULT_DRAWER_WIDTH) + ) + const [isResizing, setIsResizing] = useState(false) + const widthRef = useRef(width) + widthRef.current = width + + useEffect(() => { + const onResize = () => setWidth((current) => clamp(current)) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + const commit = useCallback((next: number) => { + const clamped = clamp(next) + setWidth(clamped) + storeWidth(clamped) + }, []) + + const startResize = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + const startX = event.clientX + const startWidth = widthRef.current + setIsResizing(true) + document.body.style.userSelect = 'none' + document.body.style.cursor = 'col-resize' + + const onMove = (move: PointerEvent) => { + setWidth(clamp(startWidth + (startX - move.clientX))) + } + const onUp = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + document.body.style.userSelect = '' + document.body.style.cursor = '' + setIsResizing(false) + storeWidth(widthRef.current) + } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + }, []) + + const onHandleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + event.preventDefault() + commit(widthRef.current + KEYBOARD_STEP) + } else if (event.key === 'ArrowRight') { + event.preventDefault() + commit(widthRef.current - KEYBOARD_STEP) + } else if (event.key === 'Home') { + event.preventDefault() + commit(DEFAULT_DRAWER_WIDTH) + } + }, + [commit] + ) + + const resetWidth = useCallback(() => commit(DEFAULT_DRAWER_WIDTH), [commit]) + + return { + width, + isResizing, + startResize, + onHandleKeyDown, + resetWidth, + minWidth: MIN_WIDTH, + maxWidth: maxWidth, + } +} diff --git a/src/components/ai-elements/conversation.tsx b/src/components/ai-elements/conversation.tsx new file mode 100644 index 000000000..e711c013e --- /dev/null +++ b/src/components/ai-elements/conversation.tsx @@ -0,0 +1,101 @@ +import type { ComponentProps } from 'react' +import { useCallback } from 'react' +import { ArrowDownIcon } from 'lucide-react' +import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom' +import { Button } from '@site/src/components/ui/button' +import { cn } from '@site/src/lib/utils.js' + +export type ConversationProps = ComponentProps + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +) + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +> + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +) + +export type ConversationEmptyStateProps = ComponentProps<'div'> & { + title?: string + description?: string + icon?: React.ReactNode +} + +export const ConversationEmptyState = ({ + className, + title = 'No messages yet', + description = 'Start a conversation to see messages here', + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+) + +export type ConversationScrollButtonProps = ComponentProps + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext() + + const handleScrollToBottom = useCallback(() => { + scrollToBottom() + }, [scrollToBottom]) + + if (isAtBottom) { + return null + } + + return ( + + ) +} diff --git a/src/components/ai-elements/message.tsx b/src/components/ai-elements/message.tsx new file mode 100644 index 000000000..5a9f59ae6 --- /dev/null +++ b/src/components/ai-elements/message.tsx @@ -0,0 +1,63 @@ +import type { UIMessage } from 'ai' +import type { ComponentProps, HTMLAttributes } from 'react' +import { memo } from 'react' +import { Streamdown } from 'streamdown' +import { code } from '@streamdown/code' +import { cn } from '@site/src/lib/utils.js' + +export type MessageProps = HTMLAttributes & { + from: UIMessage['role'] +} + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+) + +export type MessageContentProps = HTMLAttributes + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+) + +export type MessageResponseProps = ComponentProps + +const streamdownPlugins = { code } + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-base [&_h4]:text-sm [&_:not(pre)>code]:text-xs', + className + )} + plugins={streamdownPlugins} + {...props} + /> + ), + (prevProps, nextProps) => + prevProps.children === nextProps.children && + prevProps.isAnimating === nextProps.isAnimating +) + +MessageResponse.displayName = 'MessageResponse' diff --git a/src/components/ai-elements/prompt-input.tsx b/src/components/ai-elements/prompt-input.tsx new file mode 100644 index 000000000..489d7c60c --- /dev/null +++ b/src/components/ai-elements/prompt-input.tsx @@ -0,0 +1,182 @@ +import type { ChatStatus } from 'ai' +import type { + ComponentProps, + FormEvent, + FormEventHandler, + HTMLAttributes, + KeyboardEventHandler, +} from 'react' +import { useCallback, useState } from 'react' +import { ArrowUpIcon, LoaderCircleIcon, SquareIcon, XIcon } from 'lucide-react' +import { Button } from '@site/src/components/ui/button' +import { Textarea } from '@site/src/components/ui/textarea' +import { cn } from '@site/src/lib/utils.js' + +export interface PromptInputMessage { + text: string +} + +export type PromptInputProps = Omit< + HTMLAttributes, + 'onSubmit' +> & { + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void | Promise +} + +export const PromptInput = ({ + className, + onSubmit, + children, + ...props +}: PromptInputProps) => { + const handleSubmit: FormEventHandler = useCallback( + async (event) => { + event.preventDefault() + const form = event.currentTarget + const formData = new FormData(form) + const value = formData.get('message') + const text = typeof value === 'string' ? value : '' + if (!text.trim()) { + return + } + form.reset() + try { + await onSubmit({ text }, event) + } catch { + // Keep the composer usable; the chat surface reports errors. + } + }, + [onSubmit] + ) + + return ( +
+
+ {children} +
+
+ ) +} + +export type PromptInputTextareaProps = ComponentProps + +export const PromptInputTextarea = ({ + onKeyDown, + className, + placeholder = 'What would you like to know?', + ...props +}: PromptInputTextareaProps) => { + const [isComposing, setIsComposing] = useState(false) + + const handleKeyDown: KeyboardEventHandler = useCallback( + (e) => { + onKeyDown?.(e) + if (e.defaultPrevented) { + return + } + if (e.key === 'Enter') { + if (isComposing || e.nativeEvent.isComposing || e.shiftKey) { + return + } + e.preventDefault() + const { form } = e.currentTarget + const submitButton = form?.querySelector( + 'button[type="submit"]' + ) + if (submitButton?.disabled) { + return + } + form?.requestSubmit() + } + }, + [onKeyDown, isComposing] + ) + + const handleCompositionEnd = useCallback(() => setIsComposing(false), []) + const handleCompositionStart = useCallback(() => setIsComposing(true), []) + + return ( +