Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"diff:mdx": "yarn ci-format:mdx && git diff -- . ':(exclude)package-lock.json' ':(exclude)package.json' ':(exclude)yarn.lock' | awk \"/diff --git/ {found=1} found {print}\"",
"lint:fix": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"test": "vitest run src/agent-chat",
"prepare": "husky",
"postinstall": "patch-package",
"rpcspec:build": "node openrpc/scripts/build.mjs",
Expand All @@ -37,6 +38,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",
Expand All @@ -46,6 +48,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",
Expand All @@ -57,7 +60,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"
},
Expand Down Expand Up @@ -87,7 +92,8 @@
"glob": "^13.0.6",
"husky": "^9.1.7",
"prettier": "3.8.1",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24"
Expand Down
1 change: 1 addition & 0 deletions routes.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/
/404.html
/ai
/docs
/docs/build
/docs/build/agentic-payments
Expand Down
79 changes: 79 additions & 0 deletions src/agent-chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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";

<AgentChat
config={{
appId: "YOUR_APP_ID",
agentId: "YOUR_AGENT_ID",
apiKey: "YOUR_SEARCH_ONLY_KEY", // safe to expose client-side
}}
welcomeMessage="Ask me anything…"
suggestions={["How do I start?"]}
/>;
```

Floating launcher variant:

```tsx
import { AgentChatWidget } from "@site/src/agent-chat";

<AgentChatWidget config={config} title="Ask AI" />;
```

## 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. |
| `hideStatusUntilToolCall` | Hide a streaming reply until a tool call occurs, suppressing pre-search "status" chatter. Defaults to `true`. Set `false` for agents that answer without tools. |

`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 `<BrowserOnly>`).
- `core/` has no React dependency and can be used on its own to build requests.
101 changes: 101 additions & 0 deletions src/agent-chat/core/createAgentTransport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect } from "vitest";
import type { UIMessage } from "ai";

import { buildCompletionsUrl, sanitizeMessages } from "./createAgentTransport";

const CONFIG = {
appId: "VNSJF5AWIZ",
agentId: "bf6c7577-d445-4132-9ad8-c349a0560621",
apiKey: "key",
};

function msg(role: "user" | "assistant", parts: unknown[]): UIMessage {
return { id: "m1", role, parts } as UIMessage;
}

describe("buildCompletionsUrl", () => {
it("targets the Agent Studio endpoint with ai-sdk-5 compatibility and streaming", () => {
expect(buildCompletionsUrl(CONFIG)).toBe(
"https://vnsjf5awiz.algolia.net/agent-studio/1/agents/bf6c7577-d445-4132-9ad8-c349a0560621/completions?compatibilityMode=ai-sdk-5&stream=true",
);
});

it("honors host and stream overrides", () => {
expect(
buildCompletionsUrl({ ...CONFIG, host: "https://example.com", stream: false }),
).toBe(
"https://example.com/agent-studio/1/agents/bf6c7577-d445-4132-9ad8-c349a0560621/completions?compatibilityMode=ai-sdk-5&stream=false",
);
});
});

describe("sanitizeMessages", () => {
// Regression: a failed tool call (state output-error) in history made Agent
// Studio emit a `tool_use` block without a `tool_result`, so the LLM rejected
// the whole conversation and every later turn failed.
it("rewrites errored tool parts into regular outputs carrying the error", () => {
const messages = [
msg("assistant", [
{ type: "text", text: "Looking that up…" },
{
type: "tool-getPage",
toolCallId: "toolu_123",
state: "output-error",
input: { url: "/docs" },
errorText: "fetch failed",
},
{ type: "text", text: "Here is the answer." },
]),
];

const [out] = sanitizeMessages(messages);
const tool = out.parts[1] as Record<string, unknown>;
expect(tool.state).toBe("output-available");
expect(tool.output).toEqual({ error: "fetch failed" });
expect(tool).not.toHaveProperty("errorText");
// Non-tool parts pass through untouched.
expect(out.parts[0]).toEqual({ type: "text", text: "Looking that up…" });
expect(out.parts[2]).toEqual({ type: "text", text: "Here is the answer." });
});

it("defaults the output error text when errorText is missing", () => {
const [out] = sanitizeMessages([
msg("assistant", [
{ type: "dynamic-tool", toolCallId: "t1", state: "output-error", input: {} },
]),
]);
expect((out.parts[0] as Record<string, unknown>).output).toEqual({
error: "Tool call failed.",
});
});

it("drops tool parts that never produced an output (stopped streams)", () => {
const [out] = sanitizeMessages([
msg("assistant", [
{ type: "tool-search", toolCallId: "t1", state: "input-streaming" },
{ type: "tool-search", toolCallId: "t2", state: "input-available", input: {} },
{ type: "text", text: "answer" },
]),
]);
expect(out.parts).toEqual([{ type: "text", text: "answer" }]);
});

it("passes successful tool parts and full conversations through unchanged", () => {
const messages = [
msg("user", [{ type: "text", text: "What is a trustline?" }]),
msg("assistant", [
{ type: "step-start" },
{
type: "tool-algolia_search_index_docs_replica_agent",
toolCallId: "t1",
state: "output-available",
input: { query: "trustline" },
output: { hits: [] },
},
{ type: "text", text: "A trustline is…" },
]),
msg("user", [{ type: "text", text: "How do I create one?" }]),
];
expect(sanitizeMessages(messages)).toEqual(messages);
});
});
82 changes: 82 additions & 0 deletions src/agent-chat/core/createAgentTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { DefaultChatTransport, type UIMessage } from "ai";

import type { AgentChatConfig } from "./types";

type AnyPart = UIMessage["parts"][number];

function isToolPart(part: AnyPart): boolean {
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
}

/**
* Agent Studio replays UI message history to the underlying LLM, but it drops
* tool parts that never produced an output (state `output-error` from a failed
* tool run, or `input-*` after a stopped stream) without dropping the matching
* `tool_use` block. The LLM then rejects the whole conversation ("`tool_use`
* ids were found without `tool_result` blocks"), and every later turn fails.
*
* Work around it client-side: rewrite errored tool parts so the error text
* becomes a regular output (keeping that context for the model), and drop tool
* parts that have no input/output to pair up at all.
*/
export function sanitizeMessages(messages: UIMessage[]): UIMessage[] {
return messages.map((message) => ({
...message,
parts: message.parts
.filter(
(part) =>
!isToolPart(part) ||
("state" in part &&
(part.state === "output-available" ||
part.state === "output-error")),
)
.map((part) => {
if (isToolPart(part) && "state" in part && part.state === "output-error") {
const { errorText, ...rest } = part as typeof part & {
errorText?: string;
};
return {
...rest,
state: "output-available",
output: { error: errorText ?? "Tool call failed." },
} as AnyPart;
}
return part;
}),
}));
}

/**
* 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,
},
prepareSendMessagesRequest: ({ id, messages, body }) => ({
body: { ...body, id, messages: sanitizeMessages(messages) },
}),
});
}
25 changes: 25 additions & 0 deletions src/agent-chat/core/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions src/agent-chat/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading