Skip to content

Commit 51aefc7

Browse files
committed
feat(chat-messages): add experimental tool message to ai-chat-container
1 parent 8e62a16 commit 51aefc7

File tree

5 files changed

+204
-27
lines changed

5 files changed

+204
-27
lines changed

api-goldens/element-ng/chat-messages/index.api.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export interface Attachment {
3939
export interface BaseChatMessage {
4040
content?: string | Signal<string>;
4141
loading?: boolean | Signal<boolean>;
42-
type: 'user' | 'ai';
42+
type: 'user' | 'ai' | 'tool';
4343
}
4444

4545
// @public
@@ -50,7 +50,7 @@ export interface ChatInputAttachment extends Attachment {
5050
}
5151

5252
// @public
53-
export type ChatMessage = UserChatMessage | AiChatMessage | TemplateChatMessage;
53+
export type ChatMessage = UserChatMessage | AiChatMessage | ToolChatMessage | TemplateChatMessage;
5454

5555
// @public
5656
export interface MessageAction {
@@ -73,15 +73,14 @@ export class SiAiChatContainerComponent {
7373
// (undocumented)
7474
focus(): void;
7575
// (undocumented)
76-
protected getContentValue(content: string | Signal<string> | undefined): string;
77-
// (undocumented)
78-
protected getLoadingState(messageLoading: boolean | Signal<boolean> | undefined, content: string | Signal<string> | undefined, isLatest: boolean, globalLoading?: boolean): boolean;
76+
protected getContentValue<T extends string | object>(content: T | Signal<T> | undefined): T;
77+
protected getLoadingState(messageLoading: boolean | Signal<boolean> | undefined, content: string | object | Signal<string | object> | undefined, isLatest: boolean, globalLoading?: boolean, allowEmptyContent?: boolean): boolean;
7978
// (undocumented)
8079
protected getMessagePrimaryActions(message: ChatMessage): MessageAction[];
8180
// (undocumented)
8281
protected getMessageSecondaryActions(message: ChatMessage): MenuItem[];
8382
// (undocumented)
84-
protected getOutputValue(content: string | Signal<string> | undefined): string | Signal<string>;
83+
protected getOutputValue(outputValue: string | object | Signal<string | object> | undefined): string | object | undefined;
8584
// (undocumented)
8685
protected readonly inputInterruptible: Signal<boolean>;
8786
// (undocumented)
@@ -106,6 +105,10 @@ export class SiAiChatContainerComponent {
106105
readonly noAutoScroll: _angular_core.InputSignalWithTransform<boolean, unknown>;
107106
readonly secondaryActionsLabel: _angular_core.InputSignal<TranslatableString_2>;
108107
readonly sending: _angular_core.InputSignalWithTransform<boolean, unknown>;
108+
// (undocumented)
109+
protected shouldAutoExpandInputArguments(message: ChatMessage): boolean;
110+
// (undocumented)
111+
protected shouldAutoExpandOutput(message: ChatMessage): boolean;
109112
readonly statusAction: _angular_core.InputSignal<{
110113
title: string;
111114
href: string;
@@ -114,8 +117,10 @@ export class SiAiChatContainerComponent {
114117
readonly statusHeading: _angular_core.InputSignal<string | undefined>;
115118
readonly statusMessage: _angular_core.InputSignal<string | undefined>;
116119
readonly statusSeverity: _angular_core.InputSignal<"info" | "success" | "warning" | "danger" | "caution" | "critical" | undefined>;
120+
readonly toolInputArgumentsLabel: _angular_core.InputSignal<TranslatableString_2>;
121+
readonly toolOutputLabel: _angular_core.InputSignal<TranslatableString_2>;
117122
// (undocumented)
118-
static ɵcmp: _angular_core.ɵɵComponentDeclaration<SiAiChatContainerComponent, "si-ai-chat-container", never, { "messages": { "alias": "messages"; "required": false; "isSignal": true; }; "sending": { "alias": "sending"; "required": false; "isSignal": true; }; "loading": { "alias": "loading"; "required": false; "isSignal": true; }; "disableInterrupt": { "alias": "disableInterrupt"; "required": false; "isSignal": true; }; "interrupting": { "alias": "interrupting"; "required": false; "isSignal": true; }; "noAutoScroll": { "alias": "noAutoScroll"; "required": false; "isSignal": true; }; "aiIcon": { "alias": "aiIcon"; "required": false; "isSignal": true; }; "colorVariant": { "alias": "colorVariant"; "required": false; "isSignal": true; }; "emptyStateTitle": { "alias": "emptyStateTitle"; "required": false; "isSignal": true; }; "emptyStateDescription": { "alias": "emptyStateDescription"; "required": false; "isSignal": true; }; "secondaryActionsLabel": { "alias": "secondaryActionsLabel"; "required": false; "isSignal": true; }; "statusSeverity": { "alias": "statusSeverity"; "required": false; "isSignal": true; }; "statusHeading": { "alias": "statusHeading"; "required": false; "isSignal": true; }; "statusMessage": { "alias": "statusMessage"; "required": false; "isSignal": true; }; "statusAction": { "alias": "statusAction"; "required": false; "isSignal": true; }; }, { "messageSent": "messageSent"; }, ["chatInput"], ["si-chat-input"], true, [{ directive: typeof i1.SiResponsiveContainerDirective; inputs: {}; outputs: {}; }]>;
123+
static ɵcmp: _angular_core.ɵɵComponentDeclaration<SiAiChatContainerComponent, "si-ai-chat-container", never, { "messages": { "alias": "messages"; "required": false; "isSignal": true; }; "sending": { "alias": "sending"; "required": false; "isSignal": true; }; "loading": { "alias": "loading"; "required": false; "isSignal": true; }; "disableInterrupt": { "alias": "disableInterrupt"; "required": false; "isSignal": true; }; "interrupting": { "alias": "interrupting"; "required": false; "isSignal": true; }; "noAutoScroll": { "alias": "noAutoScroll"; "required": false; "isSignal": true; }; "aiIcon": { "alias": "aiIcon"; "required": false; "isSignal": true; }; "colorVariant": { "alias": "colorVariant"; "required": false; "isSignal": true; }; "emptyStateTitle": { "alias": "emptyStateTitle"; "required": false; "isSignal": true; }; "emptyStateDescription": { "alias": "emptyStateDescription"; "required": false; "isSignal": true; }; "secondaryActionsLabel": { "alias": "secondaryActionsLabel"; "required": false; "isSignal": true; }; "statusSeverity": { "alias": "statusSeverity"; "required": false; "isSignal": true; }; "statusHeading": { "alias": "statusHeading"; "required": false; "isSignal": true; }; "statusMessage": { "alias": "statusMessage"; "required": false; "isSignal": true; }; "statusAction": { "alias": "statusAction"; "required": false; "isSignal": true; }; "toolInputArgumentsLabel": { "alias": "toolInputArgumentsLabel"; "required": false; "isSignal": true; }; "toolOutputLabel": { "alias": "toolOutputLabel"; "required": false; "isSignal": true; }; }, { "messageSent": "messageSent"; }, ["chatInput"], ["si-chat-input"], true, [{ directive: typeof i1.SiResponsiveContainerDirective; inputs: {}; outputs: {}; }]>;
119124
// (undocumented)
120125
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SiAiChatContainerComponent, never>;
121126
}
@@ -349,6 +354,17 @@ export interface TemplateChatMessage {
349354
templateContext?: any;
350355
}
351356

357+
// @public (undocumented)
358+
export interface ToolChatMessage extends BaseChatMessage {
359+
autoExpandInputArguments?: boolean;
360+
autoExpandOutput?: boolean;
361+
icon?: string;
362+
inputArguments?: string | object;
363+
name: string;
364+
output?: string | object | Signal<string | object>;
365+
type: 'tool';
366+
}
367+
352368
// @public
353369
export interface UserChatMessage extends BaseChatMessage {
354370
actions?: MessageAction[];

projects/element-ng/chat-messages/chat-message.model.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export interface Attachment {
4848
*/
4949
export interface BaseChatMessage {
5050
/** Type of message */
51-
type: 'user' | 'ai';
51+
type: 'user' | 'ai' | 'tool';
5252
/** Message content - can be a string or a Signal<string>, empty string shows loading state */
5353
content?: string | Signal<string>;
5454
/** Whether the message is currently loading/being generated - can be a boolean or Signal<boolean> */
@@ -83,6 +83,24 @@ export interface AiChatMessage extends BaseChatMessage {
8383
actions?: MessageAction[];
8484
}
8585

86+
/** @experimental */
87+
export interface ToolChatMessage extends BaseChatMessage {
88+
/** Type of message */
89+
type: 'tool';
90+
/** Tool name/title */
91+
name: string;
92+
/** Input arguments for the tool call */
93+
inputArguments?: string | object;
94+
/** Output result from the tool call - can be a string/object or Signal\<string | object\>, empty does not show loading state. */
95+
output?: string | object | Signal<string | object>;
96+
/** Whether the input arguments section should be expanded by default if it's the latest message (and closed after) */
97+
autoExpandInputArguments?: boolean;
98+
/** Whether the output section should be expanded by default if it's the latest message (and closed after) */
99+
autoExpandOutput?: boolean;
100+
/** Alternative tool icon, defaults to 'element-maintenance' */
101+
icon?: string;
102+
}
103+
86104
/**
87105
* Render custom chat message via template
88106
* @experimental
@@ -101,4 +119,4 @@ export interface TemplateChatMessage {
101119
* Chat message type union
102120
* @experimental
103121
*/
104-
export type ChatMessage = UserChatMessage | AiChatMessage | TemplateChatMessage;
122+
export type ChatMessage = UserChatMessage | AiChatMessage | ToolChatMessage | TemplateChatMessage;

projects/element-ng/chat-messages/si-ai-chat-container.component.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@
4545
[contentFormatter]="markdownRenderer"
4646
/>
4747
}
48+
49+
@if (message.type === 'tool') {
50+
<si-tool-message
51+
[name]="message.name"
52+
[inputArguments]="message.inputArguments"
53+
[output]="getOutputValue(message.output)"
54+
[toolIcon]="message.icon ?? 'element-maintenance'"
55+
[loading]="getLoadingState(message.loading, message.output, false, false, true)"
56+
[inputArgumentsLabel]="toolInputArgumentsLabel()"
57+
[outputLabel]="toolOutputLabel()"
58+
[expandInputArguments]="shouldAutoExpandInputArguments(message)"
59+
[expandOutput]="shouldAutoExpandOutput(message)"
60+
/>
61+
}
4862
}
4963
}
5064
}

projects/element-ng/chat-messages/si-ai-chat-container.component.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,30 @@ describe('SiAiChatContainerComponent', () => {
118118
expect(aiMessage).toBeTruthy();
119119
});
120120

121+
it('should render tool messages', () => {
122+
const messages: ChatMessage[] = [
123+
{
124+
type: 'tool',
125+
name: 'Calculator',
126+
content: '',
127+
output: '42'
128+
}
129+
];
130+
131+
fixture.componentRef.setInput('messages', messages);
132+
fixture.detectChanges();
133+
134+
const toolMessage = debugElement.query(By.css('si-tool-message'));
135+
expect(toolMessage).toBeTruthy();
136+
});
137+
138+
it('should not render status notification when statusSeverity is not set', () => {
139+
fixture.detectChanges();
140+
141+
const notification = debugElement.query(By.css('si-inline-notification'));
142+
expect(notification).toBeFalsy();
143+
});
144+
121145
it('should render loading AI message when loading is true', () => {
122146
fixture.componentRef.setInput('messages', []);
123147
fixture.componentRef.setInput('loading', true);
@@ -378,6 +402,28 @@ describe('SiAiChatContainerComponent', () => {
378402
expect(aiMessage).toBeTruthy();
379403
});
380404

405+
it('should display tool message', () => {
406+
const messages: ChatMessage[] = [
407+
{
408+
type: 'ai',
409+
content: 'Answer'
410+
},
411+
{
412+
type: 'tool',
413+
name: 'Tool',
414+
content: '',
415+
output: 'Result'
416+
}
417+
];
418+
419+
fixture.componentRef.setInput('messages', messages);
420+
fixture.detectChanges();
421+
422+
const toolMessages = debugElement.queryAll(By.css('si-tool-message'));
423+
424+
expect(toolMessages.length).toBe(1);
425+
});
426+
381427
it('should apply color variant to underlying container', () => {
382428
fixture.componentRef.setInput('colorVariant', 'base-1');
383429
fixture.detectChanges();

projects/element-ng/chat-messages/si-ai-chat-container.component.ts

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { SiAiMessageComponent } from './si-ai-message.component';
3434
import { SiChatContainerInputDirective } from './si-chat-container-input.directive';
3535
import { SiChatContainerComponent } from './si-chat-container.component';
3636
import { ChatInputAttachment, SiChatInputComponent } from './si-chat-input.component';
37+
import { SiToolMessageComponent } from './si-tool-message.component';
3738
import { SiUserMessageComponent } from './si-user-message.component';
3839

3940
/** @experimental */
@@ -44,6 +45,7 @@ import { SiUserMessageComponent } from './si-user-message.component';
4445
SiEmptyStateComponent,
4546
SiInlineNotificationComponent,
4647
SiAiMessageComponent,
48+
SiToolMessageComponent,
4749
SiUserMessageComponent,
4850
SiChatContainerComponent,
4951
SiChatContainerInputDirective
@@ -185,6 +187,30 @@ export class SiAiChatContainerComponent {
185187
*/
186188
readonly statusAction = input<{ title: string; href: string; target?: string }>();
187189

190+
/**
191+
* Label for tool message input arguments section
192+
*
193+
* @defaultValue
194+
* ```
195+
* t(() => $localize`:@@SI_TOOL_MESSAGE.INPUT_ARGUMENTS:Input Arguments`)
196+
* ```
197+
*/
198+
readonly toolInputArgumentsLabel = input<TranslatableString>(
199+
t(() => $localize`:@@SI_TOOL_MESSAGE.INPUT_ARGUMENTS:Input Arguments`)
200+
);
201+
202+
/**
203+
* Label for tool message output section
204+
*
205+
* @defaultValue
206+
* ```
207+
* t(() => $localize`:@@SI_TOOL_MESSAGE.OUTPUT:Output`)
208+
* ```
209+
*/
210+
readonly toolOutputLabel = input<TranslatableString>(
211+
t(() => $localize`:@@SI_TOOL_MESSAGE.OUTPUT:Output`)
212+
);
213+
188214
/**
189215
* Emitted when a new message is sent
190216
*/
@@ -207,6 +233,7 @@ export class SiAiChatContainerComponent {
207233
const messages = this.messages();
208234
if (!messages?.length) {
209235
if (this.loading()) {
236+
// If loading but no messages, show a single loading AI message
210237
const loadingMessage: AiChatMessage = {
211238
type: 'ai',
212239
content: '',
@@ -217,11 +244,16 @@ export class SiAiChatContainerComponent {
217244
return [];
218245
}
219246

247+
// If global loading is true, check if we need to add an AI message
220248
if (this.loading() && messages.length > 0) {
221249
const latestMessage = messages[messages.length - 1];
222250

251+
// Add empty AI message if:
252+
// 1. Latest message is tool call and global loading is on, OR
253+
// 2. Latest message has content and not loading, but global loading is on
223254
const shouldAddAiMessage =
224255
this.isTemplateMessage(latestMessage) ||
256+
latestMessage.type === 'tool' ||
225257
(latestMessage.type === 'ai' &&
226258
this.getContentValue(latestMessage.content) &&
227259
!this.getLoadingState(latestMessage.loading, latestMessage.content, false) &&
@@ -247,7 +279,7 @@ export class SiAiChatContainerComponent {
247279
primary: MessageAction[];
248280
secondary: MenuItem[];
249281
} {
250-
if (this.isTemplateMessage(message)) {
282+
if (this.isTemplateMessage(message) || message.type === 'tool') {
251283
return { primary: [], secondary: [] };
252284
}
253285

@@ -297,49 +329,100 @@ export class SiAiChatContainerComponent {
297329
}
298330
}
299331

300-
protected getContentValue(content: string | Signal<string> | undefined): string {
301-
if (isSignal(content)) {
302-
return content();
303-
}
304-
return content ?? '';
332+
protected getContentValue<T extends string | object>(content: T | Signal<T> | undefined): T {
333+
if (!content) return '' as T;
334+
return isSignal(content) ? (content as Signal<T>)() : content;
305335
}
306336

307-
protected getOutputValue(content: string | Signal<string> | undefined): string | Signal<string> {
308-
return content ?? '';
337+
protected getOutputValue(
338+
outputValue: string | object | Signal<string | object> | undefined
339+
): string | object | undefined {
340+
if (outputValue === undefined || outputValue === null) return undefined;
341+
return isSignal(outputValue) ? (outputValue as Signal<string | object>)() : outputValue;
309342
}
310343

311-
private isEmptyContent(content: string | Signal<string> | undefined): boolean {
344+
private isEmptyContent(content: string | object | Signal<string | object> | undefined): boolean {
312345
const contentValue = this.getContentValue(content);
313-
return !contentValue || contentValue.trim().length === 0;
346+
347+
return !contentValue;
314348
}
315349

316350
private getLoadingValue(loading: boolean | Signal<boolean> | undefined): boolean {
317-
if (isSignal(loading)) {
318-
return loading();
319-
}
320-
return loading ?? false;
351+
return loading !== undefined ? (isSignal(loading) ? loading() : loading) : false;
321352
}
322353

323-
private isStreamingContent(content: string | Signal<string> | undefined): boolean {
324-
return isSignal(content) && this.getContentValue(content).trim().length > 0;
354+
private isStreamingContent(
355+
content: string | object | Signal<string | object> | undefined
356+
): boolean {
357+
return isSignal(content) && !this.isEmptyContent(content);
325358
}
326359

360+
/**
361+
* Helper method to get loading state from boolean or signal
362+
* If content (or signal content) is not empty but loading is true, assume streaming (no loading state shown)
363+
* If global loading is true and content is empty, show loading state
364+
*/
327365
protected getLoadingState(
328366
messageLoading: boolean | Signal<boolean> | undefined,
329-
content: string | Signal<string> | undefined,
367+
content: string | object | Signal<string | object> | undefined,
330368
isLatest: boolean,
331-
globalLoading: boolean = false
369+
globalLoading: boolean = false,
370+
allowEmptyContent: boolean = false
332371
): boolean {
333372
const messageLoadingValue = this.getLoadingValue(messageLoading);
334373
const isEmptyContent = this.isEmptyContent(content);
335374

375+
// If the content is empty, always show loading
376+
if (isEmptyContent && !allowEmptyContent) {
377+
return true;
378+
}
379+
336380
return messageLoadingValue || (isLatest && globalLoading && isEmptyContent);
337381
}
338382

339383
protected isLatestMessage(message: ChatMessage): boolean {
340384
const messages = this.displayMessages();
385+
if (!messages || messages.length === 0) return false;
341386
return messages[messages.length - 1] === message;
342387
}
388+
private isLatestToolMessage(message: ChatMessage): boolean {
389+
const messages = this.displayMessages();
390+
if (!messages || messages.length === 0) return false;
391+
392+
// Find the index of the message
393+
const messageIndex = messages.findIndex(m => m === message);
394+
if (messageIndex === -1) return false;
395+
396+
// If it's the last message, it's the latest
397+
if (messageIndex === messages.length - 1) return true;
398+
399+
// If it's not second to last, it's not the latest
400+
if (messageIndex < messages.length - 2) return false;
401+
402+
// Check if the next (actually last) message is a loading message
403+
const nextMessage = messages[messageIndex + 1];
404+
405+
// If next message is a single AI (or loading) message that just started, count this as latest
406+
if (!this.isTemplateMessage(nextMessage) && nextMessage.type === 'ai') return true;
407+
408+
return false;
409+
}
410+
411+
protected shouldAutoExpandInputArguments(message: ChatMessage): boolean {
412+
if (this.isTemplateMessage(message) || message.type !== 'tool') return false;
413+
if (!message.autoExpandInputArguments) {
414+
return false;
415+
}
416+
return this.isLatestToolMessage(message);
417+
}
418+
419+
protected shouldAutoExpandOutput(message: ChatMessage): boolean {
420+
if (this.isTemplateMessage(message) || message.type !== 'tool') return false;
421+
if (!message.autoExpandOutput) {
422+
return false;
423+
}
424+
return this.isLatestToolMessage(message);
425+
}
343426

344427
protected readonly isSignal = isSignal;
345428
}

0 commit comments

Comments
 (0)