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 ( +