UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

382 lines (377 loc) 84.8 kB
import * as i0 from '@angular/core'; import { input, signal, inject, ChangeDetectionStrategy, Component, Pipe, computed, effect, EventEmitter, Output, Input, output, contentChildren, viewChild } from '@angular/core'; import { IconDirective, C8yTranslateModule, C8yTranslatePipe, C8yTranslateDirective, MarkdownToHtmlPipe, DatePipe, TextareaAutoresizeDirective, NumberPipe } from '@c8y/ngx-components'; import { gettext } from '@c8y/ngx-components/gettext'; import { TranslateService } from '@ngx-translate/core'; import * as i1 from 'ngx-bootstrap/collapse'; import { CollapseModule } from 'ngx-bootstrap/collapse'; import { NgComponentOutlet, JsonPipe, AsyncPipe, NgClass, NgTemplateOutlet } from '@angular/common'; import * as i1$1 from 'ngx-bootstrap/tooltip'; import { TooltipModule } from 'ngx-bootstrap/tooltip'; import * as i1$2 from '@angular/forms'; import { FormsModule } from '@angular/forms'; class AiChatToolCallComponent { constructor() { /** * The tool call part to render. This includes all information about the tool call, including the name of the tool, * the input provided and (once available) the output from the tool. */ this.tool = input.required(...(ngDevMode ? [{ debugName: "tool" }] : [])); /** * Whether the tool call is still in progress. */ this.isExecuting = input.required(...(ngDevMode ? [{ debugName: "isExecuting" }] : [])); /** * A custom component to render the details of the tool call. If not provided, the default details section will be shown. */ this.toolDetailsComponent = input(undefined, ...(ngDevMode ? [{ debugName: "toolDetailsComponent" }] : [])); /** * Shows the default details section for a tool call. */ this.showDefaultToolDetails = input(false, ...(ngDevMode ? [{ debugName: "showDefaultToolDetails" }] : [])); /** * The label to show while the tool is executing. If not provided, a default label will be generated based on the tool name. */ this.executingLabel = input(undefined, ...(ngDevMode ? [{ debugName: "executingLabel" }] : [])); /** * The label to show once the tool has completed. If not provided, a default label will be generated based on the tool name and whether the tool call was successful or resulted in an error. */ this.completedLabel = input(undefined, ...(ngDevMode ? [{ debugName: "completedLabel" }] : [])); /** * A function that can generate a label for this tool call based on the tool call data. */ this.labelProvider = input(...(ngDevMode ? [undefined, { debugName: "labelProvider" }] : [])); this.expanded = signal(false, ...(ngDevMode ? [{ debugName: "expanded" }] : [])); this.everExpanded = signal(false, ...(ngDevMode ? [{ debugName: "everExpanded" }] : [])); this.translateService = inject(TranslateService); } getToolLabel(tool) { const isExecuting = tool.type !== 'tool-result'; if (this.labelProvider()) { try { const dynamicLabel = this.labelProvider()(tool, this.translateService); if (dynamicLabel) return dynamicLabel; } catch (err) { // Should never happen, report it then fall back to default logic console.error(`Error in labelProvider for tool ${tool.toolName}:`, err); } } if (isExecuting && this.executingLabel()) { return this.executingLabel(); } if (!isExecuting && this.completedLabel()) { return this.completedLabel(); } return this.translateService.instant(isExecuting ? gettext('Calling tool {{toolName}}') : gettext('Used tool {{toolName}}'), { toolName: tool.toolName }); } toggleExpanded() { const willExpand = !this.expanded(); this.expanded.set(willExpand); if (willExpand) { this.everExpanded.set(true); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: AiChatToolCallComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: AiChatToolCallComponent, isStandalone: true, selector: "c8y-ai-chat-tool-call", inputs: { tool: { classPropertyName: "tool", publicName: "tool", isSignal: true, isRequired: true, transformFunction: null }, isExecuting: { classPropertyName: "isExecuting", publicName: "isExecuting", isSignal: true, isRequired: true, transformFunction: null }, toolDetailsComponent: { classPropertyName: "toolDetailsComponent", publicName: "toolDetailsComponent", isSignal: true, isRequired: false, transformFunction: null }, showDefaultToolDetails: { classPropertyName: "showDefaultToolDetails", publicName: "showDefaultToolDetails", isSignal: true, isRequired: false, transformFunction: null }, executingLabel: { classPropertyName: "executingLabel", publicName: "executingLabel", isSignal: true, isRequired: false, transformFunction: null }, completedLabel: { classPropertyName: "completedLabel", publicName: "completedLabel", isSignal: true, isRequired: false, transformFunction: null }, labelProvider: { classPropertyName: "labelProvider", publicName: "labelProvider", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "@let _tool = tool();\n@let showDetails = showDefaultToolDetails() || toolDetailsComponent() || _tool.error;\n@let isToolExpanded = expanded();\n\n<fieldset\n class=\"c8y-fieldset p-b-4 ai-tool-call__fieldset\"\n [attr.aria-label]=\"'Tool call: ' + _tool.toolName\"\n>\n <button\n class=\"btn-clean ai-tool-call__btn\"\n [attr.aria-expanded]=\"showDetails ? isToolExpanded : null\"\n [attr.aria-controls]=\"showDetails ? 'tool-call-' + _tool.toolCallId : null\"\n type=\"button\"\n [attr.data-cy]=\"'tool-call-' + _tool.toolName\"\n [disabled]=\"!showDetails || null\"\n (click)=\"toggleExpanded()\"\n >\n @if (isExecuting()) {\n <i\n class=\"icon-spin icon-14 text-primary m-r-4\"\n c8yIcon=\"spinner\"\n aria-hidden=\"true\"\n ></i>\n } @else {\n <!-- Treat it as an error if a tool still thinks its executing when the message containing it is not -->\n @if (_tool.error || isExecuting()) {\n <i\n class=\"icon-14 text-danger m-r-4\"\n [c8yIcon]=\"'exclamation-circle'\"\n aria-hidden=\"true\"\n ></i>\n } @else {\n <i\n class=\"icon-14 text-success m-r-4\"\n [c8yIcon]=\"'check'\"\n aria-hidden=\"true\"\n ></i>\n }\n }\n <span class=\"small\">{{ getToolLabel(_tool) | translate }}</span>\n\n @if (showDetails) {\n <i\n class=\"m-l-4 icon-12\"\n [c8yIcon]=\"isToolExpanded ? 'collapse-arrow' : 'expand-arrow'\"\n aria-hidden=\"true\"\n ></i>\n }\n </button>\n\n <!-- If this is an artifact tool, render it - but do so lazily only on first open,\n since these components could be heavyweight (e.g. Monaco)\n -->\n @if (showDetails && (isToolExpanded || everExpanded())) {\n <div\n class=\"collapse tool-details m-t-8 p-8 b-r-4\"\n [attr.aria-label]=\"'Tool details for message latest tool: ' + _tool.toolName\"\n role=\"region\"\n [collapse]=\"!isToolExpanded\"\n [id]=\"'tool-call-' + _tool.toolCallId\"\n [isAnimated]=\"true\"\n data-cy=\"ai-tool-component\"\n >\n @if (toolDetailsComponent()) {\n <ng-container\n [ngComponentOutlet]=\"toolDetailsComponent()\"\n [ngComponentOutletInputs]=\"{ tool: _tool }\"\n ></ng-container>\n } @else {\n @let noneLabel = '(no data)' | translate;\n\n <p class=\"text-label-small\">{{ 'Tool input' | translate }}</p>\n <pre class=\"fit-w small\">{{ _tool.input || noneLabel | json }}</pre>\n\n @if (_tool.type === 'tool-result') {\n <p class=\"text-label-small\">{{ 'Tool output' | translate }}</p>\n @if (\n typeof _tool.output === 'string' || _tool.output === undefined || _tool.output === null\n ) {\n <pre class=\"fit-w small\">{{ _tool.output || noneLabel }}</pre>\n } @else {\n <pre class=\"fit-w small\">{{ _tool.output | json }}</pre>\n }\n }\n }\n </div>\n }\n</fieldset>\n", dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"], exportAs: ["ngComponentOutlet"] }, { kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "ngmodule", type: CollapseModule }, { kind: "directive", type: i1.CollapseDirective, selector: "[collapse]", inputs: ["display", "isAnimated", "collapse"], outputs: ["collapsed", "collapses", "expanded", "expands"], exportAs: ["bs-collapse"] }, { kind: "ngmodule", type: C8yTranslateModule }, { kind: "pipe", type: JsonPipe, name: "json" }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: AiChatToolCallComponent, decorators: [{ type: Component, args: [{ selector: 'c8y-ai-chat-tool-call', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ NgComponentOutlet, JsonPipe, C8yTranslatePipe, IconDirective, CollapseModule, C8yTranslateModule ], template: "@let _tool = tool();\n@let showDetails = showDefaultToolDetails() || toolDetailsComponent() || _tool.error;\n@let isToolExpanded = expanded();\n\n<fieldset\n class=\"c8y-fieldset p-b-4 ai-tool-call__fieldset\"\n [attr.aria-label]=\"'Tool call: ' + _tool.toolName\"\n>\n <button\n class=\"btn-clean ai-tool-call__btn\"\n [attr.aria-expanded]=\"showDetails ? isToolExpanded : null\"\n [attr.aria-controls]=\"showDetails ? 'tool-call-' + _tool.toolCallId : null\"\n type=\"button\"\n [attr.data-cy]=\"'tool-call-' + _tool.toolName\"\n [disabled]=\"!showDetails || null\"\n (click)=\"toggleExpanded()\"\n >\n @if (isExecuting()) {\n <i\n class=\"icon-spin icon-14 text-primary m-r-4\"\n c8yIcon=\"spinner\"\n aria-hidden=\"true\"\n ></i>\n } @else {\n <!-- Treat it as an error if a tool still thinks its executing when the message containing it is not -->\n @if (_tool.error || isExecuting()) {\n <i\n class=\"icon-14 text-danger m-r-4\"\n [c8yIcon]=\"'exclamation-circle'\"\n aria-hidden=\"true\"\n ></i>\n } @else {\n <i\n class=\"icon-14 text-success m-r-4\"\n [c8yIcon]=\"'check'\"\n aria-hidden=\"true\"\n ></i>\n }\n }\n <span class=\"small\">{{ getToolLabel(_tool) | translate }}</span>\n\n @if (showDetails) {\n <i\n class=\"m-l-4 icon-12\"\n [c8yIcon]=\"isToolExpanded ? 'collapse-arrow' : 'expand-arrow'\"\n aria-hidden=\"true\"\n ></i>\n }\n </button>\n\n <!-- If this is an artifact tool, render it - but do so lazily only on first open,\n since these components could be heavyweight (e.g. Monaco)\n -->\n @if (showDetails && (isToolExpanded || everExpanded())) {\n <div\n class=\"collapse tool-details m-t-8 p-8 b-r-4\"\n [attr.aria-label]=\"'Tool details for message latest tool: ' + _tool.toolName\"\n role=\"region\"\n [collapse]=\"!isToolExpanded\"\n [id]=\"'tool-call-' + _tool.toolCallId\"\n [isAnimated]=\"true\"\n data-cy=\"ai-tool-component\"\n >\n @if (toolDetailsComponent()) {\n <ng-container\n [ngComponentOutlet]=\"toolDetailsComponent()\"\n [ngComponentOutletInputs]=\"{ tool: _tool }\"\n ></ng-container>\n } @else {\n @let noneLabel = '(no data)' | translate;\n\n <p class=\"text-label-small\">{{ 'Tool input' | translate }}</p>\n <pre class=\"fit-w small\">{{ _tool.input || noneLabel | json }}</pre>\n\n @if (_tool.type === 'tool-result') {\n <p class=\"text-label-small\">{{ 'Tool output' | translate }}</p>\n @if (\n typeof _tool.output === 'string' || _tool.output === undefined || _tool.output === null\n ) {\n <pre class=\"fit-w small\">{{ _tool.output || noneLabel }}</pre>\n } @else {\n <pre class=\"fit-w small\">{{ _tool.output | json }}</pre>\n }\n }\n }\n </div>\n }\n</fieldset>\n" }] }], propDecorators: { tool: [{ type: i0.Input, args: [{ isSignal: true, alias: "tool", required: true }] }], isExecuting: [{ type: i0.Input, args: [{ isSignal: true, alias: "isExecuting", required: true }] }], toolDetailsComponent: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolDetailsComponent", required: false }] }], showDefaultToolDetails: [{ type: i0.Input, args: [{ isSignal: true, alias: "showDefaultToolDetails", required: false }] }], executingLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "executingLabel", required: false }] }], completedLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "completedLabel", required: false }] }], labelProvider: [{ type: i0.Input, args: [{ isSignal: true, alias: "labelProvider", required: false }] }] } }); /** * Converts a JSON string to a markdown code block. */ class JsonToMarkdownPipe { transform(value) { if (!value) { return '```json\nnull\n```'; } const escapedValue = value.replace(/\\/g, '\\\\').replace(/`/g, '\\`'); return `\`\`\`json\n${escapedValue}\n\`\`\``; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: JsonToMarkdownPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "20.3.25", ngImport: i0, type: JsonToMarkdownPipe, isStandalone: true, name: "jsonToMarkdown" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: JsonToMarkdownPipe, decorators: [{ type: Pipe, args: [{ name: 'jsonToMarkdown', standalone: true }] }] }); /** This renders a part of an assistant message. Currently only ToolCallPart is supported, but later we can expand this to deal with all part types. */ class AiChatAssistantPartComponent { constructor() { this.part = input.required(...(ngDevMode ? [{ debugName: "part" }] : [])); /** Whether this part is rendered as part of the main answer, or is an intermediate step with more muted rendering. */ this.displayAsPartOfMainAnswer = input(true, ...(ngDevMode ? [{ debugName: "displayAsPartOfMainAnswer" }] : [])); /** * The context needed to render a message. * This is a single input so we can extend in future without breaking people who have a custom rendering implementation. */ this.assistantMessageContext = input.required(...(ngDevMode ? [{ debugName: "assistantMessageContext" }] : [])); /** Tracks whether this is currently expanded (used for reasoning section). */ this.expanded = signal(false, ...(ngDevMode ? [{ debugName: "expanded" }] : [])); } toggleExpanded() { this.expanded.update(v => !v); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: AiChatAssistantPartComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: AiChatAssistantPartComponent, isStandalone: true, selector: "c8y-ai-chat-assistant-part", inputs: { part: { classPropertyName: "part", publicName: "part", isSignal: true, isRequired: true, transformFunction: null }, displayAsPartOfMainAnswer: { classPropertyName: "displayAsPartOfMainAnswer", publicName: "displayAsPartOfMainAnswer", isSignal: true, isRequired: false, transformFunction: null }, assistantMessageContext: { classPropertyName: "assistantMessageContext", publicName: "assistantMessageContext", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: "@let ctx = assistantMessageContext();\n@let _config = ctx.config;\n@let toolCallConfig = _config.toolCallConfig || {};\n@let _part = part();\n\n@if (_part.type === 'text') {\n <p\n [class.text-muted]=\"!displayAsPartOfMainAnswer()\"\n [innerHTML]=\"_part.text | markdownToHtml | async\"\n ></p>\n} @else if (_part.type === 'reasoning') {\n @let reasoningExpanded = expanded();\n\n <fieldset\n class=\"c8y-fieldset p-b-4 ai-tool-call__fieldset\"\n data-cy=\"ai-reasoning\"\n >\n <button\n class=\"btn-clean ai-tool-call__btn\"\n aria-label=\"{{ 'Toggle display of the reasoning section' | translate }}\"\n [attr.aria-expanded]=\"reasoningExpanded\"\n [attr.aria-controls]=\"'reasoning-content-message' + ctx.messageDisplayIndex\"\n type=\"button\"\n (click)=\"toggleExpanded()\"\n >\n <i\n class=\"m-r-4\"\n [c8yIcon]=\"'ai-sparkles'\"\n ></i>\n @let hideReasoning = 'Hide reasoning' | translate;\n @let showReasoning = 'Show reasoning' | translate;\n <span class=\"small\">{{ reasoningExpanded ? hideReasoning : showReasoning }}</span>\n <i\n class=\"m-l-4 icon-12 text-muted\"\n [c8yIcon]=\"reasoningExpanded ? 'collapse-arrow' : 'expand-arrow'\"\n ></i>\n </button>\n\n @let ariaLabel =\n 'Reasoning for message number {{ number }}' | translate: { number: ctx.messageDisplayIndex };\n <div\n class=\"collapse reasoning-content\"\n [attr.aria-label]=\"ariaLabel\"\n role=\"region\"\n [collapse]=\"!reasoningExpanded\"\n [id]=\"'reasoning-content-message' + ctx.messageDisplayIndex\"\n [isAnimated]=\"true\"\n data-cy=\"ai-reasoning-content\"\n >\n <div\n class=\"m-t-8 p-b-4 text-muted\"\n [innerHTML]=\"_part.text | markdownToHtml | async\"\n ></div>\n </div>\n </fieldset>\n} @else if (_part.type === 'object') {\n <p\n [class.text-muted]=\"!displayAsPartOfMainAnswer()\"\n [innerHTML]=\"_part.jsonContent | jsonToMarkdown | markdownToHtml | async\"\n data-cy=\"ai-object-content\"\n ></p>\n} @else if (_part.type === 'step-start') {\n <!-- Visually hidden label included when users copy-paste from the chat, e.g. to report issues -->\n <span\n class=\"ai-step-start hidden-copy-label\"\n aria-hidden=\"true\"\n data-cy=\"ai-step-start-separator\"\n translate\n >--- (step separator) ---</span\n >\n} @else if (_part.type.startsWith('tool')) {\n @let tool = $any(_part);\n @let toolDisplay = toolCallConfig[tool.toolName];\n\n <!-- Visually hidden label included when users copy-paste from the chat, e.g. to report issues -->\n <span\n class=\"hidden-copy-label\"\n aria-hidden=\"true\"\n translate\n [translateParams]=\"{ toolName: tool.toolName }\"\n ngNonBindable\n >--- Tool call: {{ toolName }} ---</span\n >\n\n @if (toolDisplay?.component) {\n <div data-cy=\"ai-tool-with-custom-component\">\n <ng-container\n [ngComponentOutlet]=\"toolDisplay.component\"\n [ngComponentOutletInputs]=\"{\n tool: tool,\n ctx: ctx\n }\"\n ></ng-container>\n </div>\n } @else if (!toolDisplay?.isHidden) {\n <!-- Don't allow expanding while tool call is in progress - it's unlikely to have anything worth rendering,\n and complicates implementation for the client to have to check for the isExecuting case.\n -->\n <c8y-ai-chat-tool-call\n [tool]=\"tool\"\n [isExecuting]=\"tool.type !== 'tool-result' && ctx.isMessageLoading\"\n [toolDetailsComponent]=\"_config.toolDetailsComponent?.(tool)\"\n [showDefaultToolDetails]=\"_config.showDefaultToolDetails === 'all'\"\n [executingLabel]=\"toolDisplay?.executingLabel\"\n [completedLabel]=\"toolDisplay?.completedLabel\"\n [labelProvider]=\"toolDisplay?.labelProvider\"\n ></c8y-ai-chat-tool-call>\n }\n}\n", dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"], exportAs: ["ngComponentOutlet"] }, { kind: "directive", type: C8yTranslateDirective, selector: "[translate],[ngx-translate]" }, { kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "ngmodule", type: CollapseModule }, { kind: "directive", type: i1.CollapseDirective, selector: "[collapse]", inputs: ["display", "isAnimated", "collapse"], outputs: ["collapsed", "collapses", "expanded", "expands"], exportAs: ["bs-collapse"] }, { kind: "component", type: AiChatToolCallComponent, selector: "c8y-ai-chat-tool-call", inputs: ["tool", "isExecuting", "toolDetailsComponent", "showDefaultToolDetails", "executingLabel", "completedLabel", "labelProvider"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }, { kind: "pipe", type: MarkdownToHtmlPipe, name: "markdownToHtml" }, { kind: "pipe", type: AsyncPipe, name: "async" }, { kind: "pipe", type: JsonToMarkdownPipe, name: "jsonToMarkdown" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: AiChatAssistantPartComponent, decorators: [{ type: Component, args: [{ selector: 'c8y-ai-chat-assistant-part', standalone: true, imports: [ NgComponentOutlet, C8yTranslateDirective, C8yTranslatePipe, IconDirective, CollapseModule, MarkdownToHtmlPipe, AsyncPipe, AiChatToolCallComponent, JsonToMarkdownPipe ], changeDetection: ChangeDetectionStrategy.OnPush, template: "@let ctx = assistantMessageContext();\n@let _config = ctx.config;\n@let toolCallConfig = _config.toolCallConfig || {};\n@let _part = part();\n\n@if (_part.type === 'text') {\n <p\n [class.text-muted]=\"!displayAsPartOfMainAnswer()\"\n [innerHTML]=\"_part.text | markdownToHtml | async\"\n ></p>\n} @else if (_part.type === 'reasoning') {\n @let reasoningExpanded = expanded();\n\n <fieldset\n class=\"c8y-fieldset p-b-4 ai-tool-call__fieldset\"\n data-cy=\"ai-reasoning\"\n >\n <button\n class=\"btn-clean ai-tool-call__btn\"\n aria-label=\"{{ 'Toggle display of the reasoning section' | translate }}\"\n [attr.aria-expanded]=\"reasoningExpanded\"\n [attr.aria-controls]=\"'reasoning-content-message' + ctx.messageDisplayIndex\"\n type=\"button\"\n (click)=\"toggleExpanded()\"\n >\n <i\n class=\"m-r-4\"\n [c8yIcon]=\"'ai-sparkles'\"\n ></i>\n @let hideReasoning = 'Hide reasoning' | translate;\n @let showReasoning = 'Show reasoning' | translate;\n <span class=\"small\">{{ reasoningExpanded ? hideReasoning : showReasoning }}</span>\n <i\n class=\"m-l-4 icon-12 text-muted\"\n [c8yIcon]=\"reasoningExpanded ? 'collapse-arrow' : 'expand-arrow'\"\n ></i>\n </button>\n\n @let ariaLabel =\n 'Reasoning for message number {{ number }}' | translate: { number: ctx.messageDisplayIndex };\n <div\n class=\"collapse reasoning-content\"\n [attr.aria-label]=\"ariaLabel\"\n role=\"region\"\n [collapse]=\"!reasoningExpanded\"\n [id]=\"'reasoning-content-message' + ctx.messageDisplayIndex\"\n [isAnimated]=\"true\"\n data-cy=\"ai-reasoning-content\"\n >\n <div\n class=\"m-t-8 p-b-4 text-muted\"\n [innerHTML]=\"_part.text | markdownToHtml | async\"\n ></div>\n </div>\n </fieldset>\n} @else if (_part.type === 'object') {\n <p\n [class.text-muted]=\"!displayAsPartOfMainAnswer()\"\n [innerHTML]=\"_part.jsonContent | jsonToMarkdown | markdownToHtml | async\"\n data-cy=\"ai-object-content\"\n ></p>\n} @else if (_part.type === 'step-start') {\n <!-- Visually hidden label included when users copy-paste from the chat, e.g. to report issues -->\n <span\n class=\"ai-step-start hidden-copy-label\"\n aria-hidden=\"true\"\n data-cy=\"ai-step-start-separator\"\n translate\n >--- (step separator) ---</span\n >\n} @else if (_part.type.startsWith('tool')) {\n @let tool = $any(_part);\n @let toolDisplay = toolCallConfig[tool.toolName];\n\n <!-- Visually hidden label included when users copy-paste from the chat, e.g. to report issues -->\n <span\n class=\"hidden-copy-label\"\n aria-hidden=\"true\"\n translate\n [translateParams]=\"{ toolName: tool.toolName }\"\n ngNonBindable\n >--- Tool call: {{ toolName }} ---</span\n >\n\n @if (toolDisplay?.component) {\n <div data-cy=\"ai-tool-with-custom-component\">\n <ng-container\n [ngComponentOutlet]=\"toolDisplay.component\"\n [ngComponentOutletInputs]=\"{\n tool: tool,\n ctx: ctx\n }\"\n ></ng-container>\n </div>\n } @else if (!toolDisplay?.isHidden) {\n <!-- Don't allow expanding while tool call is in progress - it's unlikely to have anything worth rendering,\n and complicates implementation for the client to have to check for the isExecuting case.\n -->\n <c8y-ai-chat-tool-call\n [tool]=\"tool\"\n [isExecuting]=\"tool.type !== 'tool-result' && ctx.isMessageLoading\"\n [toolDetailsComponent]=\"_config.toolDetailsComponent?.(tool)\"\n [showDefaultToolDetails]=\"_config.showDefaultToolDetails === 'all'\"\n [executingLabel]=\"toolDisplay?.executingLabel\"\n [completedLabel]=\"toolDisplay?.completedLabel\"\n [labelProvider]=\"toolDisplay?.labelProvider\"\n ></c8y-ai-chat-tool-call>\n }\n}\n" }] }], propDecorators: { part: [{ type: i0.Input, args: [{ isSignal: true, alias: "part", required: true }] }], displayAsPartOfMainAnswer: [{ type: i0.Input, args: [{ isSignal: true, alias: "displayAsPartOfMainAnswer", required: false }] }], assistantMessageContext: [{ type: i0.Input, args: [{ isSignal: true, alias: "assistantMessageContext", required: true }] }] } }); /** * This is the default component used to render the contents of a message from the AI assistant, including * the main answer text, text from earlier steps, reasoning text and tool calls. */ class AiChatAssistantMessageComponent { constructor() { /** * The context needed to render a message. * This is a single input so we can extend in future without breaking people who have a custom rendering implementation. */ this.assistantMessageContext = input.required(...(ngDevMode ? [{ debugName: "assistantMessageContext" }] : [])); /** * By default this component will render a "Working..." indicator while streaming results. This input can turn that off if required. */ this.showWorkingIndicator = input(true, ...(ngDevMode ? [{ debugName: "showWorkingIndicator" }] : [])); /** * Whether the thinking section is expanded. Initialized to false unless the message is still loading. * Call setThinkingExpanded() via viewChild to control this programmatically. Does nothing if appearance does not distinguish thinking steps. */ this.thinkingExpanded = signal(false, ...(ngDevMode ? [{ debugName: "thinkingExpanded" }] : [])); /** * Tracks which reasoning sections are expanded, keyed by step index. */ this.expandedReasoningStepIndices = signal(new Set(), ...(ngDevMode ? [{ debugName: "expandedReasoningStepIndices" }] : [])); this.translateService = inject(TranslateService); this.thinkingAndMainAnswerParts = computed(() => this.splitContentIntoThinkingAndMainAnswer(this.assistantMessageContext()), ...(ngDevMode ? [{ debugName: "thinkingAndMainAnswerParts" }] : [])); // Expand when we initially set the message loading flag, without overwriting the value // any other time effect(() => { if (this.assistantMessageContext().isMessageLoading) { this.thinkingExpanded.set(true); } }); } /** * This is public and exists for agent-chat to collapse older messages. * @param expanded Whether the thinking section should be expanded or not. */ setThinkingExpanded(expanded) { this.thinkingExpanded.set(expanded); } /** * Get a translated/translateable label for the specified tool. * @param tool The tool call part to get the label for. * @return The label to show for this tool call. */ getToolLabel(tool) { const isExecuting = tool.type !== 'tool-result'; const config = this.assistantMessageContext().config; const toolCallConfig = config?.toolCallConfig?.[tool.toolName]; if (toolCallConfig?.labelProvider) { try { const dynamicLabel = toolCallConfig.labelProvider(tool, this.translateService); // Run it through translate in case it contains gettext values if (dynamicLabel) return dynamicLabel; } catch (err) { // Should never happen, so report this - then fallback to default logic console.warn(`Error in labelProvider for tool ${tool.toolName}:`, err); } } if (isExecuting && toolCallConfig?.executingLabel) { return toolCallConfig.executingLabel; } if (!isExecuting && toolCallConfig?.completedLabel) { return toolCallConfig.completedLabel; } // If we have no specific label configured, use the default return this.translateService.instant(isExecuting ? gettext('Calling tool {{toolName}}') : gettext('Used tool {{toolName}}'), // Have to use the tool id since that's all we have { toolName: tool.toolName }); } toggleReasoningExpanded(stepIndex) { this.expandedReasoningStepIndices.update(s => { const next = new Set(s); s.has(stepIndex) ? next.delete(stepIndex) : next.add(stepIndex); return next; }); } /** * Note: ctx is passed as a parameter rather than reading the signal in the template to ensure * it's totally aligned with what the template is rendering. * @param ctx The context for the assistant message. * @return The label to display for the reasoning section. */ getThinkingLabel(ctx) { return ctx.isMessageLoading ? gettext('Reasoning') : this.thinkingExpanded() ? gettext('Hide reasoning') : gettext('Show reasoning'); } getToolName(part) { return part.type === 'tool-input-streaming' || part.type === 'tool-executing' || part.type === 'tool-result' ? part.toolName : undefined; } experimental_nonFinalStepTextDisplay(ctx) { // For now be conservative with the old behaviour, but hope to change this once we've got working really smoothly. return ctx.config?.experimental_nonFinalStepTextDisplay || 'main-answer'; } /** * Splits the assistant message content into two arrays: thinking content and main answer content. * This also removes hidden tools. * * If using the experimental rendering modes, main answer content is text in the final step (if conditions are met) plus any tool calls configured to be shown with the main answer. * Thinking content is everything else (reasoning, earlier text, tool calls not shown with main answer). * * @param ctx The context for the assistant message. * @return An object with thinkingParts and mainAnswerParts arrays. */ splitContentIntoThinkingAndMainAnswer(ctx) { // Remove hidden tools let content = ctx.message?.content || []; if (Object.values(ctx.config?.toolCallConfig || {}).some(c => c?.isHidden)) { content = (ctx.message?.content || []).filter(part => !ctx.config?.toolCallConfig?.[this.getToolName(part) || '']?.isHidden); } // Putting this check here keeps things safe by ensuring there is no chance of any rendering differences // during streaming unless in one of the experimental modes. This means the rest of this method only applies // to the experimental rendering. if (this.experimental_nonFinalStepTextDisplay(ctx) === 'main-answer') { return { thinkingParts: [], mainAnswerParts: content }; } const { finalStepStart, finalStepHasText } = this.findFinalStepWithText(content); const hasToolCalls = content.some(part => part.type === 'tool-input-streaming' || part.type === 'tool-executing' || part.type === 'tool-result'); // Since we don't bother with a step-start at index 0 for the first step, add one const stepCount = content.filter((part, index) => index > 0 && part.type === 'step-start').length + 1; // The final step text goes in the main answer area if there are no pending tool calls // (since otherwise the tools would show below it), and // message has finished loading, or we're expecting more steps than this. // Note that we check all steps for toolCalls not just the current one, for the sake of "fake" tool calls created by preprocessAgentMessage // where additional step boundaries may be added to simplify parsing. const shouldShowLastTextAsMainAnswer = finalStepHasText && finalStepStart !== -1 && !hasToolCalls && (!ctx.isMessageLoading || stepCount >= (ctx.config?.experimental_expectedStepCount || 2)); if (!shouldShowLastTextAsMainAnswer) { return { thinkingParts: content, mainAnswerParts: [] }; } const thinkingParts = []; const mainAnswerParts = []; // Split the content: final step and promoted tool calls go to main answer, everything else to thinking content.forEach((part, index) => { const isPromotedToolCall = ctx.config?.toolCallConfig?.[this.getToolName(part) || '']?.isShownWithMainAnswer; // This ensures promoted tool calls never go to the collapseable thinking area if (index >= finalStepStart || isPromotedToolCall) { mainAnswerParts.push(part); } else { thinkingParts.push(part); } }); return { thinkingParts, mainAnswerParts }; } /** * Scans backwards through the content parts to find the step-start index for the last step * that contains at least one text part. Skips trailing empty steps (step-start with no text after). * * @returns `finalStepStart` — index of the step-start part, or 0 if content belongs to the implicit first step. * `finalStepHasText` — whether a step with text was found at all. */ findFinalStepWithText(content) { let finalStepStart = 0; let finalStepHasText = false; for (let i = content.length - 1; i >= 0; i--) { if (content[i].type === 'text') { finalStepHasText = true; } if (content[i].type === 'step-start') { if (finalStepHasText) { finalStepStart = i; break; } else if (i === content.length - 1) { // Trailing empty step-start — keep looking continue; } else { // step-start found but no text in that step finalStepStart = -1; break; } } } return { finalStepStart, finalStepHasText }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: AiChatAssistantMessageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: AiChatAssistantMessageComponent, isStandalone: true, selector: "c8y-ai-chat-assistant-message", inputs: { assistantMessageContext: { classPropertyName: "assistantMessageContext", publicName: "assistantMessageContext", isSignal: true, isRequired: true, transformFunction: null }, showWorkingIndicator: { classPropertyName: "showWorkingIndicator", publicName: "showWorkingIndicator", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "@let ctx = assistantMessageContext();\n@let parts = ctx.message.content;\n@let _config = ctx.config;\n\n<!-- \nWe show the assistant message in (up to) 3 slots:\n- collapsible thinking block (for intermediate step text, standard tool calls, etc)\n- special \"artifact\" tools that update user artifacts and need prominent display (e.g. for code edits made by the AI)\n- the main answer (typically from the final step)\n\nThis is a standard approach for AI chats - step text and tools from an LLM are treated as a work log, i.e. useful to \ndisplay progress while streaming the response, but only the text from the last step after all tool calls gives the main answer of the AI to the user. \n\nDepending on configuration, we have an experimental option (if we need it) to style the thinking block the same as the main answer. \n\nOnce the message finishes loading we know the final step IS the main content, but if it's still steaming \nwe don't know for sure which step has the final/main answer until the LLM has actually finished so have to take a heuistic guess \n(and minimize jumping in the UI). \n\nFor applications where we expect tool calls in most responses, assume the 2nd step is probably the final text; \nfor applications where tool calls are less ubiquitous, assume the first step is main answer until proved otherwise. \nThis minimizes jumping (except when there are >2 steps, which is rare).\n\n-->\n\n@let contentSplit = thinkingAndMainAnswerParts();\n@let nonFinalStepTextDisplay = experimental_nonFinalStepTextDisplay(ctx);\n\n<!-- Thinking steps section - holds reasoning, text from non-final steps, and standard/non-artifact tool calls -->\n\n@if (contentSplit.thinkingParts.length > 0) {\n <div\n class=\"m-b-8 text-muted\"\n [class.thinking-block]=\"nonFinalStepTextDisplay === 'collapsible-thinking-block'\"\n [class.small]=\"nonFinalStepTextDisplay === 'muted-main-answer'\"\n [class.thinking-steps-appearance]=\"nonFinalStepTextDisplay\"\n data-cy=\"thinking-steps\"\n >\n @if (nonFinalStepTextDisplay === 'collapsible-thinking-block') {\n <button\n class=\"btn btn-clean btn-xs text-muted p-l-0\"\n aria-label=\"{{ 'Toggle collapse of the reasoning section' | translate }}\"\n [attr.aria-expanded]=\"thinkingExpanded()\"\n [attr.aria-controls]=\"'thinking-content-' + ctx.messageDisplayIndex\"\n type=\"button\"\n (click)=\"thinkingExpanded.set(!thinkingExpanded())\"\n >\n <span class=\"small\">{{ getThinkingLabel(ctx) }}</span>\n <i\n class=\"m-l-4 icon-12\"\n [c8yIcon]=\"thinkingExpanded() ? 'collapse-arrow' : 'expand-arrow'\"\n ></i>\n </button>\n }\n\n @let ariaLabel =\n 'Reasoning for message number {{ number }}' | translate: { number: ctx.messageDisplayIndex };\n <div\n class=\"collapse\"\n [attr.aria-label]=\"ariaLabel\"\n role=\"region\"\n [collapse]=\"nonFinalStepTextDisplay === 'collapsible-thinking-block' && !thinkingExpanded()\"\n [id]=\"'thinking-content-' + ctx.messageDisplayIndex\"\n [isAnimated]=\"true\"\n >\n <div\n [class]=\"\n nonFinalStepTextDisplay === 'collapsible-thinking-block'\n ? 'thinking-content bg-level-1 m-t-8 p-8 b-r-4 border-left-accent small m-b-0 text-muted'\n : ''\n \"\n >\n @for (part of contentSplit.thinkingParts; track $index) {\n @if (part.type !== 'step-start' || $index > 0) {\n <c8y-ai-chat-assistant-part\n [part]=\"part\"\n [assistantMessageContext]=\"ctx\"\n [displayAsPartOfMainAnswer]=\"nonFinalStepTextDisplay !== 'muted-main-answer'\"\n ></c8y-ai-chat-assistant-part>\n }\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Main answer area contains the text from the final step, and \n any promoted tool calls which are too important to be hidden in the collapeable thinking area -->\n<div\n class=\"message-content\"\n data-cy=\"ai-main-answer-content\"\n>\n @for (part of contentSplit.mainAnswerParts; track $index) {\n <c8y-ai-chat-assistant-part\n [part]=\"part\"\n [assistantMessageContext]=\"ctx\"\n [displayAsPartOfMainAnswer]=\"true\"\n ></c8y-ai-chat-assistant-part>\n }\n</div>\n\n@if (ctx.isMessageLoading && showWorkingIndicator()) {\n <!-- Once the message starts to stream, show a small/low-key thinking indicator -->\n <div\n class=\"text-muted text-12 fade-in-out d-flex j-c-end\"\n aria-live=\"polite\"\n role=\"status\"\n data-cy=\"working-indicator\"\n >\n {{ 'Working\u2026' | translate }}\n </div>\n}\n", dependencies: [{ kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "ngmodule", type: CollapseModule }, { kind: "directive", type: i1.CollapseDirective, selector: "[collapse]", inputs: ["display", "isAnimated", "collapse"], outputs: ["collapsed", "collapses", "expanded", "expands"], exportAs: ["bs-collapse"] }, { kind: "ngmodule", type: C8yTranslateModule }, { kind: "component", type: AiChatAssistantPartComponent, selector: "c8y-ai-chat-assistant-part", inputs: ["part", "displayAsPartOfMainAnswer", "assistantMessageContext"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: AiChatAssistantMessageComponent, decorators: [{ type: Component, args: [{ selector: 'c8y-ai-chat-assistant-message', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ C8yTranslatePipe, IconDirective, CollapseModule, C8yTranslateModule, AiChatAssistantPartComponent ], template: "@let ctx = assistantMessageContext();\n@let parts = ctx.message.content;\n@let _config = ctx.config;\n\n<!-- \nWe show the assistant message in (up to) 3 slots:\n- collapsible thinking block (for intermediate step text, standard tool calls, etc)\n- special \"artifact\" tools that update user artifacts and need prominent display (e.g. for code edits made by the AI)\n- the main answer (typically from the final step)\n\nThis is a standard approach for AI chats - step text and tools from an LLM are treated as a work log, i.e. useful to \ndisplay progress while streaming the response, but only the text from the last step after all tool calls gives the main answer of the AI to the user. \n\nDepending on configuration, we have an experimental option (if we need it) to style the thinking block the same as the main answer. \n\nOnce the message finishes loading we know the final step IS the main content, but if it's still steaming \nwe don't know for sure which step has the final/main answer until the LLM has actually finished so have to take a heuistic guess \n(and minimize jumping in the UI). \n\nFor applications where we expect tool calls in most responses, assume the 2nd step is probably the final text; \nfor applications where tool calls are less ubiquitous, assume the first step is main answer until proved otherwise. \nThis minimizes jumping (except when there are >2 steps, which is rare).\n\n-->\n\n@let contentSplit = thinkingAndMainAnswerParts();\n@let nonFinalStepTextDisplay = experimental_nonFinalStepTextDisplay(ctx);\n\n<!-- Thinking steps section - holds reasoning, text from non-final steps, and standard/non-artifact tool calls -->\n\n@if (contentSplit.thinkingParts.length > 0) {\n <div\n class=\"m-b-8 text-muted\"\n [class.thinking-block]=\"nonFinalStepTextDisplay === 'collapsible-thinking-block'\"\n [class.small]=\"nonFinalStepTextDisplay === 'muted-main-answer'\"\n [class.thinking-steps-appearance]=\"nonFinalStepTextDisplay\"\n data-cy=\"thinking-steps\"\n >\n @if (nonFinalStepTextDisplay === 'collapsible-thinking-block') {\n <button\n class=\"btn btn-clean btn-xs text-muted p-l-0\"\n aria-label=\"{{ 'Toggle collapse of the reasoning section' | translate }}\"\n [attr.aria-expanded]=\"thinkingExpanded()\"\n [attr.aria-controls]=\"'thinking-content-' + ctx.messageDisplayIndex\"\n type=\"button\"\n (click)=\"thinkingExpanded.set(!thinkingExpanded())\"\n >\n <span class=\"small\">{{ getThinkingLabel(ctx) }}</span>\n <i\n class=\"m-l-4 icon-12\"\n [c8yIcon]=\"thinkingExpanded() ? 'collapse-arrow' : 'expand-arrow'\"\n ></i>\n </button>\n }\n\n @let ariaLabel =\n 'Reasoning for message number {{ number }}' | translate: { number: ctx.messageDisplayIndex };\n <div\n class=\"collapse\"\n [attr.aria-label]=\"ariaLabel\"\n role=\"region\"\n [collapse]=\"nonFinalStepTextDisplay === 'collapsible-thinking-block' && !thinkingExpanded()\"\n [id]=\"'thinking-content-' + ctx.messageDisplayIndex\"\n [isAnimated]=\"true\"\n >\n <div\n [class]=\"\n nonFinalStepTextDisplay === 'collapsible-thinking-block'\n ? 'thinking-content bg-level-1 m-t-8 p-8 b-r-4 border-left-accent small m-b-0 text-muted'\n : ''\n \"\n >\n @for (part of contentSplit.thinkingParts; track $index) {\n @if (part.type !== 'step-start' || $index > 0) {\n <c8y-ai-chat-assistant-part\n [part]=\"part\"\n [assistantMessageContext]=\"ctx\"\n [displayAsPartOfMainAnswer]=\"nonFinalStepTextDisplay !== 'muted-main-answer'\"\n ></c8y-ai-chat-assistant-part>\n }\n }\n </div>\n </div>\n </div>\n}\n\n<!-- Main answer area contains the text from the final step, and \n any promoted tool calls which are too important to be hidden in the collapeable thinking area -->\n<div\n class=\"message-content\"\n data-cy=\"ai-main-answer-content\"\n>\n @for (part of contentSplit.mainAnswerParts; track $index) {\n <c8y-ai-chat-assistant-part\n [part]=\"part\"\n [assistantMessageContext]=\"ctx\"\n [displayAsPartOfMainAnswer]=\"true\"\n ></c8y-ai-chat-assistant-part>\n }\n</div>\n\n@if (ctx.isMessageLoading && showWorkingIndicator()) {\n <!-- Once the message starts to stream, show a small/low-key thinking indicator -->\n <div\n class=\"text-muted text-12 fade-in-out d-flex j-c-end\"\n aria-live=\"polite\"\n role=\"status\"\n data-cy=\"working-indicator\"\n >\n {{ 'Working\u2026' | translate }}\n </div>\n}\n" }] }], ctorParameters: () => [], propDecorators: { assistantMessageContext: [{ type: i0.Input, args: [{ isSignal: true, alias: "assistantMessageContext", required: true }] }], showWorkingIndicator: [{ type: i0.Input, args: [{ isSignal: true, alias: "showWorkingIndicator", required: false }] }] } }); /** * An action button that can be added to chat messages. * Typically used for actions like copying, regenerating, or providing feedback on messages. */ class AiChatMessageActionComponent { constructor() { /** * Set to true to use content projection for custom action button content. */ this.custom = false; /** * Disables the action button when true. */ this.disabled = false; /** * Tooltip text displayed when hovering over the action button. */ this.tooltip = ''; /** * Icon to display in the action button. */ this.icon = 'cog'; /** * Emitted when the action button is clicked. */ this.actionClicked = new EventEmitter(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: AiChatMessageActionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: AiChatMessageActionComponent, isStandalone: true, selector: "c8y-ai-chat-message-action", inputs: { custom: "custom", disabled: "disabled", tooltip: "tooltip", icon: "icon" }, outputs: { actionClicked: "actionClicked" }, ngImport: i0, template: "@if (!custom) {\n <button\n class=\"btn btn-dot text-muted\"\n [attr.aria-label]=\"tooltip | translate\"\n [toolt