ai/ai-chat/ai-chat.component.ts
An interactive chat interface component for AI-powered conversations. Displays messages in a conversation format with support for loading states, custom configuration, and markdown formatting.
| changeDetection | ChangeDetectionStrategy.OnPush |
| host | { |
| selector | c8y-ai-chat |
| standalone | true |
| imports |
C8yTranslatePipe
MarkdownToHtmlPipe
TooltipModule
BsDropdownModule
NumberPipe
CdkTrapFocus
|
| templateUrl | ./ai-chat.component.html |
C8yTranslatePipe
FormsModule
TextareaAutoresizeDirective
IconDirective
NgClass
NgTemplateOutlet
MarkdownToHtmlPipe
AsyncPipe
TooltipModule
BsDropdownModule
NumberPipe
AiChatSpeechToTextComponent
CdkTrapFocus
<div
class="d-col fit-h fit-w flex-grow min-height-0"
[attr.aria-label]="config.headline | translate"
role="region"
>
<!-- Streamed tokens are withheld while the log is busy, so announce start and end here. -->
<div
class="sr-only"
aria-live="polite"
role="status"
>
{{ statusAnnouncement() | translate }}
</div>
@if (messages().length > 0) {
<div
[attr.aria-label]="'Chat conversation' | translate"
aria-live="polite"
aria-atomic="false"
[attr.aria-busy]="isLoading"
role="log"
#chatScrollContainer
[ngClass]="{
'inner-scroll': true,
'd-col-reverse': true,
'scrollbar-only-on-hover': config.scrollbarOnlyOnHover,
'flex-grow': true,
'min-height-0': true,
'bg-level-0': true
}"
>
<div class="d-col p-l-16 p-r-16">
<ng-content select="[slot='before-messages']"></ng-content>
<ng-content select="c8y-ai-chat-message"></ng-content>
<ng-content select="[slot='after-messages']"></ng-content>
</div>
</div>
}
<div
[ngClass]="{
'd-col fit-h': messages().length === 0
}"
>
@if (messages().length === 0) {
<div
class="p-24 d-col fit-h inner-scroll"
aria-live="polite"
role="status"
[ngClass]="{
'j-c-start': config.welcomePosition === 'top',
'j-c-center': config.welcomePosition === 'center',
'j-c-end': config.welcomePosition === 'bottom'
}"
>
<h4 class="m-b-16 text-medium">{{ config.headline | translate }}</h4>
@if (config.title.length > 0) {
<p class="p-b-8 text-pretty">{{ config.title | translate }}</p>
}
<div class="text-pretty chat-message min-height-0">
<div
class="text-muted"
[innerHTML]="config.welcomeText | translate | markdownToHtml | async"
></div>
@if (welcomeTemplate) {
<ng-container *ngTemplateOutlet="welcomeTemplate"></ng-container>
}
</div>
</div>
}
<div
class="chat-input"
[class.bg-level-1]="config.appearance !== 'flat'"
>
<!-- For simple cases allow ng-content projection; however this doesn't seem to work with dynamic
suggestion lists from a signal (e.g. returned from the AI) so also support `suggestionsTemplate` for that.
-->
@if (!isLoading) {
<div
[class]="
'd-flex inner-scroll gap-8 p-l-16 p-r-16 p-b-16 ' +
(config.suggestionsLayout === 'vertical' ? 'flex-wrap' : 'a-i-center')
"
[attr.aria-label]="'Suggested prompts' | translate"
role="group"
>
<ng-content select="c8y-ai-chat-suggestion"></ng-content>
@if (suggestionsTemplate) {
<ng-container *ngTemplateOutlet="suggestionsTemplate"></ng-container>
}
</div>
}
<div class="chat-input-group">
<label
class="sr-only"
for="chat-input-{{ componentId }}"
>
{{ config.placeholder | translate }}
</label>
<textarea
class="form-control no-resize"
[class.text-muted]="isLoading"
style="max-height: 200px !important"
[attr.aria-label]="config.placeholder | translate"
id="chat-input-{{ componentId }}"
placeholder="{{
(isListeningForSpeech() && config.listeningPlaceholder
? config.listeningPlaceholder
: config.placeholder
) | translate
}}"
[attr.aria-describedby]="config.disclaimerText ? 'chat-disclaimer-' + componentId : null"
[attr.aria-busy]="isLoading"
[(ngModel)]="prompt"
(keydown.enter)="!isLoading && sendMessage($event)"
[disabled]="disabled"
c8y-textarea-autoresize
#chatInput
></textarea>
<div class="chat-input-group-btn">
<ng-content select="[slot='input-actions']"></ng-content>
@if (config.enableSpeechToText !== false) {
<c8y-ai-chat-speech-to-text
[(prompt)]="prompt"
[disabled]="disabled || isLoading"
(listeningChange)="isListeningForSpeech.set($event)"
></c8y-ai-chat-speech-to-text>
}
</div>
</div>
<div class="chat-action-bar d-flex a-i-center gap-4 bg-level-0">
<div class="d-flex a-i-center gap-4">
@if (moreActions().length > 0) {
<div
class="dropdown"
dropdown
#moreChatActions="bs-dropdown"
[cdkTrapFocus]="moreChatActions.isOpen"
[dropup]="true"
>
<button
class="btn btn-dot"
[attr.title]="'More actions' | translate"
[attr.aria-label]="'More actions' | translate"
aria-haspopup="true"
type="button"
dropdownToggle
>
<i [c8yIcon]="'ellipsis-h'"></i>
</button>
<ul
class="dropdown-menu dropup"
role="menu"
*dropdownMenu
>
@for (action of moreActions(); track action) {
<li>
<ng-container *ngTemplateOutlet="action.template()"></ng-container>
</li>
}
</ul>
</div>
}
@for (action of leftActions(); track action) {
<ng-container *ngTemplateOutlet="action.template()"></ng-container>
}
</div>
<div class="d-flex a-i-center gap-4 m-l-auto">
@for (action of rightActions(); track action) {
<ng-container *ngTemplateOutlet="action.template()"></ng-container>
}
@if (!isLoading) {
<button
class="btn btn-dot"
[attr.title]="config.sendButtonText || '' | translate"
[attr.aria-label]="config.sendButtonText || '' | translate"
type="button"
(click)="sendMessage($event)"
[disabled]="disabled || prompt.trim().length === 0"
>
<i [c8yIcon]="config.userInterfaceIcons.send || 'arrow-circle-right'"></i>
</button>
} @else {
<button
class="btn btn-dot btn-dot--danger"
[attr.title]="config.cancelButtonText || '' | translate"
[attr.aria-label]="config.cancelButtonText || '' | translate"
type="button"
(click)="cancel()"
>
<i [c8yIcon]="config.userInterfaceIcons.cancel || 'stop'"></i>
</button>
}
</div>
</div>
<div class="d-flex p-t-8 p-l-16 p-r-16 p-b-8 gap-8">
@if (config.disclaimerText) {
<div
class="text-muted text-10"
id="chat-disclaimer-{{ componentId }}"
role="note"
>
{{ config.disclaimerText | translate }}
</div>
}
@if (cumulativeUsage) {
<button
class="btn-clean m-l-auto d-flex a-s-center"
[attr.aria-label]="
'Input tokens: {{ input }}\nOutput tokens: {{ output }}'
| translate
: {
input: cumulativeUsage?.inputTokens || 0 | c8yNumber: 'floor' : '1.0-0',
output: cumulativeUsage?.outputTokens || 0 | c8yNumber: 'floor' : '1.0-0'
}
"
tabindex="0"
[tooltip]="
'Input tokens: {{ input }}\nOutput tokens: {{ output }}'
| translate
: {
input: cumulativeUsage?.inputTokens || 0 | c8yNumber: 'floor' : '1.0-0',
output: cumulativeUsage?.outputTokens || 0 | c8yNumber: 'floor' : '1.0-0'
}
"
>
<span class="tag tag--info a-s-center">
{{ 'Tokens used: {{total}}' | translate: { total: (cumulativeUsage?.totalTokens || 0)
| c8yNumber : 'floor':'1.0-0' } }}
</span>
</button>
}
</div>
</div>
</div>
</div>