diff --git a/AGENTS.md b/AGENTS.md index 23afe6004..dd2b440a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -314,7 +314,7 @@ Mock modules with `vi.mock('../../EmptyStateIndicator', () => ({ … }))`; use ` `yarn build` = `yarn clean` + 4 steps in parallel via `concurrently`, each writing to a separate `dist/` subdirectory: 1. **`build-translations`** — `i18next-cli extract` pulls `t()` calls from source into `src/i18n/*.json` -2. **`vite build`** — bundles 5 entry points as ESM (`dist/es/*.mjs`) + CJS (`dist/cjs/*.js`) +2. **`vite build`** — bundles 6 entry points as ESM (`dist/es/*.mjs`) + CJS (`dist/cjs/*.js`) 3. **`tsc -p tsconfig.lib.json`** — `.d.ts` only → `dist/types/` 4. **`build-styling`** — Sass → `dist/css/index.css`, `emoji-replacement.css`, `emoji-picker.css`, `channel-detail.css`, `ai-components.css`, plus `cp -r src/styling/assets dist/css/assets` @@ -326,6 +326,8 @@ Mock modules with `vi.mock('../../EmptyStateIndicator', () => ({ … }))`; use ` | `stream-chat-react/channel-detail` | `src/plugins/ChannelDetail/` | | `stream-chat-react/emojis` | `src/plugins/Emojis/` | | `stream-chat-react/ai-components` | `src/plugins/AIComponents/` | +| `stream-chat-react/a2ui` | `src/plugins/A2UI/` | +| `stream-chat-react/openui` | `src/plugins/OpenUI/` | | `stream-chat-react/mp3-encoder` | `src/plugins/encoders/mp3.ts` | | `stream-chat-react/css/*` | `dist/css/*` | diff --git a/package.json b/package.json index 7837b07af..8970013fe 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,18 @@ "require": "./dist/cjs/ai-components.js", "default": "./dist/cjs/ai-components.js" }, + "./a2ui": { + "types": "./dist/types/plugins/A2UI/index.d.ts", + "import": "./dist/es/a2ui.mjs", + "require": "./dist/cjs/a2ui-require.js", + "default": "./dist/es/a2ui.mjs" + }, + "./openui": { + "types": "./dist/types/plugins/OpenUI/index.d.ts", + "import": "./dist/es/openui.mjs", + "require": "./dist/cjs/openui.js", + "default": "./dist/es/openui.mjs" + }, "./dist/css/*": { "default": "./dist/css/*" }, @@ -66,6 +78,12 @@ ], "ai-components": [ "./dist/types/plugins/AIComponents/index.d.ts" + ], + "a2ui": [ + "./dist/types/plugins/A2UI/index.d.ts" + ], + "openui": [ + "./dist/types/plugins/OpenUI/index.d.ts" ] } }, @@ -116,14 +134,18 @@ "unist-builder": "^4.0.0", "unist-util-visit": "^5.1.0", "use-sync-external-store": "^1.6.0", - "zod": "^4.1.12" + "zod": "^4.2.0" }, "optionalDependencies": { "@stream-io/transliterate": "^1.5.5" }, "peerDependencies": { + "@a2ui/react": "~0.15.0", + "@a2ui/web_core": "~0.15.0", "@breezystack/lamejs": "^1.2.7", "@emoji-mart/data": "^1.1.0", + "@openuidev/react-lang": "~1.0.2", + "@openuidev/react-ui": "~0.18.1", "emoji-mart": "^5.4.0", "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", @@ -131,12 +153,24 @@ "stream-chat": "^9.54.0" }, "peerDependenciesMeta": { + "@a2ui/react": { + "optional": true + }, + "@a2ui/web_core": { + "optional": true + }, "@breezystack/lamejs": { "optional": true }, "@emoji-mart/data": { "optional": true }, + "@openuidev/react-lang": { + "optional": true + }, + "@openuidev/react-ui": { + "optional": true + }, "emoji-mart": { "optional": true }, @@ -150,11 +184,16 @@ "README.md" ], "devDependencies": { + "@a2ui/react": "0.15.0", + "@a2ui/web_core": "0.15.0", "@breezystack/lamejs": "^1.2.7", "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", "@emoji-mart/data": "^1.2.1", "@eslint/js": "^9.39.4", + "@openuidev/react-headless": "0.18.1", + "@openuidev/react-lang": "1.0.2", + "@openuidev/react-ui": "0.18.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^12.0.8", @@ -199,7 +238,8 @@ "typescript-eslint": "^8.59.4", "vite": "^8.1.3", "vitest": "^4.1.9", - "vitest-axe": "^0.1.0" + "vitest-axe": "^0.1.0", + "zustand": "4.5.7" }, "scripts": { "clean": "rm -rf dist", diff --git a/scripts/validate-cjs-node-bundle.cjs b/scripts/validate-cjs-node-bundle.cjs index 9e2c82613..c17a674a7 100644 --- a/scripts/validate-cjs-node-bundle.cjs +++ b/scripts/validate-cjs-node-bundle.cjs @@ -3,3 +3,11 @@ require('../dist/cjs/index.js'); require('../dist/cjs/ai-components.js'); + +// The A2UI renderer is an ES module only, and requiring it says so. +try { + require('stream-chat-react/a2ui'); + throw new Error('stream-chat-react/a2ui loaded with require'); +} catch (error) { + if (!/an ES module only/.test(error.message)) throw error; +} diff --git a/src/plugins/A2UI/A2UIInstructions.ts b/src/plugins/A2UI/A2UIInstructions.ts new file mode 100644 index 000000000..bce3eda8d --- /dev/null +++ b/src/plugins/A2UI/A2UIInstructions.ts @@ -0,0 +1,116 @@ +// What an agent is told to write A2UI with, and what checks it, without the A2UI packages: they +// load when the instructions are asked for. +import type { AIUIComponent } from '../AIComponents/GenerativeUI/AIUIComponent'; +import type { AIUIComponentDefinition } from '../AIComponents/GenerativeUI/AIUIComponentDefinition'; +import { isRecord } from '../AIComponents/GenerativeUI/AIUIHelpers'; +import { A2UI_VERSION } from './A2UIStream'; + +/** + * What an agent is told to write replies' UI in A2UI with: the basic catalog, or the part of it + * `keep` names, with the app's components, and what the app adds. The same as the + * `prompt.json` StreamChatA2UI's instructions are written from. + */ +export type A2UIInstructionsConfig = { + /** The id of the catalog with the app's components, as the renderer names it. */ + catalogId?: string; + /** The app's components, or their portable definitions, such as a `UIComponents.json`. */ + components?: (AIUIComponent | AIUIComponentDefinition)[]; + /** + * Replies with their UI, as the agent should write them. Each block must render with the + * catalog, or no instructions are written: a broken example teaches the model broken UI. + */ + examples?: string[]; + /** The info string of a reply's UI block, as the agents router reads it. */ + fence?: string; + /** The basic catalog's components and functions to describe; all of them when absent. */ + keep?: string[]; + /** Guidance on using the app's components. */ + notes?: string[]; + /** What the model is told first, such as which answers to write as UI. */ + preamble?: string; + /** Rules added after the protocol's. */ + rules?: string[]; +}; + +/** The A2UI an example holds that would not render, and why. */ +export class A2UIInstructionsError extends Error { + readonly problems: string[]; + + constructor(problems: string[]) { + super(`An example's A2UI would not render:\n${problems.join('\n')}`); + this.name = 'A2UIInstructionsError'; + this.problems = problems; + } +} + +/** A description of the A2UI v1.0 messages and the rules that keep what they make valid. */ +export const A2UI_PROTOCOL_OVERVIEW = `# Generating user interfaces with A2UI v1.0 + +You render user interfaces by emitting A2UI messages. Every message is a JSON object with a \`"version": "1.0"\` field plus exactly one of these keys: + +- \`createSurface\`: creates a surface. Requires \`surfaceId\`, and accepts \`catalogId\`, \`sendDataModel\`, \`components\` and \`dataModel\` so a complete UI can be sent in one message. +- \`updateComponents\`: adds or replaces components of an existing surface. Requires \`surfaceId\` and \`components\`. +- \`updateDataModel\`: writes to the surface data model. Requires \`surfaceId\` and \`value\`, and accepts a JSON Pointer \`path\`. Setting \`value\` to \`null\` deletes the entry at \`path\`. +- \`deleteSurface\`: removes a surface. Requires \`surfaceId\`. +- \`callRendererFunction\`: asks the renderer to run one of its functions. +- \`agentFunctionResponse\`: answers a \`callAgentFunction\` request from the renderer. + +Rules: + +1. Components are a flat adjacency list. Each component has a unique \`id\`, a \`component\` type, and its properties inline. Exactly one component must have the id \`root\`. An id is an identifier: letters, digits and underscores, not starting with a digit, such as \`saturday_plan\` (never \`saturday-plan\`). +2. Children are referenced by id, never nested inline. Use an array of ids, or \`{"componentId": "...", "path": "/items"}\` to repeat a template for every item of a data model list. +3. Any property may be a literal, a data binding \`{"@path": "/a/b"}\` or a function call \`{"@call": "name", "args": {...}}\`. Inside a list template, paths without a leading slash resolve relative to the current item, and \`{"@call": "@index"}\` returns the item's position. A literal object key that starts with \`@\` is written with \`@@\`, so \`"@@type"\` means \`"@type"\`. +4. Put content that changes in the data model and bind to it, so you can update values with \`updateDataModel\` instead of resending components. +5. Interactive components declare an \`action\`, which either dispatches an event to you (\`{"event": {"name": "...", "context": {...}}}\`) or runs a renderer function (\`{"functionCall": {"@call": "openUrl", "args": {"url": "..."}}}\`). +6. Surface ids must be unique for the lifetime of the renderer. Creating a surface that already exists is an error: delete it first, or send \`updateComponents\` and \`updateDataModel\` to change it.`; + +export const writing = (fence: string, catalogId: string) => `## Writing UI in A2UI + +Write a reply's UI in one fenced block whose info string is \`${fence}\`, after one short sentence saying what it shows, which is what people read wherever the UI can't show. In the block, write A2UI messages, one JSON object per line, and nothing else: + +1. First a \`createSurface\` with a \`surfaceId\` new to this reply, the \`catalogId\` \`${catalogId}\`, and the \`dataModel\` the UI's inputs start from. +2. Then \`updateComponents\` messages for that surface with a few components each, \`root\` first, so the UI shows as you write it. + +A reply's UI is shown as you wrote it: never write \`callRendererFunction\` or \`agentFunctionResponse\`, and never change an earlier reply's UI, but write a new one. What the person does in it comes back as their next message. Give every event action a \`userMessage\`, the words of that message, such as "Update the plan", and a \`context\` with what you need to know, bound to the data model.`; + +// A JSON value with its object keys sorted, so the same catalog always reads the same. +export const sorted = (value: unknown): unknown => + Array.isArray(value) + ? value.map(sorted) + : isRecord(value) + ? Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sorted(value[key])]), + ) + : value; + +export const fenceOf = (fence?: string) => fence ?? `ui:a2ui@${A2UI_VERSION}`; + +// The A2UI blocks of an example reply. +export const blocksIn = (example: string, fence: string) => + example + .split(`\`\`\`${fence}\n`) + .slice(1) + .map((block) => block.split('\n```')[0]); + +/** + * The instructions that teach an agent's model to write A2UI with the basic catalog and the + * app's components, in Markdown, from the A2UI core's own catalog: the counterpart of + * StreamChatA2UI's `A2UIInstructions`, from the same configuration as its `prompt.json`. Give + * them to the agent's backend, as the part of its instructions about UI. It runs on a server or + * in a build too, and rejects with an `A2UIInstructionsError` when an example would not render. + */ +export const a2uiInstructions = async (config: A2UIInstructionsConfig = {}) => + (await import('./instructions')).a2uiInstructionsText(config); + +/** + * What is wrong with the A2UI messages of `text`, such as an agent's example, for the catalog + * `config` makes, or nothing when they render as written: messages the renderer can't read, + * components it doesn't have or that don't fit their schemas, and references that lead nowhere. + * The counterpart of StreamChatA2UI's `A2UIProgram.problems`. + */ +export const a2uiProblems = async ( + text: string, + config: Pick = {}, +) => (await import('./instructions')).a2uiProblemsIn(text, config); diff --git a/src/plugins/A2UI/A2UIPlaceholder.tsx b/src/plugins/A2UI/A2UIPlaceholder.tsx new file mode 100644 index 000000000..c52fc4df9 --- /dev/null +++ b/src/plugins/A2UI/A2UIPlaceholder.tsx @@ -0,0 +1,8 @@ +import React from 'react'; + +/** What shows while a reply's A2UI loads, or while its first surface is being written. */ +export const A2UIPlaceholder = () => ( +
+
+
+); diff --git a/src/plugins/A2UI/A2UIRenderer.tsx b/src/plugins/A2UI/A2UIRenderer.tsx new file mode 100644 index 000000000..a8bc32588 --- /dev/null +++ b/src/plugins/A2UI/A2UIRenderer.tsx @@ -0,0 +1,161 @@ +import React, { type ComponentPropsWithoutRef, type ComponentType } from 'react'; + +import type { AIUIDocument } from '../AIComponents/GenerativeUI/AIUIDocument'; +import type { AIUIHost } from '../AIComponents/GenerativeUI/AIUIHost'; +import type { AIUIEngine, AIUIRenderer } from '../AIComponents/GenerativeUI/AIUIRenderer'; +import { + AIUIRuntimeLoader, + AIUIRuntimeView, +} from '../AIComponents/GenerativeUI/AIUIRuntimeLoader'; +import { A2UIPlaceholder } from './A2UIPlaceholder'; +import { A2UI_DEFAULT_CATALOG_ID } from './catalogDocument'; +import { A2UI_VERSION, a2uiSourceOf, supportsA2UIVersion } from './A2UIStream'; +import { a2uiInstructions } from './A2UIInstructions'; +// Types only: the runtime, with the A2UI packages, loads in the browser when a reply first +// shows A2UI. +import type { A2UIReplyState, A2UIRuntime } from './runtime'; + +export { A2UI_DEFAULT_CATALOG_ID }; + +/** What went wrong in a reply's A2UI, as the A2UI specification reports it to an agent. */ +export type A2UIError = { + /** A code such as `VALIDATION_FAILED` or `EXPRESSION_ERROR`. */ + code: string; + message: string; + /** Where in the message it went wrong, as a JSON pointer. */ + path?: string; + surfaceId?: string; +}; + +export type A2UIRendererOptions = { + /** + * The id the agent names the catalog with the app's components by. A surface naming the + * basic catalog shows them too, and a surface naming none is made with this one. + */ + catalogId?: string; + /** + * The icon font the page loads, such as `'Material Symbols Outlined'`, for the icons of A2UI's + * Icon that neither `icons` nor Stream draws. Without one, such an icon shows nothing. + */ + iconFont?: string; + /** + * The app's icons for the names A2UI's Icon takes, such as `shoppingCart`, over Stream's. + * Stream draws about 30 of the 60 names. + */ + icons?: Partial>>>; + /** + * Tells the app what went wrong in a reply's A2UI, such as a message that isn't JSON or a + * component the catalog doesn't have, with the reply's id. The rest of the reply's UI still + * shows. What is wrong with the renderer rather than a reply, such as an app's component that + * can't join the catalog or a runtime that couldn't load, comes without an id. Logged by + * default. + */ + onError?: (error: A2UIError, messageId?: string) => void; +}; + +const reportA2UIError = ( + options: A2UIRendererOptions, + error: A2UIError, + messageId?: string, +) => { + if (options.onError) options.onError(error, messageId); + else console.error(`A reply's A2UI can't show: ${error.code} ${error.message}`, error); +}; + +/** + * Shows the A2UI of one conversation. Each reply's surfaces are its own, kept for as long as + * the host keeps the engine, so what the person entered survives scrolling. + */ +export class A2UIEngine implements AIUIEngine { + readonly catalogId: string; + readonly host: AIUIHost; + readonly iconFont?: string; + readonly icons?: A2UIRendererOptions['icons']; + /** Loads the runtime, the A2UI packages, when a reply first shows A2UI. */ + readonly loader: AIUIRuntimeLoader; + /** Each reply's surfaces, by the reply's id. */ + readonly replies = new Map(); + + private readonly options: A2UIRendererOptions; + + constructor(host: AIUIHost, options: A2UIRendererOptions) { + this.host = host; + this.options = options; + this.catalogId = options.catalogId ?? A2UI_DEFAULT_CATALOG_ID; + this.iconFont = options.iconFont; + this.icons = options.icons; + this.loader = new AIUIRuntimeLoader( + host, + () => import('./runtime'), + (error) => + this.report({ + code: 'EXECUTION_ERROR', + message: `The A2UI renderer couldn't load: ${String(error)}`, + }), + ); + } + + /** + * Tells the app what went wrong, after the current render: a reply's messages are applied + * as it first shows, and an app may set state when told. + */ + report(error: A2UIError, messageId?: string) { + queueMicrotask(() => reportA2UIError(this.options, error, messageId)); + } + + remove(messageId: string) { + this.replies.get(messageId)?.dispose(); + this.replies.delete(messageId); + } + + view(document: AIUIDocument, messageId: string) { + if (a2uiSourceOf(document) === undefined || this.loader.showsText(document)) + return null; + // A finished reply whose A2UI made no surface shows its text instead, once that is known. + if (this.loader.runtime?.showsNothing(this, document, messageId)) return null; + return ( + } + render={({ A2UIReplyContent }) => ( + + )} + /> + ); + } +} + +/** + * The renderer of [A2UI](https://a2ui.org) 1.0 for `AIUIHost`, on the official A2UI packages + * (`@a2ui/web_core` and `@a2ui/react`, which the app installs). The app's components join its + * catalog, so the agent places them like the basic catalog's own. The counterpart of + * `A2UIRenderer` in StreamChatA2UI. + */ +export const createA2UIRenderer = (options: A2UIRendererOptions = {}): AIUIRenderer => { + const catalogId = options.catalogId ?? A2UI_DEFAULT_CATALOG_ID; + return { + // As StreamChatA2UI's: the fence, the catalog and the instructions for both, written with + // the A2UI core, which loads to write them. Without them, when it can't load, the rest still + // goes to the agent's backend. + capabilities: async (components) => { + const fence = `ui:a2ui@${A2UI_VERSION}`; + let instructions: string | undefined; + try { + instructions = await a2uiInstructions({ catalogId, components, fence }); + } catch (error) { + reportA2UIError(options, { + code: 'EXECUTION_ERROR', + message: `The A2UI instructions couldn't be written: ${String(error)}`, + }); + } + return { + catalog_id: catalogId, + fence, + ...(instructions === undefined ? {} : { instructions }), + }; + }, + createEngine: (host) => new A2UIEngine(host, options), + format: 'a2ui', + supports: supportsA2UIVersion, + }; +}; diff --git a/src/plugins/A2UI/A2UIStream.ts b/src/plugins/A2UI/A2UIStream.ts new file mode 100644 index 000000000..26de83960 --- /dev/null +++ b/src/plugins/A2UI/A2UIStream.ts @@ -0,0 +1,82 @@ +import type { AIUIDocument } from '../AIComponents/GenerativeUI/AIUIDocument'; +import { isRecord } from '../AIComponents/GenerativeUI/AIUIHelpers'; + +/** The version of A2UI this renderer reads. */ +export const A2UI_VERSION = '1.0'; + +// The spellings of 1.0 an agent may write, such as from instructions written before its +// release, or by copying the fence's `ui:a2ui@1.0` in another form. +const VERSION_SPELLINGS = new Set(['1', '1.0', '1.0.0', 'v1', 'v1.0']); + +/** Whether a reply's A2UI of `version`, or of no version, is one this renderer reads. */ +export const supportsA2UIVersion = (version: string | undefined) => + version === undefined || VERSION_SPELLINGS.has(version); + +/** + * The complete A2UI messages in `text`, as written: every top-level JSON object, in order. + * One object per line, a pretty-printed one or a JSON array of them all read the same, and + * words around them are skipped. An object still being written is left out until it closes, so + * a reply's UI shows each message as it completes. + */ +export const a2uiMessagesIn = (text: string): string[] => { + const messages: string[] = []; + let depth = 0; + let start = 0; + let inString = false; + let escaped = false; + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (inString) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"' && depth > 0) { + inString = true; + } else if (char === '{') { + if (depth === 0) start = index; + depth++; + } else if (char === '}' && depth > 0) { + depth--; + if (depth === 0) messages.push(text.slice(start, index + 1)); + } + } + return messages; +}; + +/** + * The messages of a document as text: as the agent wrote them, or written out when a backend + * sent them as JSON. `undefined` for content that holds no messages. + */ +export const a2uiSourceOf = (document: AIUIDocument): string | undefined => { + const { content } = document; + if (typeof content === 'string') return content; + if (typeof content === 'object' && content !== null) { + try { + return JSON.stringify(content); + } catch { + return undefined; + } + } + return undefined; +}; + +/** + * A message as the processor reads it: 1.0 in its own spelling, and a surface the agent made + * without naming a catalog made with `catalogId`, the catalog with the app's components. + */ +export const normalizeA2UIMessage = ( + message: Record, + catalogId: string, +): Record => { + let normalized = message; + if (typeof message.version === 'string' && VERSION_SPELLINGS.has(message.version)) { + normalized = { ...normalized, version: A2UI_VERSION }; + } + const { createSurface } = normalized; + if (isRecord(createSurface) && createSurface.catalogId === undefined) { + normalized = { ...normalized, createSurface: { ...createSurface, catalogId } }; + } + return normalized; +}; diff --git a/src/plugins/A2UI/__tests__/A2UIInstructions.test.ts b/src/plugins/A2UI/__tests__/A2UIInstructions.test.ts new file mode 100644 index 000000000..a82f76b33 --- /dev/null +++ b/src/plugins/A2UI/__tests__/A2UIInstructions.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment node +// What an agent is told is written on a server or in a build, with no page. +import { parseAIUIComponentDefinition } from '../../AIComponents'; +import { + A2UI_DEFAULT_CATALOG_ID, + a2uiInstructions, + A2UIInstructionsError, + a2uiProblems, +} from '..'; +import { ohridLines, uiComponents } from './ohrid'; + +const components = uiComponents.flatMap((json) => { + const definition = parseAIUIComponentDefinition(json); + return definition ? [definition] : []; +}); + +// The basic catalog's part the Swift assistant's `a2ui/prompt.json` keeps. +const keep = [ + 'Text', + 'Icon', + 'Row', + 'Column', + 'List', + 'Card', + 'Tabs', + 'Divider', + 'Button', + 'TextField', + 'CheckBox', + 'ChoicePicker', + 'Slider', + 'DateTimeInput', + 'formatString', + 'formatDate', + 'formatNumber', + 'openUrl', + 'required', +]; + +const example = (lines: string[]) => + [ + 'Example — a weekend trip:', + 'A weekend in Ohrid by the lake: a map, your calendar and a plan for each day.', + `\`\`\`ui:a2ui@1.0\n${lines.join('\n')}\n\`\`\``, + ].join('\n\n'); + +const line = (message: Record) => + JSON.stringify({ version: '1.0', ...message }); + +const surface = (components: Record[]) => [ + line({ createSurface: { catalogId: A2UI_DEFAULT_CATALOG_ID, surfaceId: 's' } }), + line({ updateComponents: { components, surfaceId: 's' } }), +]; + +describe('a2uiInstructions', () => { + it('writes what StreamChatA2UI writes from the same prompt.json, in its order', async () => { + const text = await a2uiInstructions({ + components, + examples: [example(ohridLines)], + keep, + notes: ['- PlaceCard, TripMap and CalendarCheck are the phone’s own views.'], + preamble: 'Some answers work better as interactive UI than as text.', + rules: ['Never add images for places.', 'Write the whole UI again.'], + }); + + const headings = text.split('\n').filter((row) => /^#{1,2} /.test(row)); + expect(headings).toEqual([ + '## Writing UI in A2UI', + '# Generating user interfaces with A2UI v1.0', + '## Catalog Guidelines', + "## The app's components", + '## The catalog `getstream.io:app-catalog`', + '## Rules', + '## Examples', + ]); + expect(text.startsWith('Some answers work better')).toBe(true); + expect(text).toContain('whose info string is `ui:a2ui@1.0`'); + expect(text).toContain('the `catalogId` `getstream.io:app-catalog`'); + expect(text).toContain( + '1. Never add images for places.\n2. Write the whole UI again.', + ); + + const catalog = JSON.parse( + (text.match(/```json\n(\{.*\})\n```/) ?? [])[1] ?? '{}', + ) as { catalogId: string; components: Record }; + expect(catalog.catalogId).toBe(A2UI_DEFAULT_CATALOG_ID); + expect(Object.keys(catalog.components)).toEqual( + expect.arrayContaining(['PlaceCard', 'TripMap', 'CalendarCheck', 'Text', 'Tabs']), + ); + expect(catalog.components).not.toHaveProperty('Video'); + expect(catalog.components).not.toHaveProperty('Modal'); + expect(catalog.components.CalendarCheck).toEqual({ + description: + "The person's own calendar for the days of a plan, read on the phone. You never see it: use it whenever a plan has dates, so they can see what the trip would clash with.", + properties: { + component: { const: 'CalendarCheck' }, + end: { + description: 'The last day, as YYYY-MM-DD', + format: 'date', + type: 'string', + }, + start: { + description: 'The first day, as YYYY-MM-DD', + format: 'date', + type: 'string', + }, + }, + required: ['component', 'end', 'start'], + type: 'object', + }); + expect(JSON.stringify(catalog.components.PlaceCard)).toContain( + 'Tapping the card asks you about the place.', + ); + }); + + it('writes nothing when an example would not render, and says why', async () => { + const broken = [ + ...ohridLines.slice(0, 2), + line({ + updateComponents: { + components: [{ component: 'Globe', id: 'map' }], + surfaceId: 'ohrid-weekend', + }, + }), + ]; + const asked = a2uiInstructions({ components, examples: [example(broken)], keep }); + await expect(asked).rejects.toBeInstanceOf(A2UIInstructionsError); + await expect(asked).rejects.toMatchObject({ + problems: expect.arrayContaining([expect.stringMatching(/^example: map: .*Globe/)]), + }); + }); +}); + +describe('a2uiProblems', () => { + it('finds nothing in the Ohrid plan', async () => { + expect(await a2uiProblems(ohridLines.join('\n'), { components })).toEqual([]); + }); + + it('says what doesn’t fit the catalog: components, properties and their values', async () => { + const problems = await a2uiProblems( + surface([ + { children: ['title', 'map', 'go'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'title', size: 3, text: 'A weekend' }, + { component: 'Globe', id: 'map' }, + { child: 'title', component: 'Button', id: 'go' }, + ]).join('\n'), + { components }, + ); + expect(problems).toEqual([ + expect.stringMatching(/^go: .*action/), + expect.stringMatching(/^map: .*Globe/), + expect.stringMatching(/^title: .*size/), + ]); + }); + + it('says what the components make together: references, root, orphans, cycles and ids', async () => { + const dangling = await a2uiProblems( + surface([ + { children: ['a', 'gone'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'a', text: 'Here' }, + { component: 'Text', id: 'lost', text: 'Nobody holds this' }, + ]).join('\n'), + ); + expect(dangling).toEqual([ + 'root holds gone, which is not defined', + 'lost is defined but nothing holds it', + ]); + + expect( + await a2uiProblems( + surface([{ component: 'Text', id: 'top', text: 'Hi' }]).join('\n'), + ), + ).toEqual(['surface s has no component with the id root']); + + const cycle = await a2uiProblems( + surface([ + { children: ['loop'], component: 'Column', id: 'root' }, + { children: ['root'], component: 'Column', id: 'loop' }, + ]).join('\n'), + ); + expect(cycle).toEqual([expect.stringMatching(/holds itself through its children/)]); + + const named = await a2uiProblems( + surface([ + { children: ['saturday-plan'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'saturday-plan', text: 'Morning' }, + ]).join('\n'), + ); + expect(named).toEqual([expect.stringMatching(/^saturday-plan: .*identifier/)]); + }); + + it('says what is wrong with the messages themselves', async () => { + const problems = await a2uiProblems( + [ + '{not json}', + line({ updateComponents: { components: [], surfaceId: 'later' } }), + line({ callRendererFunction: { name: 'openUrl' } }), + line({ createSurface: { catalogId: 'someone-else', surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { component: 'Text', id: 'root', text: 'One' }, + { component: 'Text', id: 'root', text: 'Two' }, + ], + surfaceId: 's', + }, + }), + ].join('\n'), + ); + expect(problems).toEqual([ + "this isn't JSON: {not json}", + 'surface later gets components before it is created', + "a reply's UI calls no functions", + 'surface s names the catalog someone-else, not getstream.io:app-catalog', + 'surface s defines root more than once in one message', + ]); + expect( + await a2uiProblems( + [ + ...surface([{ component: 'Text', id: 'root', text: 'One' }]), + line({ createSurface: { catalogId: A2UI_DEFAULT_CATALOG_ID, surfaceId: 's' } }), + ].join('\n'), + ), + ).toEqual([expect.stringMatching(/^surface s is created again/)]); + expect( + await a2uiProblems( + [ + ...surface([{ component: 'Text', id: 'root', text: 'One' }]), + line({ deleteSurface: { surfaceId: 's' } }), + ...surface([{ component: 'Text', id: 'root', text: 'Two' }]), + ].join('\n'), + ), + ).toEqual([]); + expect(await a2uiProblems('Just words.')).toEqual([ + 'nothing renders: there is no createSurface', + ]); + }); +}); diff --git a/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx b/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx new file mode 100644 index 000000000..9023ceb35 --- /dev/null +++ b/src/plugins/A2UI/__tests__/A2UIRenderer.test.tsx @@ -0,0 +1,1021 @@ +import React, { StrictMode } from 'react'; +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; + +import { + AIConversation, + type AIMessage, + type AIUIAction, + AIUIHost, + defineAIUIComponent, + fromStreamMessage, + parseAIUIComponentDefinition, +} from '../../AIComponents'; +import { renderWithEnglish } from '../../AIComponents/__tests__/renderWithEnglish'; +import { + A2UI_DEFAULT_CATALOG_ID, + type A2UIRendererOptions, + createA2UIRenderer, +} from '..'; +import { a2uiMessagesIn, normalizeA2UIMessage, supportsA2UIVersion } from '../A2UIStream'; +import { a2uiAppCatalogDocument, a2uiTypeName } from '../catalogDocument'; +import { ohridLines, uiComponents } from './ohrid'; + +const definitionOf = (name: string) => { + const definition = parseAIUIComponentDefinition( + uiComponents.find((component) => component.name === name), + ); + if (!definition) throw new Error(`no ${name}`); + return definition; +}; + +const placeCard = defineAIUIComponent<{ note?: string; title: string }>( + definitionOf('place_card'), + ({ args, context }) => ( + + ), +); + +const tripMap = defineAIUIComponent<{ places: { name: string }[] }>( + definitionOf('trip_map'), + ({ args }) => ( +

{args.places.map(({ name }) => name).join(', ')}

+ ), +); + +const calendarCheck = defineAIUIComponent<{ end: Date; start: Date }>( + definitionOf('calendar_check'), + ({ args }) => ( +

+ {args.start.getDate()}–{args.end.getDate()} +

+ ), +); + +const setup = (options: A2UIRendererOptions = {}) => { + const sent: AIUIAction[] = []; + const errors: { code: string; message: string }[] = []; + const open = vi.fn(); + const ui = new AIUIHost({ + components: [placeCard, tripMap, calendarCheck], + open, + renderers: [ + createA2UIRenderer({ ...options, onError: (error) => errors.push(error) }), + ], + send: (action) => { + sent.push(action); + }, + }); + return { errors, open, sent, ui }; +}; + +const question: AIMessage = { id: 'u1', role: 'user', text: 'Plan a weekend in Ohrid' }; +const replyWith = (lines: string[], status = 'complete', text = 'A weekend in Ohrid.') => + fromStreamMessage({ + ai_generated: true, + ai_ui: { content: lines.join('\n'), format: 'a2ui', status, v: 1, version: '1.0' }, + attachments: [], + id: 'a1', + text, + }); + +const line = (message: Record) => + JSON.stringify({ version: '1.0', ...message }); + +describe('a2uiMessagesIn', () => { + it('reads the messages one per line, in an array, pretty-printed, or among words', () => { + const one = '{"version":"1.0","deleteSurface":{"surfaceId":"a"}}'; + const two = '{"version":"1.0","deleteSurface":{"surfaceId":"b"}}'; + expect(a2uiMessagesIn(`${one}\n${two}`)).toEqual([one, two]); + expect(a2uiMessagesIn(`[${one},${two}]`)).toEqual([one, two]); + expect(a2uiMessagesIn(`Here it is:\n${one}\nand more.`)).toEqual([one]); + expect( + a2uiMessagesIn('{\n "version": "1.0",\n "deleteSurface": {"surfaceId": "a"}\n}'), + ).toHaveLength(1); + }); + + it('leaves out a message still being written', () => { + expect(a2uiMessagesIn('{"version":"1.0"}\n{"version":"1.0","updateComp')).toEqual([ + '{"version":"1.0"}', + ]); + }); + + it('is not misled by braces and quotes inside strings', () => { + const tricky = '{"text":"a } and a \\" and a {"}'; + expect(a2uiMessagesIn(tricky)).toEqual([tricky]); + }); +}); + +describe('the A2UI versions and catalog it reads', () => { + it('reads 1.0 in the spellings an agent writes, and no other version', () => { + for (const version of [undefined, '1', '1.0', '1.0.0', 'v1', 'v1.0']) { + expect(supportsA2UIVersion(version)).toBe(true); + } + for (const version of ['0.9', '2.0', 'v0.8']) { + expect(supportsA2UIVersion(version)).toBe(false); + } + }); + + it('writes every message as 1.0, and names the app’s catalog for a surface naming none', () => { + expect(normalizeA2UIMessage({ version: 'v1.0' }, 'app').version).toBe('1.0'); + expect( + normalizeA2UIMessage({ createSurface: { surfaceId: 's' }, version: '1.0' }, 'app'), + ).toEqual({ createSurface: { catalogId: 'app', surfaceId: 's' }, version: '1.0' }); + expect( + normalizeA2UIMessage( + { createSurface: { catalogId: 'other', surfaceId: 's' }, version: '1.0' }, + 'app', + ).createSurface, + ).toEqual({ catalogId: 'other', surfaceId: 's' }); + }); + + it('describes the app’s components to the agent as StreamChatA2UI does', () => { + expect(a2uiTypeName('place_card')).toBe('PlaceCard'); + const document = a2uiAppCatalogDocument(A2UI_DEFAULT_CATALOG_ID, [placeCard]); + expect(document.components.PlaceCard).toMatchObject({ + description: + 'One place of a plan, with its photo and a short summary, which the phone loads from Wikipedia, so it needs no image URL. Use one per sight, beach, monastery, village or day trip. Tapping the card asks you about the place.', + required: ['component', 'title'], + }); + }); + + it('tells the agent’s backend its fence, catalog and instructions, as StreamChatA2UI does', async () => { + const { ui } = setup(); + const { renderers } = await ui.capabilities(); + expect(renderers).toEqual([ + { + catalog_id: A2UI_DEFAULT_CATALOG_ID, + fence: 'ui:a2ui@1.0', + format: 'a2ui', + instructions: expect.any(String), + }, + ]); + const { instructions } = renderers[0] as { instructions: string }; + expect(instructions).toContain('whose info string is `ui:a2ui@1.0`'); + expect(instructions).toContain(`## The catalog \`${A2UI_DEFAULT_CATALOG_ID}\``); + expect(instructions).toContain('"PlaceCard"'); + }); +}); + +describe('a reply written in A2UI', () => { + it('shows the Ohrid plan with the basic catalog and the app’s components', async () => { + const { errors, ui } = setup(); + renderWithEnglish( + , + ); + + await waitFor(() => + expect(screen.getByTestId('trip-map')).toHaveTextContent( + 'Church of St. John at Kaneo', + ), + ); + expect(screen.getByTestId('calendar-check')).toHaveTextContent('14–15'); + expect(screen.getAllByTestId('place-card')[0]).toHaveTextContent('Plaošnik (1 hour)'); + expect(document.body).toHaveTextContent('Two easy days by the lake'); + expect(screen.queryByText('A weekend in Ohrid.')).not.toBeInTheDocument(); + expect(errors).toEqual([]); + }); + + it('tells the agent what the person did, in their words', async () => { + const { sent, ui } = setup(); + renderWithEnglish( + , + ); + + const update = await screen.findByRole('button', { name: 'Update the plan' }); + act(() => { + fireEvent.click(update); + }); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0]).toEqual({ + format: 'a2ui', + label: 'Update the plan', + messageId: 'a1', + name: 'update_plan', + payload: { + context: { interests: [], pace: ['relaxed'] }, + sourceComponentId: 'update', + surfaceId: 'ohrid-weekend', + }, + }); + + fireEvent.click(screen.getAllByTestId('place-card')[0] as HTMLElement); + await waitFor(() => expect(sent).toHaveLength(2)); + expect(sent[1]).toEqual({ + format: 'a2ui', + label: 'Tell me more about Plaošnik', + messageId: 'a1', + name: 'ask_about', + payload: { component: 'place_card', title: 'Plaošnik' }, + }); + }); + + it('names an action by what its button says when the agent gave it no words', async () => { + const { sent, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { + action: { event: { name: 'book' } }, + child: 'label', + component: 'Button', + id: 'root', + }, + { component: 'Text', id: 'label', text: 'Book the boat' }, + ], + surfaceId: 's', + }, + }), + ]; + renderWithEnglish(); + const book = await screen.findByRole('button', { name: 'Book the boat' }); + act(() => { + fireEvent.click(book); + }); + await waitFor(() => expect(sent[0]?.label).toBe('Book the boat')); + }); + + it('follows a reply as the agent writes it, a message at a time', async () => { + const { ui } = setup(); + const { rerender } = renderWithEnglish( + , + ); + await waitFor(() => + expect(document.body).toHaveTextContent('Two easy days by the lake'), + ); + expect(screen.queryByTestId('trip-map')).not.toBeInTheDocument(); + + rerender( + , + ); + await waitFor(() => expect(screen.getByTestId('trip-map')).toBeInTheDocument()); + }); + + it('starts the surfaces again when the agent rewrites what it had written', async () => { + const { ui } = setup(); + const surface = line({ createSurface: { surfaceId: 's' } }); + const text = (words: string) => + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: words }], + surfaceId: 's', + }, + }); + const { rerender } = renderWithEnglish( + , + ); + await waitFor(() => expect(document.body).toHaveTextContent('First draft')); + rerender( + , + ); + await waitFor(() => expect(document.body).toHaveTextContent('Second draft')); + expect(document.body).not.toHaveTextContent('First draft'); + }); + + it('shows the rest of a batch that one bad component spoils, and says what went wrong', async () => { + const { errors, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { children: ['good', 'bad'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'good', text: 'Still here' }, + { component: 'Slider', id: 'bad', value: 'not a number' }, + ], + surfaceId: 's', + }, + }), + ]; + renderWithEnglish(); + await waitFor(() => expect(document.body).toHaveTextContent('Still here')); + expect(errors).toEqual([ + expect.objectContaining({ code: 'VALIDATION_FAILED', surfaceId: 's' }), + ]); + }); + + it('shows the rest of the components a surface is made with when one of them is bad', async () => { + const { errors, ui } = setup(); + const lines = [ + line({ + createSurface: { + components: [ + { children: ['good', 'bad'], component: 'Column', id: 'root' }, + { component: 'Text', id: 'good', text: 'Still here' }, + { component: 'Slider', id: 'bad', value: 'not a number' }, + ], + surfaceId: 's', + }, + }), + ]; + renderWithEnglish(); + await waitFor(() => expect(document.body).toHaveTextContent('Still here')); + expect(errors).toEqual([ + expect.objectContaining({ code: 'VALIDATION_FAILED', surfaceId: 's' }), + ]); + }); + + it('says a message is not JSON, and shows the rest', async () => { + const { errors, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + '{not json}', + line({ + updateComponents: { + components: [{ component: 'Text', id: 'root', text: 'After the bad one' }], + surfaceId: 's', + }, + }), + ]; + renderWithEnglish(); + await waitFor(() => expect(document.body).toHaveTextContent('After the bad one')); + expect(errors.map(({ message }) => message)).toContain( + 'A message isn’t a JSON object', + ); + }); + + it('opens links the way the host allows, from the person’s tap only', async () => { + const { open, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { + action: { + functionCall: { + '@call': 'openUrl', + args: { url: 'https://en.wikipedia.org/wiki/Ohrid' }, + }, + }, + child: 'label', + component: 'Button', + id: 'root', + }, + { component: 'Text', id: 'label', text: 'Read about Ohrid' }, + ], + surfaceId: 's', + }, + }), + ]; + renderWithEnglish(); + const read = await screen.findByRole('button', { name: 'Read about Ohrid' }); + expect(open).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(read); + }); + await waitFor(() => + expect(open).toHaveBeenCalledWith(new URL('https://en.wikipedia.org/wiki/Ohrid')), + ); + }); + + it('opens the links of its Markdown the way the host allows, not wherever they point', async () => { + const { open, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { + component: 'Text', + id: 'root', + text: 'Read [about Ohrid](https://en.wikipedia.org/wiki/Ohrid) first.', + }, + ], + surfaceId: 's', + }, + }), + ]; + renderWithEnglish(); + const link = await screen.findByRole('link', { name: 'about Ohrid' }); + const followed = fireEvent.click(link); + expect(followed).toBe(false); + expect(open).toHaveBeenCalledWith(new URL('https://en.wikipedia.org/wiki/Ohrid')); + }); + + it('opens a Modal as a dialog from its trigger, and closes it from Escape and the backdrop', async () => { + const { sent, ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { component: 'Modal', content: 'details', id: 'root', trigger: 'open' }, + { + action: { event: { name: 'opened_details' } }, + child: 'open_label', + component: 'Button', + id: 'open', + }, + { component: 'Text', id: 'open_label', text: 'Opening times' }, + { component: 'Text', id: 'details', text: 'Open daily, 9 to 5.' }, + ], + surfaceId: 's', + }, + }), + ]; + const { container } = renderWithEnglish( + , + ); + const trigger = await screen.findByRole('button', { name: 'Opening times' }); + const dialog = container.querySelector( + 'dialog.str-chat__a2ui-modal', + ) as HTMLDialogElement; + expect(dialog.open).toBe(false); + expect(document.body).not.toHaveTextContent('Open daily, 9 to 5.'); + + act(() => { + fireEvent.click(trigger); + }); + await waitFor(() => expect(dialog.open).toBe(true)); + await waitFor(() => expect(dialog).toHaveTextContent('Open daily, 9 to 5.')); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + // the trigger's own action still runs + await waitFor(() => expect(sent[0]?.name).toBe('opened_details')); + + act(() => { + fireEvent(dialog, new Event('cancel', { cancelable: true })); + }); + await waitFor(() => expect(dialog.open).toBe(false)); + + act(() => { + fireEvent.click(trigger); + }); + await waitFor(() => expect(dialog.open).toBe(true)); + act(() => { + fireEvent.click(dialog); + }); + await waitFor(() => expect(dialog.open).toBe(false)); + }); + + it('opens a Modal whose trigger is not a button from the keyboard', async () => { + const { ui } = setup(); + const lines = [ + line({ createSurface: { surfaceId: 's' } }), + line({ + updateComponents: { + components: [ + { component: 'Modal', content: 'details', id: 'root', trigger: 'label' }, + { component: 'Text', id: 'label', text: 'See the opening times' }, + { component: 'Text', id: 'details', text: 'Open daily, 9 to 5.' }, + ], + surfaceId: 's', + }, + }), + ]; + const { container } = renderWithEnglish( + , + ); + const trigger = await screen.findByRole('button', { name: /See the opening times/ }); + expect(trigger).toHaveAttribute('tabindex', '0'); + act(() => { + fireEvent.keyDown(trigger, { key: 'Enter' }); + }); + const dialog = container.querySelector( + 'dialog.str-chat__a2ui-modal', + ) as HTMLDialogElement; + await waitFor(() => expect(dialog.open).toBe(true)); + }); + + it('shows the text of a finished reply whose A2UI made no surface, from history too', async () => { + const { ui } = setup(); + // The renderer is not loaded yet, as on a page that opens on the conversation. + renderWithEnglish( + , + ); + await waitFor(() => expect(screen.getByText('Just words')).toBeInTheDocument()); + }); + + it('shows the text of a reply whose A2UI turns out to make no surface once written', async () => { + const { ui } = setup(); + const unfinished = [line({ deleteSurface: { surfaceId: 'x' } })]; + const { rerender } = renderWithEnglish( + , + ); + await waitFor(() => + expect(document.querySelector('.str-chat__a2ui--loading')).toBeInTheDocument(), + ); + rerender( + , + ); + await waitFor(() => expect(screen.getByText('Just words')).toBeInTheDocument()); + }); +}); + +const surfaceWith = (components: Record[], dataModel = {}) => [ + line({ createSurface: { dataModel, surfaceId: 's' } }), + line({ updateComponents: { components, surfaceId: 's' } }), +]; + +describe('what goes wrong in a reply’s A2UI', () => { + it('is told after the conversation renders, so the app may set state then', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const App = () => { + const [count, setCount] = React.useState(0); + const [ui] = React.useState( + () => + new AIUIHost({ + renderers: [createA2UIRenderer({ onError: () => setCount((n) => n + 1) })], + send: vi.fn(), + }), + ); + return ( + <> + {count} + + + ); + }; + renderWithEnglish(); + await waitFor(() => expect(screen.getByTestId('errors')).not.toHaveTextContent('0')); + const warnings = logged.mock.calls.filter((call) => + String(call[0]).includes('Cannot update a component'), + ); + expect(warnings).toEqual([]); + logged.mockRestore(); + }); +}); + +describe('a reply written in A2UI, under StrictMode', () => { + it('shows each surface once, follows the stream, and sends an action once', async () => { + const { errors, sent, ui } = setup(); + const strict = (lines: string[], status: string) => ( + + + + ); + const { container, rerender } = renderWithEnglish( + strict(ohridLines.slice(0, 2), 'streaming'), + ); + await waitFor(() => + expect(document.body).toHaveTextContent('Two easy days by the lake'), + ); + rerender(strict(ohridLines, 'complete')); + + const update = await screen.findByRole('button', { name: 'Update the plan' }); + expect(container.querySelectorAll('.str-chat__a2ui__surface')).toHaveLength(1); + expect( + screen.getAllByText('Two easy days by the lake', { exact: false }), + ).toHaveLength(1); + expect(screen.getAllByTestId('trip-map')).toHaveLength(1); + act(() => { + fireEvent.click(update); + }); + await waitFor(() => expect(sent).toHaveLength(1)); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(sent).toHaveLength(1); + expect(errors).toEqual([]); + }); +}); + +describe('the basic catalog in Stream’s look', () => { + it('draws Buttons as Stream’s, with their label as words', async () => { + const { ui } = setup(); + const lines = surfaceWith([ + { children: ['go', 'later'], component: 'Row', id: 'root' }, + { + action: { event: { name: 'go' } }, + child: 'go_label', + component: 'Button', + id: 'go', + variant: 'primary', + }, + { component: 'Text', id: 'go_label', text: '**Book** it' }, + { + action: { event: { name: 'later' } }, + child: 'later_label', + component: 'Button', + id: 'later', + variant: 'borderless', + }, + { component: 'Text', id: 'later_label', text: 'Later' }, + ]); + renderWithEnglish(); + + const book = await screen.findByRole('button', { name: '**Book** it' }); + expect(book).toHaveClass('str-chat__button--primary', 'str-chat__button--solid'); + expect(book.querySelector('p, strong')).toBeNull(); + // Borderless reads as a link, in the accent colour, rather than as grey words; in a Row it + // is no follow-up. + const later = screen.getByRole('button', { name: 'Later' }); + expect(later).toHaveClass('str-chat__button--ghost', 'str-chat__button--primary'); + expect(later).not.toHaveClass('str-chat__a2ui-button--follow-up'); + }); + + it('draws the borderless Buttons of a Column as follow-ups, parted by hairlines', async () => { + const { ui } = setup(); + const followUp = (id: string, text: string) => [ + { + action: { event: { name: 'ask', userMessage: text } }, + child: `${id}_label`, + component: 'Button', + id, + variant: 'borderless', + }, + { component: 'Text', id: `${id}_label`, text }, + ]; + const lines = surfaceWith([ + { children: ['go', 'hotel', 'eat'], component: 'Column', id: 'root' }, + { + action: { event: { name: 'go' } }, + child: 'go_label', + component: 'Button', + id: 'go', + variant: 'primary', + }, + { component: 'Text', id: 'go_label', text: 'Update the plan' }, + ...followUp('hotel', 'Find a hotel'), + ...followUp('eat', 'Where should we eat?'), + ]); + renderWithEnglish(); + + const hotel = await screen.findByRole('button', { name: 'Find a hotel' }); + const eat = screen.getByRole('button', { name: 'Where should we eat?' }); + expect(hotel).toHaveClass('str-chat__a2ui-button--follow-up'); + // The first follows the primary Button, not another follow-up, so no hairline. + expect(hotel).not.toHaveClass('str-chat__a2ui-button--follow-up-after'); + expect(eat).toHaveClass( + 'str-chat__a2ui-button--follow-up', + 'str-chat__a2ui-button--follow-up-after', + ); + }); + + it('draws Markdown and captions, in a Card', async () => { + const { ui } = setup(); + const lines = surfaceWith([ + { child: 'column', component: 'Card', id: 'root' }, + { children: ['title', 'body', 'note'], component: 'Column', id: 'column' }, + { component: 'Text', id: 'title', text: '### Saturday' }, + { component: 'Text', id: 'body', text: 'Coffee by the **harbour**.' }, + { component: 'Text', id: 'note', text: 'Opens at 8', variant: 'caption' }, + ]); + const { container } = renderWithEnglish( + , + ); + + const title = await screen.findByRole('heading', { level: 3, name: 'Saturday' }); + expect(title.closest('.str-chat__a2ui-card')).not.toBeNull(); + expect(screen.getByText('harbour').tagName).toBe('STRONG'); + expect(screen.getByText('Opens at 8')).toHaveClass('str-chat__a2ui-text--caption'); + expect(container.querySelector('a2ui-card, a2ui-basic-text')).toBeNull(); + }); + + it('keeps what the person types in the data model, and says what its checks found', async () => { + const { sent, ui } = setup(); + const lines = surfaceWith( + [ + { children: ['name', 'notes', 'send'], component: 'Column', id: 'root' }, + { + checks: [ + { + condition: { '@call': 'required', args: { value: { '@path': '/name' } } }, + message: 'Say who is coming', + }, + ], + component: 'TextField', + id: 'name', + label: 'Who is coming', + value: { '@path': '/name' }, + }, + { + component: 'TextField', + id: 'notes', + label: 'Anything else', + value: { '@path': '/notes' }, + variant: 'longText', + }, + { + action: { + event: { + context: { name: { '@path': '/name' }, notes: { '@path': '/notes' } }, + name: 'book', + }, + }, + child: 'send_label', + component: 'Button', + id: 'send', + }, + { component: 'Text', id: 'send_label', text: 'Book' }, + ], + { name: '', notes: '' }, + ); + renderWithEnglish(); + + const name = await screen.findByRole('textbox', { name: 'Who is coming' }); + expect(name).toHaveAttribute('aria-invalid', 'true'); + expect(name).toHaveAccessibleDescription('Say who is coming'); + act(() => { + fireEvent.change(name, { target: { value: 'Ana and Marko' } }); + }); + await waitFor(() => expect(name).toHaveAttribute('aria-invalid', 'false')); + expect(screen.queryByText('Say who is coming')).not.toBeInTheDocument(); + + const notes = screen.getByRole('textbox', { name: 'Anything else' }); + expect(notes.tagName).toBe('TEXTAREA'); + act(() => { + fireEvent.change(notes, { target: { value: 'A room by the lake' } }); + }); + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Book' })); + }); + await waitFor(() => + expect(sent[0]?.payload).toMatchObject({ + context: { name: 'Ana and Marko', notes: 'A room by the lake' }, + }), + ); + }); + + it('chooses one option or several, as chips or check marks, and filters them', async () => { + const { sent, ui } = setup(); + const lines = surfaceWith( + [ + { children: ['pace', 'interests', 'send'], component: 'Column', id: 'root' }, + { + component: 'ChoicePicker', + displayStyle: 'chips', + id: 'pace', + label: 'Pace', + options: [ + { label: 'Relaxed', value: 'relaxed' }, + { label: 'Packed', value: 'packed' }, + ], + value: { '@path': '/pace' }, + }, + { + component: 'ChoicePicker', + filterable: true, + id: 'interests', + label: 'More of', + options: [ + { label: 'History', value: 'history' }, + { label: 'Food', value: 'food' }, + { label: 'Nature', value: 'nature' }, + ], + value: { '@path': '/interests' }, + variant: 'multipleSelection', + }, + { + action: { + event: { + context: { + interests: { '@path': '/interests' }, + pace: { '@path': '/pace' }, + }, + name: 'update_plan', + }, + }, + child: 'send_label', + component: 'Button', + id: 'send', + }, + { component: 'Text', id: 'send_label', text: 'Update the plan' }, + ], + { interests: [], pace: ['relaxed'] }, + ); + renderWithEnglish(); + + const pace = await screen.findByRole('group', { name: 'Pace' }); + const relaxed = within(pace).getByRole('button', { name: 'Relaxed' }); + const packed = within(pace).getByRole('button', { name: 'Packed' }); + expect(relaxed).toHaveAttribute('aria-pressed', 'true'); + act(() => { + fireEvent.click(packed); + }); + await waitFor(() => expect(packed).toHaveAttribute('aria-pressed', 'true')); + expect(relaxed).toHaveAttribute('aria-pressed', 'false'); + + const interests = screen.getByRole('group', { name: 'More of' }); + act(() => { + fireEvent.click(within(interests).getByRole('checkbox', { name: 'History' })); + }); + act(() => { + fireEvent.click(within(interests).getByRole('checkbox', { name: 'Food' })); + }); + act(() => { + fireEvent.change(within(interests).getByRole('searchbox', { name: 'Search' }), { + target: { value: 'nat' }, + }); + }); + expect(within(interests).queryByRole('checkbox', { name: 'Food' })).toBeNull(); + expect(within(interests).getByRole('checkbox', { name: 'Nature' })).not.toBeChecked(); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Update the plan' })); + }); + await waitFor(() => + expect(sent[0]?.payload).toMatchObject({ + context: { interests: ['history', 'food'], pace: ['packed'] }, + }), + ); + }); + + it('draws icons as Stream’s or the app’s, in its icon font, or as nothing', async () => { + const Cart = () => ; + const icons = (names: string[]) => + surfaceWith([ + { children: names, component: 'Row', id: 'root' }, + ...names.map((name) => ({ component: 'Icon', id: name, name })), + ]); + const names = ['search', 'shoppingCart', 'payment']; + + const withFont = setup({ + iconFont: 'Material Symbols Outlined', + icons: { shoppingCart: Cart }, + }); + const { unmount } = renderWithEnglish( + , + ); + const search = await screen.findByRole('img', { name: 'search' }); + expect(search.querySelector('svg.str-chat__icon--search')).not.toBeNull(); + expect( + within(screen.getByRole('img', { name: 'shopping cart' })).getByTestId('cart'), + ).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'payment' })).toHaveTextContent('payment'); + unmount(); + + const { ui } = setup(); + renderWithEnglish( + , + ); + await screen.findByRole('img', { name: 'search' }); + expect(screen.getByRole('img', { name: 'payment' })).toHaveTextContent(''); + expect(screen.getByRole('img', { name: 'shopping cart' })).toHaveTextContent(''); + }); + + it('tells assistive technology what the agent says about its parts, as A2UI’s own parts do', async () => { + const { ui } = setup(); + const lines = surfaceWith( + [ + { + accessibility: { label: 'The plan for Saturday' }, + child: 'column', + component: 'Card', + id: 'root', + }, + { + children: ['status', 'go', 'name', 'pace', 'pin'], + component: 'Column', + id: 'column', + }, + { + accessibility: { live: 'polite' }, + component: 'Text', + id: 'status', + text: 'Two places so far', + }, + { + accessibility: { + description: 'Asks Juno to plan the boat ride', + label: { '@path': '/boat' }, + }, + action: { event: { name: 'go' } }, + child: 'go_label', + component: 'Button', + id: 'go', + }, + { component: 'Text', id: 'go_label', text: 'Go' }, + { + accessibility: { label: 'Your name' }, + component: 'TextField', + id: 'name', + label: 'Name', + value: { '@path': '/name' }, + }, + { + accessibility: { label: 'How busy the days are' }, + component: 'ChoicePicker', + id: 'pace', + label: 'Pace', + options: [{ label: 'Easy', value: 'easy' }], + value: { '@path': '/pace' }, + }, + { + accessibility: { label: 'The meeting point' }, + component: 'Icon', + id: 'pin', + name: 'locationOn', + }, + ], + { boat: 'Book the boat to St. Naum', name: '', pace: [] }, + ); + renderWithEnglish(); + + const book = await screen.findByRole('button', { name: 'Book the boat to St. Naum' }); + expect(book).toHaveAttribute('aria-description', 'Asks Juno to plan the boat ride'); + expect( + screen.getByRole('group', { name: 'The plan for Saturday' }), + ).toBeInTheDocument(); + expect(screen.getByText('Two places so far').closest('[aria-live]')).toHaveAttribute( + 'aria-live', + 'polite', + ); + expect(screen.getByRole('textbox', { name: 'Your name' })).toBeInTheDocument(); + expect( + screen.getByRole('radiogroup', { name: 'How busy the days are' }), + ).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'The meeting point' })).toBeInTheDocument(); + }); +}); + +describe('a reply’s parts coming in', () => { + const text = (id: string, words: string) => ({ component: 'Text', id, text: words }); + const column = (children: string[]) => ({ children, component: 'Column', id: 'root' }); + + it('shows a reply from history whole, its parts with it', async () => { + const { ui } = setup(); + const lines = surfaceWith([ + column(['a', 'b']), + text('a', 'First'), + text('b', 'Second'), + ]); + renderWithEnglish(); + const second = await screen.findByText('Second'); + expect(second.closest('[data-entrance]')).toHaveAttribute('data-entrance', 'shown'); + expect(screen.getByText('First').closest('[data-entrance]')).toHaveAttribute( + 'data-entrance', + 'shown', + ); + }); + + it('brings in a part the agent writes once the reply has come in', async () => { + const { ui } = setup(); + const first = surfaceWith([column(['a']), text('a', 'First')]); + const { rerender } = renderWithEnglish( + , + ); + const stage = (await screen.findByText('First')).closest('.str-chat__ai-ui'); + await waitFor(() => expect(stage).toHaveAttribute('data-entrance', 'shown')); + const more = [ + ...first, + line({ + updateComponents: { + components: [column(['a', 'b']), text('b', 'Second')], + surfaceId: 's', + }, + }), + ]; + rerender( + , + ); + const second = (await screen.findByText('Second')).closest('[data-entrance]'); + expect(second).toHaveAttribute('data-entrance', 'hidden'); + await waitFor(() => expect(second).toHaveAttribute('data-entrance', 'shown')); + }); +}); diff --git a/src/plugins/A2UI/__tests__/ohrid.ts b/src/plugins/A2UI/__tests__/ohrid.ts new file mode 100644 index 000000000..b519b4372 --- /dev/null +++ b/src/plugins/A2UI/__tests__/ohrid.ts @@ -0,0 +1,107 @@ +// The Swift assistant's "Plan a weekend in Ohrid" example: the A2UI an agent is prompted to +// write (`a2ui/prompt.json`) and the app's components it places (`UIComponents.json`). +// Kept as the shared files write them, key order included: `x-order` aside, the order is what +// the agent reads. +/* eslint-disable sort-keys */ + +export const ohridLines = [ + '{"version":"1.0","createSurface":{"surfaceId":"ohrid-weekend","catalogId":"getstream.io:app-catalog","dataModel":{"pace":["relaxed"],"interests":[]}}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"root","component":"Column","children":["title","intro","map","calendar","days","tip","refine","next"]},{"id":"title","component":"Text","text":"## A weekend in Ohrid"},{"id":"intro","component":"Text","text":"Two easy days by the lake: the old town on Saturday, a boat to St. Naum on Sunday."}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"map","component":"TripMap","places":[{"name":"Church of St. John at Kaneo","latitude":41.1112,"longitude":20.7881},{"name":"Plaošnik","latitude":41.1131,"longitude":20.7903},{"name":"Samuel\'s Fortress, Ohrid","latitude":41.1162,"longitude":20.7928},{"name":"Monastery of Saint Naum","latitude":40.9139,"longitude":20.7406},{"name":"Bay of Bones","latitude":41.0118,"longitude":20.7988}]},{"id":"calendar","component":"CalendarCheck","start":"2026-11-14","end":"2026-11-15"}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"days","component":"Tabs","tabs":[{"title":"Saturday","child":"saturday"},{"title":"Sunday","child":"sunday"}]},{"id":"saturday","component":"Column","children":["saturday_plan","plaosnik","fortress","kaneo"]},{"id":"saturday_plan","component":"Text","text":"**Morning:** coffee by the harbour, then up through the old town to Plaošnik.\\n\\n**Afternoon:** Samuel\'s Fortress for the view over the lake.\\n\\n**Sunset:** St. John at Kaneo, on the cliff above the water."},{"id":"plaosnik","component":"PlaceCard","title":"Plaošnik","note":"1 hour"},{"id":"fortress","component":"PlaceCard","title":"Samuel\'s Fortress, Ohrid","note":"2 hours"},{"id":"kaneo","component":"PlaceCard","title":"Church of St. John at Kaneo","note":"Best at sunset"}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"sunday","component":"Column","children":["sunday_plan","naum","bones"]},{"id":"sunday_plan","component":"Text","text":"**Morning:** the boat from the harbour to St. Naum.\\n\\n**Afternoon:** the Bay of Bones on the way back."},{"id":"naum","component":"PlaceCard","title":"Monastery of Saint Naum","note":"Take the 10:00 boat"},{"id":"bones","component":"PlaceCard","title":"Bay of Bones","note":"1 hour"}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"tip","component":"Text","text":"> **Good to know:** book the St. Naum boat the day before. It fills up at weekends."},{"id":"refine","component":"Card","child":"refine_form"},{"id":"refine_form","component":"Column","children":["pace","interests","update"]},{"id":"pace","component":"ChoicePicker","label":"Pace","variant":"mutuallyExclusive","displayStyle":"chips","options":[{"label":"Relaxed","value":"relaxed"},{"label":"Packed","value":"packed"}],"value":{"@path":"/pace"}},{"id":"interests","component":"ChoicePicker","label":"More of","variant":"multipleSelection","displayStyle":"chips","options":[{"label":"History","value":"history"},{"label":"Food","value":"food"},{"label":"Nature","value":"nature"},{"label":"Swimming","value":"swimming"}],"value":{"@path":"/interests"}},{"id":"update","component":"Button","variant":"primary","child":"update_label","action":{"event":{"name":"update_plan","userMessage":"Update the plan","context":{"pace":{"@path":"/pace"},"interests":{"@path":"/interests"}}}}},{"id":"update_label","component":"Text","text":"Update the plan"}]}}', + '{"version":"1.0","updateComponents":{"surfaceId":"ohrid-weekend","components":[{"id":"next","component":"Column","children":["hotel","eat","longer"]},{"id":"hotel","component":"Button","variant":"borderless","child":"hotel_label","action":{"event":{"name":"ask","userMessage":"Find a hotel by the lake"}}},{"id":"hotel_label","component":"Text","text":"Find a hotel by the lake"},{"id":"eat","component":"Button","variant":"borderless","child":"eat_label","action":{"event":{"name":"ask","userMessage":"Where should we eat?"}}},{"id":"eat_label","component":"Text","text":"Where should we eat?"},{"id":"longer","component":"Button","variant":"borderless","child":"longer_label","action":{"event":{"name":"ask","userMessage":"Make it three days"}}},{"id":"longer_label","component":"Text","text":"Make it three days"}]}}', +]; + +export const uiComponents = [ + { + name: 'place_card', + description: + 'One place of a plan, with its photo and a short summary, which the phone loads from Wikipedia, so it needs no image URL. Use one per sight, beach, monastery, village or day trip.', + parameters: { + type: 'object', + properties: { + title: { + type: 'string', + description: + 'The place\'s exact English Wikipedia article title, such as "Church of St. John at Kaneo"', + }, + note: { + type: 'string', + description: + 'One short line about it for this plan, such as "Best at sunset" or "2 hours"', + }, + }, + required: ['title'], + 'x-order': ['title', 'note'], + }, + events: [ + { + name: 'ask_about', + description: 'Tapping the card asks you about the place.', + }, + ], + }, + { + name: 'trip_map', + description: + 'A map of a plan with a pin for each place, drawn on the phone. Use one near the top of every trip plan.', + parameters: { + type: 'object', + properties: { + places: { + type: 'array', + description: 'Every place of the plan, with its coordinates', + items: { + type: 'object', + properties: { + name: { + type: 'string', + }, + latitude: { + type: 'number', + }, + longitude: { + type: 'number', + }, + }, + required: ['name', 'latitude', 'longitude'], + 'x-order': ['name', 'latitude', 'longitude'], + }, + }, + }, + required: ['places'], + 'x-order': ['places'], + }, + events: [ + { + name: 'select_place', + description: 'Tapping a pin asks you about that place.', + }, + ], + }, + { + name: 'calendar_check', + description: + "The person's own calendar for the days of a plan, read on the phone. You never see it: use it whenever a plan has dates, so they can see what the trip would clash with.", + parameters: { + type: 'object', + properties: { + start: { + type: 'string', + format: 'date', + description: 'The first day, as YYYY-MM-DD', + }, + end: { + type: 'string', + format: 'date', + description: 'The last day, as YYYY-MM-DD', + }, + }, + required: ['start', 'end'], + 'x-order': ['start', 'end'], + }, + events: [], + }, +]; diff --git a/src/plugins/A2UI/__tests__/ssr.test.tsx b/src/plugins/A2UI/__tests__/ssr.test.tsx new file mode 100644 index 000000000..0bb4562bc --- /dev/null +++ b/src/plugins/A2UI/__tests__/ssr.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment node +import React from 'react'; +import { renderToString } from 'react-dom/server'; + +import { AIConversation, AIUIHost, fromStreamMessage } from '../../AIComponents'; +import { createA2UIRenderer } from '..'; + +it('server-renders a reply’s A2UI as a placeholder, without loading the A2UI packages', () => { + expect(typeof window).toBe('undefined'); + expect(typeof HTMLElement).toBe('undefined'); + const ui = new AIUIHost({ renderers: [createA2UIRenderer()], send: () => undefined }); + const html = renderToString( + , + ); + expect(html).toContain('str-chat__a2ui--loading'); + expect(html).not.toContain('ssr-hidden-text'); +}); diff --git a/src/plugins/A2UI/basicComponents.tsx b/src/plugins/A2UI/basicComponents.tsx new file mode 100644 index 000000000..4ffcfd860 --- /dev/null +++ b/src/plugins/A2UI/basicComponents.tsx @@ -0,0 +1,678 @@ +// The basic catalog's components a person sees most, in Stream's look: the ones Stream has a +// component for are Stream's, rather than the A2UI elements themed. The rest of the catalog, +// its layouts and media, stays A2UI's own. +import React, { + type ComponentPropsWithoutRef, + createContext, + type CSSProperties, + type KeyboardEvent, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import clsx from 'clsx'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import type { ReactA2uiComponentProps } from '@a2ui/react'; +import type { ComponentContext } from '@a2ui/web_core'; + +import { Button } from '../../components/Button'; +import { Checkbox, FieldError, TextInput } from '../../components/Form'; +import type { IconSlots } from '../../components/Icons/slots'; +import { useStableId } from '../../components/UtilityComponents/useStableId'; +import { useComponentContextIcons } from '../../context/useComponentContextIcons'; +import { useTranslationContext } from '../../context/TranslationContext'; +import { AIMarkdown } from '../AIComponents/AIMarkdown'; +import { aiUILinkOpener, isRecord } from '../AIComponents/GenerativeUI/AIUIHelpers'; +import { useAIUIEntrance } from '../AIComponents/GenerativeUI/AIUIEntrance'; +import type { A2UIEngine } from './A2UIRenderer'; + +/** The engine of the surfaces below, for what in them reaches the host, such as links. */ +export const A2UIEngineContext = createContext(undefined); + +// Whether a Text is the label of a Button, where it shows as words rather than as Markdown. +const A2UILabelContext = createContext(false); + +type Weighted = { accessibility?: unknown; weight?: unknown }; + +// A part's flex weight in its Row or Column, as the A2UI elements apply theirs. +const weightOf = ({ weight }: Weighted): CSSProperties | undefined => + typeof weight === 'number' ? { flex: weight } : undefined; + +type Accessibility = { + 'aria-description'?: string; + 'aria-hidden'?: boolean; + 'aria-label'?: string; + 'aria-live'?: 'assertive' | 'polite'; +}; + +const said = (value: unknown) => + value === undefined || value === null || value === '' ? undefined : String(value); + +// What the agent tells assistive technology about a part, with its `accessibility`, as the A2UI +// elements tell it: what to call it, what more to say about it, whether to announce its changes, +// and whether to leave it out. +const accessibilityOf = ({ accessibility }: Weighted): Accessibility => { + if (!isRecord(accessibility)) return {}; + const { description, hidden, label, live } = accessibility; + const named = said(label); + const described = said(description); + return { + ...(named ? { 'aria-label': named } : {}), + ...(described ? { 'aria-description': described } : {}), + ...(live === 'polite' || live === 'assertive' ? { 'aria-live': live } : {}), + ...(hidden === true ? { 'aria-hidden': true } : {}), + }; +}; + +// A part that comes in when it shows, carrying its weight and what the agent tells assistive +// technology about it: the props of its element, and the scope of what shows with it. +const usePart = (props: Weighted) => { + const { props: entrance, scope } = useAIUIEntrance(); + return { + part: { + ...entrance, + ...accessibilityOf(props), + style: { ...entrance.style, ...weightOf(props) }, + }, + scope, + }; +}; + +const textOf = (value: unknown) => + typeof value === 'string' ? value : value == null ? '' : String(value); + +const errorsOf = ({ isValid, validationErrors }: Checked) => + isValid === false && Array.isArray(validationErrors) + ? validationErrors.filter((error): error is string => typeof error === 'string') + : []; + +type Checked = { isValid?: unknown; validationErrors?: unknown }; + +// What a part's checks found, under it. +const Problems = ({ errors, id }: { errors: string[]; id: string }) => + errors.length ? ( + + ) : null; + +type ButtonProps = Checked & + Weighted & { + action?: unknown; + child?: unknown; + variant?: unknown; + }; + +const isBorderlessButton = (context: ComponentContext, id: unknown) => { + if (typeof id !== 'string') return false; + const component = context.surfaceComponents.get(id); + return component?.type === 'Button' && component.properties.variant === 'borderless'; +}; + +/** + * Where a borderless Button is among the follow-ups of a Column: `first`, or `after` another + * borderless Button, which a hairline then parts it from. A borderless Button anywhere else, such + * as in a Row, is not a follow-up, and reads as a link. + */ +const followUpPlaceOf = ( + context: ComponentContext | undefined, +): 'first' | 'after' | undefined => { + if (!context) return undefined; + const { id } = context.componentModel; + for (const parent of context.surfaceComponents.values) { + const { children } = parent.properties; + if (parent.type !== 'Column' || !Array.isArray(children)) continue; + const index = children.indexOf(id); + if (index < 0) continue; + return index > 0 && isBorderlessButton(context, children[index - 1]) + ? 'after' + : 'first'; + } + return undefined; +}; + +/** + * A2UI's Button as Stream's: primary for the main call to action, and borderless as a ghost + * button in the accent colour, which the spec means to read as a link, as it does on iOS. + */ +export const A2UIButton = ({ + buildChild, + context, + props, +}: ReactA2uiComponentProps) => { + const { part, scope } = usePart(props); + const isDisabled = props.isValid === false; + const followUp = props.variant === 'borderless' ? followUpPlaceOf(context) : undefined; + const look = + props.variant === 'primary' + ? ({ appearance: 'solid', variant: 'primary' } as const) + : props.variant === 'borderless' + ? ({ appearance: 'ghost', variant: 'primary' } as const) + : ({ appearance: 'outline', variant: 'secondary' } as const); + return ( + + ); +}; + +type CardProps = Weighted & { child?: unknown }; + +/** A2UI's Card: a box of its own in the reply, as Stream's cards are. */ +export const A2UICard = ({ buildChild, props }: ReactA2uiComponentProps) => { + const { part, scope } = usePart(props); + return ( +
+ {scope(typeof props.child === 'string' ? buildChild(props.child) : null)} +
+ ); +}; + +type TextProps = Weighted & { text?: unknown; variant?: unknown }; + +/** + * A2UI's Text: Markdown rendered as replies' is, or a caption. Its links open the + * way the host allows, since the agent writes them, rather than wherever they point. As a + * Button's label it is the button's words. + */ +export const A2UIText = ({ props }: ReactA2uiComponentProps) => + useContext(A2UILabelContext) ? ( + + {textOf(props.text)} + + ) : ( + + ); + +// A Text that is a part of its own, coming in when it shows. +const A2UITextPart = (props: TextProps) => { + const engine = useContext(A2UIEngineContext); + const { part } = usePart(props); + const text = textOf(props.text); + const variant = typeof props.variant === 'string' ? props.variant : 'body'; + const classes = clsx( + 'str-chat__a2ui-text', + `str-chat__a2ui-text--${variant}`, + part.className, + ); + // A generic element can't be named, so a Text the agent names is a group. + const role = part['aria-label'] ? 'group' : undefined; + if (variant === 'caption') { + return ( +

+ {text} +

+ ); + } + const follow = aiUILinkOpener(engine?.host); + return ( +
+ {text} +
+ ); +}; + +type TextFieldProps = Checked & + Weighted & { + label?: unknown; + placeholder?: unknown; + setValue?: unknown; + value?: unknown; + variant?: unknown; + }; + +/** A2UI's TextField as Stream's text input, or a text area for long text. */ +export const A2UITextField = ({ props }: ReactA2uiComponentProps) => { + // What names and describes the field goes on its input; the rest on the part. + const { + part: { 'aria-description': description, 'aria-label': name, ...part }, + } = usePart(props); + const named = { 'aria-description': description, 'aria-label': name }; + const id = useStableId(); + const problemsId = `${id}-problems`; + const errors = errorsOf(props); + const label = textOf(props.label); + const placeholder = textOf(props.placeholder) || undefined; + const value = textOf(props.value); + const change = (next: string) => { + if (typeof props.setValue === 'function') props.setValue(next); + }; + const field = + props.variant === 'longText' ? ( +
0 && 'str-chat__form-text-input--error', + )} + > + {label && ( + + )} +
+
+