diff --git a/docs/guides/custom-components.md b/docs/guides/custom-components.md index 9915fa400..059db43d4 100644 --- a/docs/guides/custom-components.md +++ b/docs/guides/custom-components.md @@ -1,83 +1,411 @@ +--- +render_macros: false +--- + # Custom Component Catalogs -Extend A2UI by defining **custom catalogs** that include your own components alongside standard A2UI components. +Extend A2UI with a catalog of your own components — video players, maps, charts, or anything your application needs. ## Why Custom Catalogs? -The A2UI Standard Catalog provides common UI elements (buttons, text fields, etc.), but your application might need specialized components: +The A2UI Basic Catalog covers common UI elements (text, buttons, inputs, layout), but your application might need specialized components: + +- **Media**: YouTube embeds, audio visualizers, 3D viewers +- **Maps**: Google Maps, Mapbox, Leaflet +- **Charts**: Chart.js, D3, Recharts +- **Domain-specific**: Stock tickers, medical imaging, CAD viewers + +Custom catalogs let agents generate UI that includes **any** component your app supports — not just what's in the basic catalog. + +!!! tip "Already have a component library?" + If you're adding A2UI to an existing app with its own design system (Material, Ant Design, PrimeNG, etc.), start with the [Design System Integration](design-system-integration.md) guide first — it walks through wrapping your existing components as A2UI components. + +## How It Works + +``` +Agent ──generates──> A2UI JSON ──references──> "GoogleMap" component + │ +Client ──registers──> Catalog { GoogleMap: ... } ───┘ + │ +Angular ──renders──> <───┘ +``` + +1. You **implement** an Angular component that extends `DynamicComponent` +2. You **register** it in a catalog +3. The agent **references** it by name in `updateComponents` messages +4. The A2UI renderer **instantiates** your component with the agent's properties + +## Adding a Custom Component: YouTube Example + +Let's add a YouTube video player as a custom A2UI component. + +### 1. Create the Component + +Custom components extend `DynamicComponent` from `@a2ui/angular`: + +```typescript +// a2ui-catalog/youtube.ts +import { DynamicComponent } from '@a2ui/angular'; +import * as Primitives from '@a2ui/web_core/types/primitives'; +import * as Types from '@a2ui/web_core/types/types'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; + +@Component({ + selector: 'a2ui-youtube', + changeDetection: ChangeDetectionStrategy.Eager, + styles: ` + :host { display: block; flex: var(--weight); padding: 8px; } + .video-container { + position: relative; + width: 100%; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + border-radius: 8px; + overflow: hidden; + } + iframe { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + border: none; + } + `, + template: ` + @if (resolvedVideoId()) { + @if (resolvedTitle()) { +

{{ resolvedTitle() }}

+ } +
+ +
+ } + `, +}) +export class YouTube extends DynamicComponent { + private static readonly YOUTUBE_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/; + + readonly videoId = input.required(); + protected readonly resolvedVideoId = computed(() => + this.resolvePrimitive(this.videoId()), + ); + + readonly title = input(); + protected readonly resolvedTitle = computed(() => + this.resolvePrimitive(this.title() ?? null), + ); + + protected readonly safeUrl = computed(() => { + const id = this.resolvedVideoId(); + if (!id) return null; + + // Validate video ID format before constructing URL + if (!YouTube.YOUTUBE_ID_REGEX.test(id)) { + console.error('Invalid YouTube video ID received from agent:', id); + return null; + } + + const url = `https://www.youtube.com/embed/${encodeURIComponent(id)}`; + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + }); + + constructor(private sanitizer: DomSanitizer) { + super(); + } +} +``` + +**Key patterns:** + +- Extend `DynamicComponent` for custom component types +- Use `input()` for properties the agent will set +- Use `resolvePrimitive()` to resolve values that may be literals or data-bound paths +- Use `computed()` for reactive derivations +- Validate agent-provided data before use (e.g., video ID format check) + +### 2. Register in a Catalog + +Add your component to a catalog: + +```typescript +// a2ui-catalog/catalog.ts +import { Catalog, DEFAULT_CATALOG } from '@a2ui/angular'; +import { inputBinding } from '@angular/core'; + +export const MY_CATALOG = { + ...DEFAULT_CATALOG, // Optionally include A2UI basic catalog components + + YouTube: { + type: () => import('./youtube').then((r) => r.YouTube), + bindings: ({ properties }) => [ + inputBinding('videoId', () => + ('videoId' in properties && properties['videoId']) || undefined + ), + inputBinding('title', () => + ('title' in properties && properties['title']) || undefined + ), + ], + }, +} as Catalog; +``` + +A2UI ships with a basic catalog to get you started quickly, but you do **not** need to use it. If your design system already provides the components you want to render, you can expose only your own components, or a mix of basic and custom components. + +**What's happening:** + +- `...DEFAULT_CATALOG` — optionally spread the basic catalog (Text, Button, etc.) +- `type` — lazy-loaded import of your component class +- `bindings` — maps properties from the A2UI JSON to Angular `@Input()` values + +### 3. Use the Custom Catalog + +Update your app config to use your catalog: + +```typescript +// app.config.ts +import { MY_CATALOG } from './a2ui-catalog/catalog'; + +export const appConfig: ApplicationConfig = { + providers: [ + configureChatCanvasFeatures( + usingA2aService(MyA2aService), + usingA2uiRenderers(MY_CATALOG, theme), + ), + ], +}; +``` + +### 4. Agent-Side: Using the Custom Component + +The agent references your component by name in `updateComponents`: + +```json +{ + "type": "updateComponents", + "surfaceId": "main", + "components": { + "root": { + "component": "Column", + "properties": {}, + "childIds": ["vid1"] + }, + "vid1": { + "component": "YouTube", + "properties": { + "videoId": "dQw4w9WgXcQ", + "title": "Check out this video" + } + } + } +} +``` + +The agent knows about your custom components through the catalog configuration in its prompt or system instructions. + +## Google Maps Example + +A map component that displays pins from the agent's data model: + +```typescript +// a2ui-catalog/google-map.ts +@Component({ + selector: 'a2ui-map', + imports: [GoogleMapsModule], + template: ` + + @for (pin of resolvedPins(); track pin) { + + } + + `, +}) +export class GoogleMap extends DynamicComponent { + readonly zoom = input(); + readonly center = input<{ path: string } | null>(); + readonly pins = input<{ path: string }>(); + + protected resolvedZoom = computed(() => this.resolvePrimitive(this.zoom())); + protected resolvedCenter = computed(() => this.resolveLatLng(this.center())); + protected resolvedPins = computed(() => this.resolveLocations(this.pins())); + // ... (resolve helpers iterate over data model paths) +} +``` + +**Catalog entry:** + +```typescript +GoogleMap: { + type: () => import('./google-map').then((r) => r.GoogleMap), + bindings: ({ properties }) => [ + inputBinding('zoom', () => properties['zoom'] || 8), + inputBinding('center', () => properties['center'] || undefined), + inputBinding('pins', () => properties['pins'] || undefined), + inputBinding('title', () => properties['title'] || undefined), + ], +}, +``` -- **Domain-specific widgets**: Stock tickers, medical charts, CAD viewers -- **Third-party integrations**: Google Maps, payment forms, chat widgets -- **Brand-specific components**: Custom date pickers, product cards, dashboards +**Agent JSON:** -**Custom catalogs** are collections of components that can include: -- Standard A2UI components (Text, Button, TextField, etc.) -- Your custom components (GoogleMap, StockTicker, etc.) -- Third-party components +```json +{ + "component": "GoogleMap", + "properties": { + "zoom": 12, + "center": { "path": "/mapCenter" }, + "pins": { "path": "/restaurants" }, + "title": "Nearby Restaurants" + } +} +``` -You register entire catalogs with your client application, not individual components. This allows agents and clients to agree on a shared, extended set of components while maintaining security and type safety. +Maps uses **data binding** — the `center` and `pins` reference paths in the data model, so the agent can update locations dynamically via `updateDataModel`. -## How Custom Catalogs Work +## Chart Example -1. **Client Defines Catalog**: You create a catalog definition that lists both standard and custom components. -2. **Client Registers Catalog**: You register the catalog (and its component implementations) with your client app. -3. **Client Announces Support**: The client informs the agent which catalogs it supports. -4. **Agent Selects Catalog**: The agent chooses a catalog for a given UI surface. -5. **Agent Generates UI**: The agent generates component messages (`surfaceUpdate` in v0.8, `updateComponents` in v0.9) using components from that catalog by name. +A chart component using Chart.js: -## Defining Custom Catalogs +```typescript +// a2ui-catalog/chart.ts +@Component({ + selector: 'a2ui-chart', + imports: [BaseChartDirective], + template: ` +
+

{{ resolvedTitle() }}

+ +
+ `, +}) +export class Chart extends DynamicComponent { + readonly type = input.required(); + readonly title = input(); + readonly chartData = input.required(); -TODO: Add detailed guide for defining custom catalogs for each platform. + protected chartType = computed(() => this.type() as ChartType); + protected resolvedTitle = computed(() => this.resolvePrimitive(this.title() ?? null)); + // ... (resolve chart data from data model paths) +} +``` -**Web (Lit / Angular):** +**Catalog entry:** -- How to define a catalog with both standard and custom components -- How to register the catalog with the A2UI client -- How to implement custom component classes +```typescript +Chart: { + type: () => import('./chart').then((r) => r.Chart), + bindings: ({ properties }) => [ + inputBinding('type', () => properties['type'] || undefined), + inputBinding('title', () => properties['title'] || undefined), + inputBinding('chartData', () => properties['chartData'] || undefined), + ], +}, +``` -**Flutter:** +## Any Component -- How to define custom catalogs using GenUI -- How to register custom component renderers +The same pattern works for **any Angular component**. If you can build it as an Angular component, you can make it an A2UI custom component: -**See working examples:** +- **Carousel**: Wrap your carousel library, bind slides via data model paths +- **Code editor**: Monaco editor with syntax highlighting +- **3D viewer**: Three.js scene driven by agent data +- **Payment form**: Stripe Elements with A2UI event callbacks +- **PDF viewer**: Display documents the agent references -- [Lit samples](https://github.com/google/a2ui/tree/main/samples/client/lit) -- [Angular samples](https://github.com/google/a2ui/tree/main/samples/client/angular) -- [Flutter GenUI docs](https://docs.flutter.dev/ai/genui) +The pattern is always: -## Agent-Side: Using Components from a Custom Catalog +1. Extend `DynamicComponent` +2. Declare `input()` properties +3. Use `resolvePrimitive()` for data binding +4. Register in catalog with `inputBinding()` mappings -Once a catalog is registered on the client, agents can use components from it in `surfaceUpdate` messages. +## Data Binding with Custom Components -The agent specifies which catalog to use via the `catalogId` in the `beginRendering` message. +Custom components can use A2UI's data binding system. Instead of literal values, properties can reference paths in the data model: -TODO: Add examples of: +```json +{ + "component": "Chart", + "properties": { + "type": "pie", + "title": "Sales by Region", + "chartData": { "path": "/salesData" } + } +} +``` -- How agents select catalogs -- How agents reference custom components from catalogs -- How catalog versioning works +The agent updates data separately via `updateDataModel`: -## Data Binding and Actions +```json +{ + "type": "updateDataModel", + "surfaceId": "main", + "data": { + "salesData": [ + { "label": "North America", "value": 45 }, + { "label": "Europe", "value": 30 }, + { "label": "Asia", "value": 25 } + ] + } +} +``` -Custom components support the same data binding and action mechanisms as standard components: +This separation means the agent can update chart data without re-sending the entire component tree. -- **Data binding**: Custom components can bind properties to data model paths using JSON Pointer syntax -- **Actions**: Custom components can emit actions that the agent receives and handles +## Agent Configuration -## Security Considerations +For agents to use your custom components, include the component definitions in the agent's prompt or catalog configuration: -When creating custom catalogs and components: +```python +# Agent-side catalog config +catalog = CatalogConfig( + catalog_id="my-custom-catalog", + components={ + "YouTube": { + "description": "Embedded YouTube video player", + "properties": { + "videoId": "YouTube video ID (e.g., 'dQw4w9WgXcQ')", + "title": "Optional title displayed above the video", + }, + }, + "GoogleMap": { + "description": "Interactive Google Map with pins", + "properties": { + "zoom": "Map zoom level (1-20)", + "center": "Center coordinates (data model path)", + "pins": "Array of pin locations (data model path)", + }, + }, + "Chart": { + "description": "Chart.js chart (pie, bar, line, doughnut)", + "properties": { + "type": "Chart type: pie, bar, line, doughnut", + "title": "Chart title", + "chartData": "Chart data (data model path)", + }, + }, + }, +) +``` -1. **Allowlist components**: Only register components you trust in your catalogs -2. **Validate properties**: Always validate component properties from agent messages -3. **Sanitize user input**: If components accept user input, sanitize it before processing -4. **Limit API access**: Don't expose sensitive APIs or credentials to custom components +## Working Examples -TODO: Add detailed security best practices and code examples. +- [**rizzcharts**](https://github.com/google/a2ui/tree/main/samples/client/angular/projects/rizzcharts) — Chart.js + Google Maps + YouTube custom components ## Next Steps -- **[Theming & Styling](theming.md)**: Customize the look and feel of components -- **[Component Reference](../reference/components.md)**: See all standard components -- **[Agent Development](agent-development.md)**: Build agents that use custom components +- [Design System Integration](design-system-integration.md) — Wrap your existing design system components as A2UI components +- [Theming Guide](theming.md) — Style custom components with your design system +- [Agent Development](agent-development.md) — Build agents that use custom components diff --git a/docs/guides/design-system-integration.md b/docs/guides/design-system-integration.md new file mode 100644 index 000000000..bfe8d0573 --- /dev/null +++ b/docs/guides/design-system-integration.md @@ -0,0 +1,184 @@ +--- +render_macros: false +--- + +# Integrating A2UI into an Existing Design System + +This guide walks through adding A2UI to an **existing** Angular application that already uses a component library (like Angular Material). Instead of using the A2UI basic catalog, you'll wrap your own Material components as A2UI components — so agents generate UI that matches your design system. + +> **Prerequisites**: An Angular 19+ application with a component library installed (this guide uses Angular Material). Familiarity with Angular components and dependency injection. + +## Overview + +Adding A2UI to an existing app involves four steps: + +1. **Install** the A2UI Angular renderer and web_core packages +2. **Wrap** your existing components as A2UI custom components +3. **Register** them in a custom catalog +4. **Connect** to an A2A-compatible agent + +The key insight: A2UI doesn't replace your design system — it wraps it. Your existing components become the rendering targets for agent-generated UI. Agents compose your Material buttons, cards, and inputs — not generic A2UI ones. + +## Step 1: Install A2UI Packages + +```bash +npm install @a2ui/angular @a2ui/web_core +``` + +The `@a2ui/angular` package provides: + +- `DynamicComponent` — base class for wrapping your components as A2UI-compatible +- `Catalog` injection token — for providing your catalog to the renderer +- `configureChatCanvasFeatures()` — helper for wiring everything together + +## Step 2: Wrap Your Components + +Create A2UI wrappers around your existing Material components. Each wrapper extends `DynamicComponent` and delegates rendering to your Material component: + +```typescript +// a2ui-catalog/material-button.ts +import { DynamicComponent } from '@a2ui/angular'; +import * as Types from '@a2ui/web_core/types/types'; +import { Component, computed, input } from '@angular/core'; +import { MatButton } from '@angular/material/button'; + +@Component({ + selector: 'a2ui-mat-button', + imports: [MatButton], + template: ` + + `, +}) +export class MaterialButton extends DynamicComponent { + readonly label = input.required(); + readonly color = input(); + + protected resolvedLabel = computed(() => this.resolvePrimitive(this.label())); + protected resolvedColor = computed(() => + this.resolvePrimitive(this.color() ?? null) || 'primary' + ); +} +``` + +The wrapper is thin — it just maps A2UI properties to your Material component's API. + +## Step 3: Register a Custom Catalog + +Build a catalog from your wrapped components. You do **not** need to include the A2UI basic catalog — your design system provides the components: + +```typescript +// a2ui-catalog/catalog.ts +import { Catalog } from '@a2ui/angular'; +import { inputBinding } from '@angular/core'; + +// No DEFAULT_CATALOG spread — your Material components ARE the catalog +export const MATERIAL_CATALOG = { + Button: { + type: () => import('./material-button').then((r) => r.MaterialButton), + bindings: ({ properties }) => [ + inputBinding('label', () => properties['label'] || ''), + inputBinding('color', () => properties['color'] || undefined), + ], + }, + Card: { + type: () => import('./material-card').then((r) => r.MaterialCard), + bindings: ({ properties }) => [ + inputBinding('title', () => properties['title'] || undefined), + inputBinding('subtitle', () => properties['subtitle'] || undefined), + ], + }, + // ... wrap more of your Material components +} as Catalog; +``` + +You can also mix approaches — use some basic catalog components alongside your custom ones: + +```typescript +import { DEFAULT_CATALOG } from '@a2ui/angular'; + +export const MIXED_CATALOG = { + ...DEFAULT_CATALOG, // A2UI basic components as fallback + Button: /* your Material button overrides the basic one */, + Card: /* your Material card */, +} as Catalog; +``` + +The basic components are entirely optional. If your design system already covers what you need, expose only your own components. + +## Step 4: Wire It Up + +```typescript +// app.config.ts +import { + configureChatCanvasFeatures, + usingA2aService, + usingA2uiRenderers, +} from '@a2a_chat_canvas/config'; +import { MATERIAL_CATALOG } from './a2ui-catalog/catalog'; +import { theme } from './theme'; + +export const appConfig: ApplicationConfig = { + providers: [ + // ... your existing providers (Material, Router, etc.) + configureChatCanvasFeatures( + usingA2aService(MyA2aService), + usingA2uiRenderers(MATERIAL_CATALOG, theme), + ), + ], +}; +``` + +## Step 5: Add the Chat Canvas + +The chat canvas is the container where A2UI surfaces are rendered. Add it alongside your existing layout: + +```html + +
+ + + ... + + + + + + + +
+``` + +## What Changes, What Doesn't + +| Aspect | Before A2UI | After A2UI | +|--------|------------|------------| +| Your existing pages | Material components | Material components (unchanged) | +| Agent-generated UI | Not possible | Rendered via your Material wrappers | +| Component library | Angular Material | Angular Material (unchanged) | +| Design consistency | Your theme | Your theme (agents use your components) | + +Your existing app is untouched. A2UI adds a rendering layer where agents compose **your** components. + +## Theming + +Because agents render your Material components, theming is automatic — your existing Material theme applies. You can optionally map tokens for any A2UI basic components you include: + +```typescript +// theme.ts +import { Theme } from '@a2ui/angular'; + +export const theme: Theme = { + // Map your Material design tokens to A2UI + // See the Theming guide for full details +}; +``` + +See the [Theming Guide](theming.md) for complete theming documentation. + +## Next Steps + +- [Custom Components](custom-components.md) — Add specialized components to your catalog (Maps, Charts, YouTube, etc.) +- [Theming Guide](theming.md) — Deep dive into theming +- [Agent Development](agent-development.md) — Build agents that generate A2UI using your catalog diff --git a/mkdocs.yaml b/mkdocs.yaml index 3b076e6d7..6e886fd97 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -52,6 +52,7 @@ nav: - Client Setup: guides/client-setup.md - Agent Development: guides/agent-development.md - Renderer Development: guides/renderer-development.md + - Design System Integration: guides/design-system-integration.md - Custom Components: guides/custom-components.md - Theming & Styling: guides/theming.md - Reference: diff --git a/samples/client/angular/projects/rizzcharts/src/a2ui-catalog/catalog.ts b/samples/client/angular/projects/rizzcharts/src/a2ui-catalog/catalog.ts index e7cc9f045..a4acf3234 100644 --- a/samples/client/angular/projects/rizzcharts/src/a2ui-catalog/catalog.ts +++ b/samples/client/angular/projects/rizzcharts/src/a2ui-catalog/catalog.ts @@ -40,4 +40,12 @@ export const RIZZ_CHARTS_CATALOG = { inputBinding('title', () => ('title' in properties && properties['title']) || undefined), ], }, + YouTube: { + type: () => import('./youtube').then((r) => r.YouTube), + bindings: ({ properties }) => [ + inputBinding('videoId', () => ('videoId' in properties && properties['videoId']) || undefined), + inputBinding('title', () => ('title' in properties && properties['title']) || undefined), + inputBinding('autoplay', () => ('autoplay' in properties && properties['autoplay']) || undefined), + ], + }, } as Catalog; diff --git a/samples/client/angular/projects/rizzcharts/src/a2ui-catalog/youtube.ts b/samples/client/angular/projects/rizzcharts/src/a2ui-catalog/youtube.ts new file mode 100644 index 000000000..914ea1858 --- /dev/null +++ b/samples/client/angular/projects/rizzcharts/src/a2ui-catalog/youtube.ts @@ -0,0 +1,131 @@ +/* + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import { DynamicComponent } from '@a2ui/angular'; +import * as Primitives from '@a2ui/web_core/types/primitives'; +import * as Types from '@a2ui/web_core/types/types'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; + +@Component({ + selector: 'a2ui-youtube', + changeDetection: ChangeDetectionStrategy.Eager, + styles: ` + :host { + display: block; + flex: var(--weight); + padding: 8px; + } + + .youtube-container { + background-color: var(--mat-sys-surface-container); + border-radius: 8px; + border: 1px solid var(--mat-sys-surface-container-high); + padding: 16px; + max-width: 800px; + } + + .youtube-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .youtube-header h3 { + margin: 0; + font-size: 18px; + color: var(--mat-sys-on-surface); + } + + .video-wrapper { + position: relative; + width: 100%; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + border-radius: 8px; + overflow: hidden; + } + + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } + `, + template: ` + @if (resolvedVideoId()) { +
+ @if (resolvedTitle()) { +
+

{{ resolvedTitle() }}

+
+ } +
+ +
+
+ } + `, +}) +export class YouTube extends DynamicComponent { + readonly videoId = input.required(); + protected readonly resolvedVideoId = computed(() => + this.resolvePrimitive(this.videoId()), + ); + + readonly title = input(); + protected readonly resolvedTitle = computed(() => + this.resolvePrimitive(this.title() ?? null), + ); + + readonly autoplay = input(); + protected readonly resolvedAutoplay = computed(() => + this.resolvePrimitive(this.autoplay() ?? null), + ); + + private static readonly YOUTUBE_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/; + + protected readonly safeUrl = computed((): SafeResourceUrl | null => { + const id = this.resolvedVideoId(); + if (!id) return null; + + // Validate video ID format before constructing URL + if (!YouTube.YOUTUBE_ID_REGEX.test(id)) { + console.error('Invalid YouTube video ID received from agent:', id); + return null; + } + + const autoplay = this.resolvedAutoplay() ? '1' : '0'; + const url = `https://www.youtube.com/embed/${encodeURIComponent(id)}?autoplay=${autoplay}&rel=0`; + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + }); + + constructor(private sanitizer: DomSanitizer) { + super(); + } +}