Skip to content

Commit bbd5f2e

Browse files
google-genai-botcopybara-github
authored andcommitted
ADK changes
PiperOrigin-RevId: 856687165
1 parent ca5bcf4 commit bbd5f2e

File tree

12 files changed

+2066
-1255
lines changed

12 files changed

+2066
-1255
lines changed

package-lock.json

Lines changed: 860 additions & 1252 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"private": true,
1515
"dependencies": {
16+
"@a2ui/angular": "^0.8.3",
1617
"@angular/animations": "^21.0.0",
1718
"@angular/cdk": "^21.0.0",
1819
"@angular/common": "^21.0.0",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
Copyright 2026 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
:host {
18+
display: block;
19+
height: 100%;
20+
width: 100%;
21+
overflow: auto;
22+
}
23+
24+
:host ::ng-deep * {
25+
box-sizing: border-box;
26+
}
27+
28+
.canvas {
29+
display: flex;
30+
flex-direction: column;
31+
gap: 16px;
32+
padding: 16px;
33+
box-sizing: border-box;
34+
min-height: 100%;
35+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
19+
import {MessageProcessor} from '@a2ui/angular';
20+
import {Types} from '@a2ui/lit/0.8';
21+
import {SimpleChanges} from '@angular/core';
22+
import {ComponentFixture, TestBed} from '@angular/core/testing';
23+
// 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it}
24+
25+
import {initTestBed} from '../../testing/utils';
26+
27+
import {A2uiCanvasComponent} from './a2ui-canvas.component';
28+
29+
describe('A2uiCanvasComponent', () => {
30+
let component: A2uiCanvasComponent;
31+
let fixture: ComponentFixture<A2uiCanvasComponent>;
32+
let mockMessageProcessor: jasmine.SpyObj<MessageProcessor>;
33+
34+
beforeEach(async () => {
35+
initTestBed();
36+
mockMessageProcessor = jasmine.createSpyObj<MessageProcessor>(
37+
'MessageProcessor', ['processMessages', 'getSurfaces']);
38+
mockMessageProcessor.getSurfaces.and.returnValue(new Map());
39+
40+
await TestBed
41+
.configureTestingModule({
42+
imports: [A2uiCanvasComponent],
43+
providers: [
44+
{provide: MessageProcessor, useValue: mockMessageProcessor},
45+
],
46+
})
47+
.compileComponents();
48+
49+
fixture = TestBed.createComponent(A2uiCanvasComponent);
50+
component = fixture.componentInstance;
51+
fixture.detectChanges();
52+
});
53+
54+
it('should create', () => {
55+
expect(component).toBeTruthy();
56+
});
57+
58+
it('should process beginRendering message', () => {
59+
const message = {
60+
beginRendering: {
61+
surfaceId: 'sales_data_yearly_surface',
62+
root: 'root-column',
63+
styles: {primaryColor: '#00BFFF', font: 'Arial'}
64+
}
65+
} as unknown as Types.ServerToClientMessage;
66+
component.beginRendering = message;
67+
68+
const changes: SimpleChanges = {
69+
beginRendering: {
70+
currentValue: message,
71+
previousValue: null,
72+
firstChange: true,
73+
isFirstChange: () => true
74+
}
75+
};
76+
component.ngOnChanges(changes);
77+
78+
expect(mockMessageProcessor.processMessages).toHaveBeenCalledWith([message]);
79+
expect(component.surfaceId()).toBe('sales_data_yearly_surface');
80+
});
81+
82+
it('should process surfaceUpdate message', () => {
83+
const message = {
84+
surfaceUpdate: {
85+
surfaceId: 'sales_data_yearly_surface',
86+
components: [
87+
{
88+
id: 'root-column',
89+
component: {
90+
Column: {
91+
children: {explicitList: ['chart-title', 'category-list']}
92+
}
93+
}
94+
},
95+
{
96+
id: 'chart-title',
97+
component: {
98+
Text: {text: {path: 'chart.title'}, usageHint: 'h2'}
99+
}
100+
},
101+
{
102+
id: 'category-list',
103+
component: {
104+
List: {
105+
direction: 'vertical',
106+
children: {
107+
template: {
108+
componentId: 'category-item-template',
109+
dataBinding: '/chart.items'
110+
}
111+
}
112+
}
113+
}
114+
},
115+
{
116+
id: 'category-item-template',
117+
component: {Card: {child: 'item-row'}}
118+
},
119+
{
120+
id: 'item-row',
121+
component: {
122+
Row: {
123+
distribution: 'spaceBetween',
124+
children: {explicitList: ['item-label', 'item-value']}
125+
}
126+
}
127+
},
128+
{
129+
id: 'item-label',
130+
component: {Text: {text: {path: 'label'}}}
131+
},
132+
{
133+
id: 'item-value',
134+
component: {Text: {text: {path: 'value'}}}
135+
}
136+
]
137+
}
138+
} as unknown as Types.ServerToClientMessage;
139+
component.surfaceUpdate = message;
140+
141+
const changes: SimpleChanges = {
142+
surfaceUpdate: {
143+
currentValue: message,
144+
previousValue: null,
145+
firstChange: true,
146+
isFirstChange: () => true
147+
}
148+
};
149+
component.ngOnChanges(changes);
150+
151+
expect(mockMessageProcessor.processMessages).toHaveBeenCalledWith([message]);
152+
expect(component.surfaceId()).toBe('sales_data_yearly_surface');
153+
});
154+
155+
it('should process dataModelUpdate message', () => {
156+
const message = {
157+
dataModelUpdate: {
158+
surfaceId: 'sales_data_yearly_surface',
159+
path: '/',
160+
contents: [
161+
{key: 'chart.title', valueString: 'Yearly Sales by Category'},
162+
{key: 'chart.items[0].label', valueString: 'Apparel'},
163+
{key: 'chart.items[0].value', valueNumber: 41},
164+
{key: 'chart.items[1].label', valueString: 'Home Goods'},
165+
{key: 'chart.items[1].value', valueNumber: 15},
166+
{key: 'chart.items[2].label', valueString: 'Electronics'},
167+
{key: 'chart.items[2].value', valueNumber: 28},
168+
{key: 'chart.items[3].label', valueString: 'Health & Beauty'},
169+
{key: 'chart.items[3].value', valueNumber: 10},
170+
{key: 'chart.items[4].label', valueString: 'Other'},
171+
{key: 'chart.items[4].value', valueNumber: 6}
172+
]
173+
}
174+
} as unknown as Types.ServerToClientMessage;
175+
component.dataModelUpdate = message;
176+
177+
const changes: SimpleChanges = {
178+
dataModelUpdate: {
179+
currentValue: message,
180+
previousValue: null,
181+
firstChange: true,
182+
isFirstChange: () => true
183+
}
184+
};
185+
component.ngOnChanges(changes);
186+
187+
expect(mockMessageProcessor.processMessages).toHaveBeenCalledWith([message]);
188+
expect(component.surfaceId()).toBe('sales_data_yearly_surface');
189+
});
190+
191+
it('should update activeSurface when surfaceId matches', () => {
192+
const surfaceId = 'sales_data_yearly_surface';
193+
const mockSurface = {} as Types.Surface;
194+
const surfaces = new Map<string, Types.Surface>([[surfaceId, mockSurface]]);
195+
mockMessageProcessor.getSurfaces.and.returnValue(surfaces);
196+
197+
const message = {
198+
beginRendering: {surfaceId: surfaceId, root: 'root-column'}
199+
} as unknown as Types.ServerToClientMessage;
200+
201+
component.beginRendering = message;
202+
component.ngOnChanges({
203+
beginRendering: {
204+
currentValue: message,
205+
previousValue: null,
206+
firstChange: true,
207+
isFirstChange: () => true
208+
}
209+
});
210+
211+
expect(component.activeSurface()).toBe(mockSurface);
212+
});
213+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { CommonModule } from '@angular/common';
19+
import {
20+
ChangeDetectionStrategy,
21+
Component,
22+
inject,
23+
Input,
24+
OnChanges,
25+
SimpleChanges,
26+
signal,
27+
computed,
28+
} from '@angular/core';
29+
import { MessageProcessor, Surface } from '@a2ui/angular';
30+
import { Types } from '@a2ui/lit/0.8';
31+
32+
/**
33+
* Component responsible for rendering A2UI content on a canvas.
34+
*/
35+
@Component({
36+
selector: 'app-a2ui-canvas',
37+
template: `
38+
@if (surface()) {
39+
<a2ui-surface [surfaceId]="surfaceId()!" [surface]="surface()!" />
40+
}
41+
`,
42+
styleUrls: ['./a2ui-canvas.component.scss'],
43+
changeDetection: ChangeDetectionStrategy.OnPush,
44+
imports: [CommonModule, Surface],
45+
})
46+
export class A2uiCanvasComponent implements OnChanges {
47+
private readonly processor = inject(MessageProcessor);
48+
49+
@Input() beginRendering: Types.ServerToClientMessage | null = null;
50+
@Input() surfaceUpdate: Types.ServerToClientMessage | null = null;
51+
@Input() dataModelUpdate: Types.ServerToClientMessage | null = null;
52+
53+
readonly surfaceId = signal<string | null>(null);
54+
55+
readonly activeSurface = signal<Types.Surface | null>(null);
56+
readonly surface = computed(() => this.activeSurface());
57+
58+
constructor() {}
59+
60+
ngOnChanges(changes: SimpleChanges): void {
61+
const messages: Types.ServerToClientMessage[] = [];
62+
let detectedSurfaceId: string | null = null;
63+
64+
if (changes['beginRendering'] && this.beginRendering && Object.keys(this.beginRendering).length > 0) {
65+
messages.push(this.beginRendering);
66+
detectedSurfaceId = this.beginRendering?.beginRendering?.surfaceId ?? detectedSurfaceId;
67+
}
68+
if (changes['surfaceUpdate'] && this.surfaceUpdate && Object.keys(this.surfaceUpdate).length > 0) {
69+
messages.push(this.surfaceUpdate);
70+
detectedSurfaceId = this.surfaceUpdate?.surfaceUpdate?.surfaceId ?? detectedSurfaceId;
71+
}
72+
if (changes['dataModelUpdate'] && this.dataModelUpdate && Object.keys(this.dataModelUpdate).length > 0) {
73+
messages.push(this.dataModelUpdate);
74+
detectedSurfaceId = this.dataModelUpdate?.dataModelUpdate?.surfaceId ?? detectedSurfaceId;
75+
}
76+
77+
if (messages.length > 0) {
78+
this.processor.processMessages(messages);
79+
}
80+
81+
if (detectedSurfaceId) {
82+
this.surfaceId.set(detectedSurfaceId);
83+
}
84+
85+
// Refresh active surface from processor state
86+
const currentId = this.surfaceId();
87+
if (currentId) {
88+
const surfaces = this.processor.getSurfaces();
89+
if (surfaces.has(currentId)) {
90+
this.activeSurface.set(surfaces.get(currentId)!);
91+
}
92+
}
93+
}
94+
}

src/app/components/chat-panel/chat-panel.component.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@
113113
<div [innerHTML]="renderGooglerSearch(message.renderedContent)"></div>
114114
</div>
115115
}
116+
@if (message.a2uiData) {
117+
<app-a2ui-canvas
118+
[beginRendering]="message.a2uiData.beginRendering"
119+
[surfaceUpdate]="message.a2uiData.surfaceUpdate"
120+
[dataModelUpdate]="message.a2uiData.dataModelUpdate">
121+
</app-a2ui-canvas>
122+
}
116123
</div>
117124
@if (message.executableCode) {
118125
<code> {{ message.executableCode.code }} </code>

src/app/components/chat-panel/chat-panel.component.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,15 @@ describe('ChatPanelComponent', () => {
166166
deleteButton.nativeElement.click();
167167
expect(component.removeFile.emit).toHaveBeenCalledWith(0);
168168
});
169+
170+
it('should display A2UI canvas', () => {
171+
component.messages = [
172+
{role: 'bot', a2uiData: {beginRendering: true, surfaceUpdate: {}, dataModelUpdate: {}}},
173+
];
174+
fixture.detectChanges();
175+
const canvas = fixture.debugElement.query(By.css('app-a2ui-canvas'));
176+
expect(canvas).toBeTruthy();
177+
});
169178
});
170179

171180
it('should display loading bar if message isLoading', async () => {

src/app/components/chat-panel/chat-panel.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {STRING_TO_COLOR_SERVICE} from '../../core/services/interfaces/string-to-
4343
import {ListResponse} from '../../core/services/interfaces/types';
4444
import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state';
4545
import {MediaType,} from '../artifact-tab/artifact-tab.component';
46+
import {A2uiCanvasComponent} from '../a2ui-canvas/a2ui-canvas.component';
4647
import {AudioPlayerComponent} from '../audio-player/audio-player.component';
4748
import {MARKDOWN_COMPONENT, MarkdownComponentInterface} from '../markdown/markdown.component.interface';
4849
import {MessageFeedbackComponent} from '../message-feedback/message-feedback.component';
@@ -70,6 +71,7 @@ const ROOT_AGENT = 'root_agent';
7071
MatMenuModule,
7172
MatProgressSpinnerModule,
7273
NgxJsonViewerModule,
74+
A2uiCanvasComponent,
7375
AudioPlayerComponent,
7476
MessageFeedbackComponent,
7577
MatTooltipModule,

0 commit comments

Comments
 (0)