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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/website/content/docs/chat/a2ui/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<chat [ref]="agentRef" [views]="catalog" [handlers]="handlers" />`,
})
export class MyComponent {
agentRef = agent({ apiUrl: '/api', assistantId: 'my-agent' });
catalog = a2uiBasicCatalog();

handlers = {
addToCart: async (args: Record<string, unknown>) => {
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

<CardGroup cols={2}>
Expand Down
1 change: 1 addition & 0 deletions apps/website/content/docs/chat/components/chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export class ChatPageComponent {
| `ref` | `AgentRef<any, any>` | **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<string, (params: Record<string, unknown>) => unknown \| Promise<unknown>>` | `{}` | 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. |

Expand Down
17 changes: 17 additions & 0 deletions apps/website/content/docs/render/guides/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,23 @@ Each handler receives a `params` object and can return a value or a `Promise`:
type Handler = (params: Record<string, unknown>) => unknown | Promise<unknown>;
```

### Injection Context

Handlers execute inside Angular's `runInInjectionContext`. This means you can call `inject()` to access services:

```typescript
const handlers = {
saveForm: async (params: Record<string, unknown>) => {
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 `<render-spec>`, `provideRender()`, or `ChatComponent`.

### Resolution Priority

Handlers resolve with the same priority as other inputs:
Expand Down
26 changes: 26 additions & 0 deletions libs/chat/src/lib/a2ui/surface.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}): A2uiSurface {
const map = new Map<string, A2uiComponent>();
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' } },
});
});
});
40 changes: 26 additions & 14 deletions libs/chat/src/lib/a2ui/surface.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null {
<render-spec
[spec]="s"
[registry]="registry()"
[handlers]="handlers"
[handlers]="internalHandlers()"
(events)="onRenderEvent($event)"
/>
}
Expand All @@ -121,6 +121,7 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null {
export class A2uiSurfaceComponent {
readonly surface = input.required<A2uiSurface>();
readonly catalog = input.required<ViewRegistry>();
readonly handlers = input<Record<string, (params: Record<string, unknown>) => unknown | Promise<unknown>>>({});
readonly events = output<RenderEvent>();

/** Convert the A2UI surface to a json-render Spec for rendering. */
Expand All @@ -129,19 +130,30 @@ export class A2uiSurfaceComponent {
/** Convert ViewRegistry to AngularRegistry for RenderSpecComponent. */
readonly registry = computed(() => toRenderRegistry(this.catalog()));

readonly handlers: Record<string, (params: Record<string, unknown>) => unknown> = {
'a2ui:event': (params) => {
return params;
},
'a2ui:localAction': (params) => {
const call = params['call'] as string;
const args = (params['args'] as Record<string, unknown>) ?? {};
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<string, unknown>) => {
return params;
},
'a2ui:localAction': (params: Record<string, unknown>) => {
const call = params['call'] as string;
const args = (params['args'] as Record<string, unknown>) ?? {};

// 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);
Expand Down
3 changes: 3 additions & 0 deletions libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import { KeyValuePipe } from '@angular/common';
[spec]="spec"
[registry]="renderRegistry()"
[store]="store()"
[handlers]="handlers()"
[loading]="ref().isLoading()"
(events)="onSpecEvent($event, index)"
/>
Expand All @@ -152,6 +153,7 @@ import { KeyValuePipe } from '@angular/common';
<a2ui-surface
[surface]="entry.value"
[catalog]="catalog"
[handlers]="handlers()"
(events)="onA2uiEvent($event, index, entry.key)"
/>
}
Expand Down Expand Up @@ -217,6 +219,7 @@ export class ChatComponent {
readonly ref = input.required<AgentRef<any, any>>();
readonly views = input<ViewRegistry | undefined>(undefined);
readonly store = input<StateStore | undefined>(undefined);
readonly handlers = input<Record<string, (params: Record<string, unknown>) => unknown | Promise<unknown>>>({});
readonly threads = input<Thread[]>([]);
readonly activeThreadId = input<string>('');
readonly threadSelected = output<string>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { RenderSpecComponent } from '@cacheplane/render';
[spec]="spec()"
[registry]="registry()"
[store]="store()"
[handlers]="handlers()"
[loading]="loading()"
(events)="events.emit($event)"
/>
Expand All @@ -30,6 +31,7 @@ export class ChatGenerativeUiComponent {
readonly spec = input<Spec | null>(null);
readonly registry = input<AngularRegistry | undefined>(undefined);
readonly store = input<StateStore | undefined>(undefined);
readonly handlers = input<Record<string, (params: Record<string, unknown>) => unknown | Promise<unknown>> | undefined>(undefined);
readonly loading = input<boolean>(false);
readonly events = output<RenderEvent>();
}
24 changes: 24 additions & 0 deletions libs/render/src/lib/render-element.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => {
// 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({});
Expand Down
5 changes: 4 additions & 1 deletion libs/render/src/lib/render-element.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Injector,
input,
OnInit,
runInInjectionContext,
type Signal,
} from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
Expand Down Expand Up @@ -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<string, unknown> ?? {});
runInInjectionContext(this.parentInjector, () =>
handler(b.params as Record<string, unknown> ?? {}),
);
}
}
};
Expand Down
Loading