diff --git a/apps/website/content/docs/chat/a2ui/overview.mdx b/apps/website/content/docs/chat/a2ui/overview.mdx index ca7850448..47a2b171b 100644 --- a/apps/website/content/docs/chat/a2ui/overview.mdx +++ b/apps/website/content/docs/chat/a2ui/overview.mdx @@ -160,6 +160,33 @@ export class MyComponent { } ``` +## Custom Function Call Handlers + +When an A2UI button has a `functionCall` action, the `call` name is looked up in the `[handlers]` map on `ChatComponent`. This lets you define client-side behavior triggered by agent-built UI: + +```typescript +@Component({ + template: ``, +}) +export class MyComponent { + agentRef = agent({ apiUrl: '/api', assistantId: 'my-agent' }); + catalog = a2uiBasicCatalog(); + + handlers = { + addToCart: async (args: Record) => { + const cart = inject(CartService); + return cart.add(args['sku'] as string); + }, + }; +} +``` + +The agent sends a button with `{"action": {"functionCall": {"call": "addToCart", "args": {"sku": "ABC"}}}}`. When clicked, the `addToCart` handler runs in Angular's injection context — `inject()` works for accessing services. + +If no consumer handler matches the `call` name, built-in handlers are used as fallbacks (e.g., `openUrl` opens a URL in a new tab). + +Handler return values are emitted on the `RenderHandlerEvent` — observe them via the `renderEvent` output on `ChatComponent`. + ## What's Next diff --git a/apps/website/content/docs/chat/components/chat.mdx b/apps/website/content/docs/chat/components/chat.mdx index 78c3c1de4..fc4a9ca53 100644 --- a/apps/website/content/docs/chat/components/chat.mdx +++ b/apps/website/content/docs/chat/components/chat.mdx @@ -64,6 +64,7 @@ export class ChatPageComponent { | `ref` | `AgentRef` | **Required** | The agent ref providing streaming state. Created by `agent()` from `@cacheplane/angular`. | | `views` | `ViewRegistry \| undefined` | `undefined` | View registry for generative UI. Maps spec type names to Angular components. Created with `views()` from `@cacheplane/chat`. | | `store` | `StateStore \| undefined` | `undefined` | Optional state store for interactive generative UI specs. | +| `handlers` | `Record) => unknown \| Promise>` | `{}` | Event handlers for generative UI specs and A2UI `functionCall` actions. Handlers run in Angular injection context — `inject()` is available inside handler functions. | | `threads` | `Thread[]` | `[]` | List of threads to display in the sidebar. Each thread must have an `id` property. | | `activeThreadId` | `string` | `''` | The ID of the currently active thread, used for highlighting in the sidebar. | diff --git a/apps/website/content/docs/render/guides/events.mdx b/apps/website/content/docs/render/guides/events.mdx index 0ff844be9..608c79693 100644 --- a/apps/website/content/docs/render/guides/events.mdx +++ b/apps/website/content/docs/render/guides/events.mdx @@ -152,6 +152,23 @@ Each handler receives a `params` object and can return a value or a `Promise`: type Handler = (params: Record) => unknown | Promise; ``` +### Injection Context + +Handlers execute inside Angular's `runInInjectionContext`. This means you can call `inject()` to access services: + +```typescript +const handlers = { + saveForm: async (params: Record) => { + const http = inject(HttpClient); + const snapshot = store.getSnapshot(); + await firstValueFrom(http.post('/api/forms', snapshot)); + store.set('/saved', true); + }, +}; +``` + +This works for handlers passed via `[handlers]` on ``, `provideRender()`, or `ChatComponent`. + ### Resolution Priority Handlers resolve with the same priority as other inputs: diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index 8bb33a540..c46c88b47 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -127,3 +127,29 @@ describe('surfaceToSpec — state initialization', () => { expect(spec.state).toEqual({ count: 0, name: 'test' }); }); }); + +describe('A2uiSurfaceComponent — consumer handlers', () => { + function makeSurface(components: A2uiComponent[], dataModel: Record = {}): A2uiSurface { + const map = new Map(); + for (const c of components) map.set(c.id, c); + return { surfaceId: 's1', catalogId: 'basic', components: map, dataModel }; + } + + it('maps functionCall action call name to a2ui:localAction params', () => { + const surface = makeSurface([ + { id: 'root', component: 'Column', children: ['btn'] }, + { + id: 'btn', + component: 'Button', + label: 'Add', + action: { functionCall: { call: 'addToCart', args: { sku: 'ABC' } } }, + }, + ]); + const spec = surfaceToSpec(surface)!; + const btnElement = spec.elements['btn']; + expect(btnElement.on!['click']).toEqual({ + action: 'a2ui:localAction', + params: { call: 'addToCart', args: { sku: 'ABC' } }, + }); + }); +}); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index 24f908260..1d2bf7529 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -112,7 +112,7 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { } @@ -121,6 +121,7 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { export class A2uiSurfaceComponent { readonly surface = input.required(); readonly catalog = input.required(); + readonly handlers = input) => unknown | Promise>>({}); readonly events = output(); /** Convert the A2UI surface to a json-render Spec for rendering. */ @@ -129,19 +130,30 @@ export class A2uiSurfaceComponent { /** Convert ViewRegistry to AngularRegistry for RenderSpecComponent. */ readonly registry = computed(() => toRenderRegistry(this.catalog())); - readonly handlers: Record) => unknown> = { - 'a2ui:event': (params) => { - return params; - }, - 'a2ui:localAction': (params) => { - const call = params['call'] as string; - const args = (params['args'] as Record) ?? {}; - if (call === 'openUrl' && typeof globalThis.window !== 'undefined') { - globalThis.window.open(String(args['url'] ?? ''), '_blank'); - } - return undefined; - }, - }; + /** Merge built-in A2UI handlers with consumer-provided handlers. */ + readonly internalHandlers = computed(() => { + const consumerHandlers = this.handlers(); + return { + 'a2ui:event': (params: Record) => { + return params; + }, + 'a2ui:localAction': (params: Record) => { + const call = params['call'] as string; + const args = (params['args'] as Record) ?? {}; + + // Consumer handler takes priority + if (consumerHandlers[call]) { + return consumerHandlers[call](args); + } + + // Built-in fallback + if (call === 'openUrl' && typeof globalThis.window !== 'undefined') { + globalThis.window.open(String(args['url'] ?? ''), '_blank'); + } + return undefined; + }, + }; + }); onRenderEvent(event: RenderEvent): void { this.events.emit(event); diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index fcaf64bd7..c5ecd584a 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -141,6 +141,7 @@ import { KeyValuePipe } from '@angular/common'; [spec]="spec" [registry]="renderRegistry()" [store]="store()" + [handlers]="handlers()" [loading]="ref().isLoading()" (events)="onSpecEvent($event, index)" /> @@ -152,6 +153,7 @@ import { KeyValuePipe } from '@angular/common'; } @@ -217,6 +219,7 @@ export class ChatComponent { readonly ref = input.required>(); readonly views = input(undefined); readonly store = input(undefined); + readonly handlers = input) => unknown | Promise>>({}); readonly threads = input([]); readonly activeThreadId = input(''); readonly threadSelected = output(); diff --git a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts index ff6df2ea2..6a7bdab39 100644 --- a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts +++ b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts @@ -20,6 +20,7 @@ import { RenderSpecComponent } from '@cacheplane/render'; [spec]="spec()" [registry]="registry()" [store]="store()" + [handlers]="handlers()" [loading]="loading()" (events)="events.emit($event)" /> @@ -30,6 +31,7 @@ export class ChatGenerativeUiComponent { readonly spec = input(null); readonly registry = input(undefined); readonly store = input(undefined); + readonly handlers = input) => unknown | Promise> | undefined>(undefined); readonly loading = input(false); readonly events = output(); } diff --git a/libs/render/src/lib/render-element.component.spec.ts b/libs/render/src/lib/render-element.component.spec.ts index 367fa459c..3b10de01b 100644 --- a/libs/render/src/lib/render-element.component.spec.ts +++ b/libs/render/src/lib/render-element.component.spec.ts @@ -327,6 +327,30 @@ describe('RenderElementComponent — children rendering', () => { }); }); +describe('RenderElementComponent — handler injection context', () => { + it('should allow handlers to call inject() inside runInInjectionContext', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { Injector, runInInjectionContext, inject, DestroyRef } = require('@angular/core'); + const injector = Injector.create({ providers: [] }); + + let destroyRefAccessed = false; + const handler = (params: Record) => { + // This would throw outside injection context + runInInjectionContext(injector, () => { + const dr = inject(DestroyRef); + destroyRefAccessed = dr !== undefined; + }); + return params; + }; + + const result = handler({ test: true }); + expect(destroyRefAccessed).toBe(true); + expect(result).toEqual({ test: true }); + }); + }); +}); + describe('RenderElementComponent — element-level memoization', () => { it('element lookup returns same reference when spec changes but element is unchanged', () => { TestBed.configureTestingModule({}); diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts index 43131da85..fb2f647a2 100644 --- a/libs/render/src/lib/render-element.component.ts +++ b/libs/render/src/lib/render-element.component.ts @@ -8,6 +8,7 @@ import { Injector, input, OnInit, + runInInjectionContext, type Signal, } from '@angular/core'; import { NgComponentOutlet } from '@angular/common'; @@ -135,7 +136,9 @@ export class RenderElementComponent implements OnInit { for (const b of bindings) { const handler = this.ctx.handlers?.[b.action]; if (handler) { - handler(b.params as Record ?? {}); + runInInjectionContext(this.parentInjector, () => + handler(b.params as Record ?? {}), + ); } } };