From df29c117cb5afe7d74059c315b0dabf92b8cde18 Mon Sep 17 00:00:00 2001 From: oceans404 Date: Mon, 1 Jun 2026 15:39:27 -0700 Subject: [PATCH 1/6] Add Algolia Agent Studio AI chat assistant (floating widget and /ai page) --- docusaurus.config.ts | 13 + package.json | 4 + routes.txt | 1 + src/agent-chat/README.md | 78 +++++ src/agent-chat/core/createAgentTransport.ts | 35 +++ src/agent-chat/core/types.ts | 25 ++ src/agent-chat/index.ts | 26 ++ src/agent-chat/react/AgentChat.module.css | 269 ++++++++++++++++ src/agent-chat/react/AgentChat.tsx | 292 ++++++++++++++++++ .../react/AgentChatWidget.module.css | 125 ++++++++ src/agent-chat/react/AgentChatWidget.tsx | 112 +++++++ src/agent-chat/react/CodeBlock.module.css | 30 ++ src/agent-chat/react/CodeBlock.tsx | 32 ++ src/agent-chat/react/CopyButton.module.css | 20 ++ src/agent-chat/react/CopyButton.tsx | 52 ++++ src/agent-chat/react/Markdown.tsx | 59 ++++ src/agent-chat/react/useAgentChat.ts | 19 ++ src/agent-chat/theme-fallback.css | 32 ++ src/css/custom.scss | 4 + src/hooks/stellarAgentChat.tsx | 29 ++ src/hooks/useAgentChatConfig.ts | 14 + src/pages/ai.module.css | 55 ++++ src/pages/ai.tsx | 54 ++++ src/theme/Root.tsx | 41 +++ yarn.lock | 121 +++++--- 25 files changed, 1505 insertions(+), 37 deletions(-) create mode 100644 src/agent-chat/README.md create mode 100644 src/agent-chat/core/createAgentTransport.ts create mode 100644 src/agent-chat/core/types.ts create mode 100644 src/agent-chat/index.ts create mode 100644 src/agent-chat/react/AgentChat.module.css create mode 100644 src/agent-chat/react/AgentChat.tsx create mode 100644 src/agent-chat/react/AgentChatWidget.module.css create mode 100644 src/agent-chat/react/AgentChatWidget.tsx create mode 100644 src/agent-chat/react/CodeBlock.module.css create mode 100644 src/agent-chat/react/CodeBlock.tsx create mode 100644 src/agent-chat/react/CopyButton.module.css create mode 100644 src/agent-chat/react/CopyButton.tsx create mode 100644 src/agent-chat/react/Markdown.tsx create mode 100644 src/agent-chat/react/useAgentChat.ts create mode 100644 src/agent-chat/theme-fallback.css create mode 100644 src/hooks/stellarAgentChat.tsx create mode 100644 src/hooks/useAgentChatConfig.ts create mode 100644 src/pages/ai.module.css create mode 100644 src/pages/ai.tsx create mode 100644 src/theme/Root.tsx diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 3f7570a17f..4a2c177a9f 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -27,6 +27,19 @@ const config: Config = { favicon: "img/docusaurus/favicon-96x96.png", organizationName: "stellar", projectName: "stellar-docs", + customFields: { + // Algolia Agent Studio connection for the docs AI assistant. + // The search-only API key is safe to expose client-side. Override any of + // these with env vars at build time. + agentChat: { + appId: process.env.AGENT_CHAT_APP_ID || "VNSJF5AWIZ", + agentId: + process.env.AGENT_CHAT_AGENT_ID || + "bf6c7577-d445-4132-9ad8-c349a0560621", + apiKey: + process.env.AGENT_CHAT_API_KEY || "86acea3d3ee70d36299b99fda5fdf092", + }, + }, // i18n: { // defaultLocale: DEFAULT_LOCALE, // locales: ["en", "es"], diff --git a/package.json b/package.json index b64226f603..e4d5d9032f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "crowdin:sync": "docusaurus write-translations && crowdin upload --no-progress --delete-obsolete && crowdin download --no-progress" }, "dependencies": { + "@ai-sdk/react": "2.0.196", "@docusaurus/core": "3.9.2", "@docusaurus/faster": "3.9.2", "@docusaurus/preset-classic": "3.9.2", @@ -46,6 +47,7 @@ "@open-rpc/meta-schema": "^1.14.9", "@open-rpc/schema-utils-js": "^1.16.2", "@stellar/open-rpc-docs-react": "^0.2.1", + "ai": "5.0.194", "ajv": "^8.18.0", "clsx": "^2.1.1", "docusaurus-plugin-openapi-docs": "4.5.1", @@ -57,7 +59,9 @@ "prism-react-renderer": "^2.4.1", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-markdown": "10.1.0", "rehype-katex": "^7.0.1", + "remark-gfm": "4.0.1", "remark-math": "^6.0.0", "sass": "^1.98.0" }, diff --git a/routes.txt b/routes.txt index b908818d18..7d085b72cd 100644 --- a/routes.txt +++ b/routes.txt @@ -1,5 +1,6 @@ / /404.html +/ai /docs /docs/build /docs/build/agentic-payments diff --git a/src/agent-chat/README.md b/src/agent-chat/README.md new file mode 100644 index 0000000000..8c8c4ef0a4 --- /dev/null +++ b/src/agent-chat/README.md @@ -0,0 +1,78 @@ +# agent-chat + +A small, reusable React chat UI for an +[Algolia Agent Studio](https://www.algolia.com/doc/guides/algolia-ai/agent-studio/) +agent, built on the [Vercel AI SDK](https://ai-sdk.dev/) (`useChat` + +`DefaultChatTransport`). + +It is intentionally **host-agnostic**: nothing here imports Docusaurus. The +folder can be lifted into a standalone package (e.g. `@stellar/agent-chat`) by +copying it and adding a `package.json`. + +## Layout + +``` +core/ # no React/Docusaurus deps — transport + config types +react/ # React UI: useAgentChat hook, AgentChat, AgentChatWidget, CodeBlock, CopyButton, Markdown +index.ts # public exports +theme-fallback.css # optional CSS vars for non-Docusaurus hosts +``` + +## Usage + +```tsx +import { AgentChat } from "@site/src/agent-chat"; + +; +``` + +Floating launcher variant: + +```tsx +import { AgentChatWidget } from "@site/src/agent-chat"; + +; +``` + +## Key props (`AgentChat`) + +| Prop | Purpose | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `config` | `{ appId, agentId, apiKey, host?, stream? }` connection details. | +| `welcomeMessage` | Shown on the empty state. | +| `suggestions` | Starter prompt buttons. | +| `placeholder` | Input placeholder. | +| `markdownClassName` | Class applied to rendered answers so they inherit the host's prose styling (e.g. Docusaurus `"markdown"`). | +| `codeBlock` | Component used for fenced code. Defaults to a built-in copyable block; pass the host's highlighter (e.g. Docusaurus `@theme/CodeBlock`) for syntax highlighting. | + +`AgentChatWidget` accepts all of the above plus `title`. + +## Theming + +The UI reads **Infima CSS variables** (`--ifm-*`), so inside a Docusaurus site +it matches the theme (including light/dark) automatically. + +- **Accent color**: set `--agent-chat-accent` and `--agent-chat-accent-contrast` + (defaults to Stellar gold `#fdda24` on near-black). Used for the launcher, + header, user bubbles, and send button. +- **Non-Docusaurus hosts**: import `theme-fallback.css` once to supply the + `--ifm-*` variables, then override as needed. + +## Notes for extraction into a standalone package + +- Declared runtime deps: `react`, `ai`, `@ai-sdk/react`, `react-markdown`, + `remark-gfm`, `clsx`. +- Pin `ai`/`@ai-sdk/react` to the v5 line to match the endpoint's + `compatibilityMode=ai-sdk-5`. +- The `react/` components must run client-side (they use the clipboard and + streaming). In SSR frameworks, render them in a browser-only boundary (e.g. + Docusaurus ``). +- `core/` has no React dependency and can be used on its own to build requests. diff --git a/src/agent-chat/core/createAgentTransport.ts b/src/agent-chat/core/createAgentTransport.ts new file mode 100644 index 0000000000..ac1731c345 --- /dev/null +++ b/src/agent-chat/core/createAgentTransport.ts @@ -0,0 +1,35 @@ +import { DefaultChatTransport } from "ai"; + +import type { AgentChatConfig } from "./types"; + +/** + * Build the Agent Studio completions endpoint URL for a given agent. + * + * Uses `compatibilityMode=ai-sdk-5` to match the AI SDK v5 UI message stream + * protocol, and `stream=true` so `useChat` receives a streamable response. + */ +export function buildCompletionsUrl(config: AgentChatConfig): string { + const host = + config.host ?? `https://${config.appId.toLowerCase()}.algolia.net`; + const params = new URLSearchParams({ + compatibilityMode: "ai-sdk-5", + stream: String(config.stream ?? true), + }); + return `${host}/agent-studio/1/agents/${config.agentId}/completions?${params.toString()}`; +} + +/** + * Create an AI SDK transport pointed at an Agent Studio agent. + * + * Pass the result to `useChat({ transport })` (or use the `useAgentChat` hook, + * which does this for you). + */ +export function createAgentTransport(config: AgentChatConfig) { + return new DefaultChatTransport({ + api: buildCompletionsUrl(config), + headers: { + "x-algolia-application-id": config.appId, + "x-algolia-api-key": config.apiKey, + }, + }); +} diff --git a/src/agent-chat/core/types.ts b/src/agent-chat/core/types.ts new file mode 100644 index 0000000000..be044aad89 --- /dev/null +++ b/src/agent-chat/core/types.ts @@ -0,0 +1,25 @@ +/** + * Configuration for connecting to an Algolia Agent Studio agent. + * + * This module is framework-agnostic on purpose: nothing here imports React or + * Docusaurus, so the whole `agent-chat` folder can be lifted into a standalone + * package (e.g. `@stellar/agent-chat`) later without changes. + */ +export interface AgentChatConfig { + /** Algolia application ID (e.g. `VNSJF5AWIZ`). */ + appId: string; + /** Published Agent Studio agent ID. */ + agentId: string; + /** Search-only API key. Safe to expose client-side. */ + apiKey: string; + /** + * Override the Algolia host. Defaults to `https://{appId}.algolia.net`. + * Useful for testing or non-standard clusters. + */ + host?: string; + /** + * Stream responses token-by-token. Defaults to `true`, which is required for + * the AI SDK `useChat` transport to read a UI message stream. + */ + stream?: boolean; +} diff --git a/src/agent-chat/index.ts b/src/agent-chat/index.ts new file mode 100644 index 0000000000..3136643190 --- /dev/null +++ b/src/agent-chat/index.ts @@ -0,0 +1,26 @@ +/** + * Reusable Algolia Agent Studio chat module. + * + * Designed to be host-agnostic: the `core/` layer has no React/Docusaurus + * dependency, and the `react/` layer only depends on React + the AI SDK. To + * reuse elsewhere, copy this folder and add a `package.json`. + */ +export { + buildCompletionsUrl, + createAgentTransport, +} from "./core/createAgentTransport"; +export type { AgentChatConfig } from "./core/types"; + +export { useAgentChat } from "./react/useAgentChat"; +export { AgentChat, type AgentChatProps } from "./react/AgentChat"; +export { + AgentChatWidget, + type AgentChatWidgetProps, +} from "./react/AgentChatWidget"; +export { + Markdown, + type MarkdownProps, + type CodeBlockComponent, +} from "./react/Markdown"; +export { CodeBlock, type CodeBlockProps } from "./react/CodeBlock"; +export { CopyButton, type CopyButtonProps } from "./react/CopyButton"; diff --git a/src/agent-chat/react/AgentChat.module.css b/src/agent-chat/react/AgentChat.module.css new file mode 100644 index 0000000000..825181d4d1 --- /dev/null +++ b/src/agent-chat/react/AgentChat.module.css @@ -0,0 +1,269 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); +} + +.messages { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.empty { + margin: auto 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.welcome { + margin: 0; + color: var(--ifm-color-emphasis-700); + font-size: 0.95rem; + line-height: 1.5; +} + +.suggestions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.suggestion { + text-align: left; + border: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-surface-color); + color: var(--ifm-font-color-base); + border-radius: 0.5rem; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease; +} + +.suggestion:hover { + border-color: var(--ifm-color-primary); + background: var(--ifm-color-emphasis-100); +} + +.message { + display: flex; +} + +.userMessage { + justify-content: flex-end; +} + +.assistantMessage { + justify-content: flex-start; +} + +.bubble { + padding: 0.625rem 0.875rem; + border-radius: 1rem; + font-size: 0.9rem; + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: anywhere; +} + +.userMessage .bubble { + max-width: 85%; + background: var(--agent-chat-accent, #fdda24); + color: var(--agent-chat-accent-contrast, #0f0f0f); + border-bottom-right-radius: 0.25rem; + white-space: pre-wrap; +} + +.assistantMessage .bubble { + max-width: 100%; + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-200); + color: var(--ifm-font-color-base); + border-bottom-left-radius: 0.25rem; +} + +.bubbleActions { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; +} + +/* Tame the inherited site prose styles so markdown reads compactly in the + narrow chat panel (the host's `.markdown` class is applied alongside). */ +.prose { + font-size: 0.9rem; +} + +.prose > :first-child { + margin-top: 0; +} + +.prose > :last-child { + margin-bottom: 0; +} + +.prose :is(h1, h2, h3, h4, h5, h6) { + font-size: 1rem; + font-weight: 600; + margin: 0.75rem 0 0.35rem; + line-height: 1.3; +} + +.prose p, +.prose ul, +.prose ol { + margin-bottom: 0.5rem; +} + +.prose ul, +.prose ol { + padding-left: 1.25rem; +} + +.prose hr { + margin: 0.75rem 0; +} + +.prose pre { + font-size: 0.8rem; +} + +/* Keep wide content (tables, code) from forcing horizontal page scroll. */ +.prose table { + display: block; + width: 100%; + overflow-x: auto; +} + +.prose img { + max-width: 100%; + height: auto; +} + +.thinking { + display: inline-flex; + gap: 0.25rem; + align-items: center; +} + +.thinking span { + width: 0.4rem; + height: 0.4rem; + border-radius: 50%; + background: var(--ifm-color-emphasis-600); + animation: blink 1.4s infinite both; +} + +.thinking span:nth-child(2) { + animation-delay: 0.2s; +} + +.thinking span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes blink { + 0%, + 80%, + 100% { + opacity: 0.25; + } + 40% { + opacity: 1; + } +} + +.error { + align-self: center; + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--ifm-color-danger); + font-size: 0.85rem; +} + +.retry { + padding: 0.2rem 0.6rem; + font-size: 0.78rem; + border: 1px solid currentColor; + border-radius: 0.35rem; + background: transparent; + color: inherit; + cursor: pointer; +} + +.inputRow { + display: flex; + gap: 0.5rem; + align-items: flex-end; + padding: 0.75rem; + border-top: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-surface-color); +} + +.input { + flex: 1 1 auto; + resize: none; + max-height: 8rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 0.75rem; + padding: 0.5rem 0.75rem; + font: inherit; + font-size: 0.9rem; + line-height: 1.4; + color: var(--ifm-font-color-base); + background: var(--ifm-background-color); +} + +.input:focus { + outline: none; + border-color: var(--ifm-color-primary); +} + +.sendButton { + flex: 0 0 auto; + width: 2.25rem; + height: 2.25rem; + border: none; + border-radius: 50%; + background: var(--agent-chat-accent, #fdda24); + color: var(--agent-chat-accent-contrast, #0f0f0f); + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.sendButton:disabled { + opacity: 0.4; + cursor: default; +} + +@media (max-width: 600px) { + /* 16px prevents iOS Safari from auto-zooming when the input is focused. */ + .input { + font-size: 16px; + } + + .sendButton { + width: 2.5rem; + height: 2.5rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .thinking span { + animation: none; + } +} diff --git a/src/agent-chat/react/AgentChat.tsx b/src/agent-chat/react/AgentChat.tsx new file mode 100644 index 0000000000..fc7112b976 --- /dev/null +++ b/src/agent-chat/react/AgentChat.tsx @@ -0,0 +1,292 @@ +import React, { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import type { UIMessage } from "ai"; + +import { useAgentChat } from "./useAgentChat"; +import { Markdown, type CodeBlockComponent } from "./Markdown"; +import { CopyButton } from "./CopyButton"; +import type { AgentChatConfig } from "../core/types"; +import styles from "./AgentChat.module.css"; + +export interface AgentChatProps { + /** Agent Studio connection config. */ + config: AgentChatConfig; + /** Extra class on the root container. */ + className?: string; + /** Placeholder for the input. */ + placeholder?: string; + /** Message shown before the conversation starts. */ + welcomeMessage?: string; + /** Optional starter prompts shown on the empty state. */ + suggestions?: string[]; + /** + * Class applied to rendered assistant markdown so it can inherit the host + * site's prose styling (e.g. Docusaurus's `markdown` class). Host-agnostic: + * pass nothing for unstyled output. + */ + markdownClassName?: string; + /** + * Component used to render fenced code blocks. Defaults to a built-in + * copyable block; pass a host component (e.g. Docusaurus `@theme/CodeBlock`) + * for syntax highlighting that matches the site. + */ + codeBlock?: CodeBlockComponent; +} + +/** Collect every non-empty text segment of a message, in order. */ +function textParts(message: UIMessage): string[] { + return message.parts + .filter((part) => part.type === "text") + .map((part) => (part as { text: string }).text) + .filter((text) => text.trim().length > 0); +} + +/** + * The agent emits short status lines ("Let me find that…") before each tool + * call, then the real answer as the final text run. Return only the text that + * comes after the last tool call so we show the answer, not the chatter. + */ +function answerParts(message: UIMessage): string[] { + let lastToolIndex = -1; + message.parts.forEach((part, index) => { + if (part.type.startsWith("tool-") || part.type === "dynamic-tool") { + lastToolIndex = index; + } + }); + return message.parts + .map((part, index) => ({ part, index })) + .filter(({ part, index }) => part.type === "text" && index > lastToolIndex) + .map(({ part }) => (part as { text: string }).text) + .filter((text) => text.trim().length > 0); +} + +function hasToolCall(message: UIMessage): boolean { + return message.parts.some( + (part) => part.type.startsWith("tool-") || part.type === "dynamic-tool", + ); +} + +/** + * Text to show for an assistant message. While it's still streaming, the + * pre-tool status chatter ("Let me find that…") would otherwise flash in and + * then vanish once a tool call arrives, so we reveal nothing until a tool call + * has happened. Once streaming finishes, show the final answer regardless. + */ +function visibleAnswer(message: UIMessage, streaming: boolean): string[] { + if (streaming && !hasToolCall(message)) { + return []; + } + return answerParts(message); +} + +function Message({ + message, + markdownClassName, + codeBlock, + streaming, +}: { + message: UIMessage; + markdownClassName?: string; + codeBlock?: CodeBlockComponent; + streaming: boolean; +}) { + const isUser = message.role === "user"; + const segments = isUser + ? textParts(message) + : visibleAnswer(message, streaming); + if (segments.length === 0) { + return null; + } + return ( +
+
+ {isUser ? ( + segments.join("\n\n") + ) : ( + <> +
+ {segments.map((segment, index) => ( + + {segment} + + ))} +
+ {!streaming && ( +
+ +
+ )} + + )} +
+
+ ); +} + +/** + * Headless-ish chat UI for an Agent Studio agent. Renders a scrollable message + * list and an input. Styling is driven by Infima CSS variables so it adapts to + * the host site's light/dark theme automatically. + */ +export function AgentChat({ + config, + className, + placeholder = "Ask a question…", + welcomeMessage, + suggestions, + markdownClassName, + codeBlock, +}: AgentChatProps) { + const { messages, sendMessage, status, error, stop, regenerate } = + useAgentChat(config); + const [input, setInput] = useState(""); + const scrollRef = useRef(null); + + const isBusy = status === "submitted" || status === "streaming"; + const isEmpty = messages.length === 0; + + useEffect(() => { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [messages, status]); + + const submit = (text: string) => { + const trimmed = text.trim(); + if (!trimmed || isBusy) { + return; + } + sendMessage({ text: trimmed }); + setInput(""); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + submit(input); + } + }; + + // The last assistant message is the one actively streaming while busy. + const last = messages[messages.length - 1]; + const streamingId = + isBusy && last && last.role === "assistant" ? last.id : undefined; + + // Show a thinking indicator while in flight and no answer text is visible yet + // (covers: awaiting first token, and running tools before the answer starts). + const showThinking = + isBusy && + (!last || last.role === "user" || visibleAnswer(last, true).length === 0); + + return ( +
+
+ {isEmpty && ( +
+ {welcomeMessage && ( +

{welcomeMessage}

+ )} + {suggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ )} +
+ )} + + {messages.map((message) => ( + + ))} + + {showThinking && ( +
+
+ + + +
+
+ )} + + {error && ( +
+ Something went wrong. + +
+ )} +
+ +
{ + event.preventDefault(); + submit(input); + }} + > +