@c8y/ngx-components
Version:
Angular modules for Cumulocity IoT applications
892 lines (889 loc) • 87.3 kB
JavaScript
import { NgComponentOutlet, AsyncPipe } from '@angular/common';
import * as i0 from '@angular/core';
import { Injectable, inject, input, computed, output, signal, viewChildren, ChangeDetectionStrategy, Component, Injector, Input } from '@angular/core';
import { GainsightService, DatePipe, ModalService, AlertService, C8Y_PLUGIN_CONTEXT_PATH, AppStateService, Status, LoadingComponent, EmptyStateComponent, C8yTranslateDirective, GuideDocsComponent, GuideHrefDirective, MarkdownToHtmlPipe, C8yTranslatePipe, NumberPipe, ContextRouteService } from '@c8y/ngx-components';
import { TranslateService } from '@ngx-translate/core';
import { AIService, defaultPruneMessagesForAgent } from '@c8y/ngx-components/ai';
import { gettext } from '@c8y/ngx-components/gettext';
import { AiChatAssistantMessageComponent, AiChatComponent, AiChatSuggestionComponent, AiChatMessageComponent, AiChatMessageActionComponent } from '@c8y/ngx-components/ai/ai-chat';
import { CollapseModule } from 'ngx-bootstrap/collapse';
import * as i1 from 'ngx-bootstrap/tooltip';
import { TooltipModule } from 'ngx-bootstrap/tooltip';
import { WidgetConfigService, WidgetConfigFeedbackComponent } from '@c8y/ngx-components/context-dashboard';
import { of, isObservable } from 'rxjs';
/** A stateless service used by agent-chat for managing chat history. Not public. */
class ChatHistoryService {
/**
* Returns a message pruner function suitable for passing to `save()`, configured by the given options.
*
* The returned function:
* - Limits the total number of messages kept (if `config.maxMessages` is set)
* - Strips transient streaming parts (`tool-input-streaming`, `tool-executing`) from all assistant messages
* - Strips `reasoning` and `tool-result` parts from assistant messages older than the 10 most recent
* - Strips `tool-result` parts whose `toolName` is in `config.transientToolNames`
*
* @param config Optional configuration
*/
static createDefaultSerializationMessagePruner(config) {
return (messages) => {
let msgs = messages;
// Apply maxMessages limit - keep most recent messages
if (config?.maxMessages && msgs.length > config.maxMessages) {
msgs = msgs.slice(-config.maxMessages);
}
const transientToolNames = new Set(config?.transientToolNames ?? []);
// NB: in future we could use tool metadata from the MCP servers to identify transient tools automatically
// Only keep tool results and reasoning for the most recent N messages
const recentMessageStartIndex = msgs.length - Math.min(10, msgs.length);
return msgs.map((msg, msgIndex) => {
if (msg.role !== 'assistant') {
return msg;
}
const isRecentMessage = msgIndex >= recentMessageStartIndex;
const filteredContent = msg.content.filter((part) => {
// Always strip transient streaming states
if (part.type === 'tool-input-streaming' || part.type === 'tool-executing') {
return false;
}
if (!isRecentMessage) {
// For older messages, only keep text and step-start
return part.type === 'text' || part.type === 'step-start';
}
// For recent messages, additionally filter transient tool results
if (part.type === 'tool-result') {
return !transientToolNames.has(part.toolName);
}
return true;
});
return { ...msg, content: filteredContent };
});
};
}
/**
* Save messages and suggestions to an opaque, versioned, JSON-serializable snapshot for persistence.
*
* By default, uses `createDefaultSerializationMessagePruner()` to strip transient content and limit
* the size of the serialized history. Pass a custom `pruneMessages` function to override this behaviour,
* for example to configure `maxMessages` or `transientToolNames`, or pass `msgs => msgs` to disable pruning.
*
* @param messages The current conversation messages to save
* @param suggestions The current suggestions to include in the snapshot
* @param pruneMessages Optional function to prune/transform messages before serialization. If not specified, createDefaultSerializationMessagePruner is used.
*/
save(messages, suggestions, pruneMessages) {
const prune = pruneMessages ?? ChatHistoryService.createDefaultSerializationMessagePruner();
const msgs = prune(messages);
const history = {
messages: msgs.map(msg => {
if (msg.role === 'assistant') {
return {
role: msg.role,
// Note: AIMessagePart[] cannot be statically checked as JSON-safe because
// ToolCallPart.output is typed as `unknown`. Serializability is enforced at
// the boundary by the outer `as ChatHistory` (= JsonValue) cast.
content: msg.content,
...(msg.timestamp ? { timestamp: msg.timestamp } : {})
};
}
return {
role: msg.role,
content: msg.content,
...('timestamp' in msg && msg.timestamp ? { timestamp: msg.timestamp } : {})
};
}),
suggestions,
chatHistoryVersion: 2
};
return history;
}
/**
* Restore a previously saved chat history snapshot.
*
* Validates the snapshot format and returns the messages and suggestions.
* Throws an error if the snapshot is invalid or the version is not supported.
* For a short time, version 1 snapshots are automatically migrated to the current format.
*
* @param history A snapshot previously returned by `save()`
*/
restore(history) {
// Validate history structure and messages
if (typeof history !== 'object' ||
history === null ||
!('messages' in history) ||
!Array.isArray(history['messages'])) {
throw new Error('Invalid chat history format');
}
const version = history['chatHistoryVersion'];
// TODO: remove v1 migration after a month or so once stored histories have been migrated
if (version === 1) {
return this.restoreV1(history);
}
if (version !== 2) {
throw new Error(`Cannot load chat history - expected version 2 but got ${version}`);
}
const messagesArray = history['messages'];
if (messagesArray.some(msg => {
if (!msg || typeof msg !== 'object' || !msg.role)
return true;
if (msg.role === 'assistant')
return !Array.isArray(msg.content);
return typeof msg.content !== 'string';
})) {
throw new Error('Invalid message format in chat history');
}
const suggestions = Array.isArray(history['suggestions'])
? history['suggestions']
: [];
return {
messages: messagesArray,
suggestions
};
}
restoreV1(history) {
const messagesArray = history['messages'];
if (messagesArray.some((msg) => !msg || typeof msg !== 'object' || !msg.role || typeof msg.content !== 'string')) {
throw new Error('Invalid message format in chat history');
}
const messages = messagesArray.map((msg) => {
if (msg.role !== 'assistant') {
return { role: msg.role, content: msg.content };
}
const parts = [];
const steps = msg.steps ?? [];
if (steps.length === 0) {
// No steps saved — fall back to the v1 string content
if (msg.content) {
parts.push({ type: 'text', text: msg.content });
}
}
else {
steps.forEach((step, i) => {
// step-start is a boundary between steps, never before the first
if (i > 0) {
parts.push({ type: 'step-start' });
}
if (step.reasoning) {
parts.push({ type: 'reasoning', text: step.reasoning });
}
if (step.text) {
parts.push({ type: 'text', text: step.text });
}
for (const toolResult of step.toolResults ?? []) {
parts.push(toolResult);
}
});
}
return {
role: 'assistant',
content: parts,
...(msg.timestamp ? { timestamp: msg.timestamp } : {})
};
});
const suggestions = Array.isArray(history['suggestions'])
? history['suggestions']
: [];
return { messages, suggestions };
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: ChatHistoryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: ChatHistoryService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: ChatHistoryService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}] });
/** A service used to send product experience data to GainSight PX. Not public. */
class UserAnalyticsService {
constructor() {
this.gainsightService = inject(GainsightService);
this.maxAnalyticsMessageLength = 4000;
/** If needed we could set this to false in production to only include hashes of the messages,
* if there's a decision that customer data shouldn't be uploaded to GainSight. */
this.includeCustomerSensitiveDataInAnalytics = true;
}
/** Create a dictionary of context from the agent-chat component that is useful to include in all GainSight messages */
async getAnalyticsMetadataContext(agentName, agentDefinition, userAnalyticsContext, model, systemPrompt) {
let systemPromptHash = undefined;
if (systemPrompt) {
systemPromptHash = await this._computeHash(systemPrompt.map(s => s.text).join('\n\n'));
}
else if (agentDefinition && agentDefinition.definition) {
// Fall back to getting it from the agent definition (if provided)
systemPromptHash = await this._computeHash(agentDefinition.definition.agent.system);
}
return {
// User-defined context first
...(userAnalyticsContext || {}),
model: model,
agent: agentName,
// Since we don't currently have any other version indicator available, a hash of the system prompt is useful to correlate feedback with the exact prompt used
systemPromptHash: systemPromptHash
};
}
/** Compute SHA-256 hash of a string for correlation purposes (assumes a secure context where crypto.subtle.digest is available) */
async _computeHash(message) {
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
/** Summarize message metrics for GainSight analytics (without including any proprietary data) */
async _summarizeMessagesForGainSight(userMessage, assistantMessage, detailed = false) {
// NB: Do NOT send any message content to GainSight servers because that's proprietary/personal customer-owned data that we must protect
// except when includeCustomerSensitiveDataInAnalytics is set;
// include hashes of messages so we can always correlate with the history stored on our own servers if available
const allTextParts = assistantMessage.content
.filter((p) => p.type === 'text')
.map(p => p.text);
const allText = allTextParts.join('\n---\n');
// If the full text is very long, prioritize the final step's text to keep analytics payloads under the limit
const lastStepStartIdx = assistantMessage.content.reduce((last, p, i) => (p.type === 'step-start' ? i : last), 0);
const finalStepText = assistantMessage.content
.slice(lastStepStartIdx + 1)
.filter((p) => p.type === 'text')
.map(p => p.text)
.join('\n---\n');
const allAssistantText = allText.length > this.maxAnalyticsMessageLength ? finalStepText : allText;
// Collect all unique tool names from tool-result parts
const toolResultParts = assistantMessage.content.filter((p) => p.type === 'tool-result');
const toolNames = toolResultParts
.map(tr => tr.toolName)
.filter((name, index, self) => self.indexOf(name) === index);
return {
assistantMessageTimestamp: assistantMessage.timestamp,
// Track how long we're spending waiting for the AI
assistantDurationSecs: assistantMessage.timestamp && userMessage.timestamp
? (new Date(assistantMessage.timestamp).getTime() -
new Date(userMessage.timestamp).getTime()) /
1000
: undefined,
assistantMessageHash: await this._computeHash(allAssistantText),
assistantMessage: this.includeCustomerSensitiveDataInAnalytics ? allAssistantText : undefined,
// although harder to read, it's useful to include the full message including tool calls and steps the feedback is due to bugs etc
assistantMessageJSON: this.includeCustomerSensitiveDataInAnalytics && detailed
? JSON.stringify(assistantMessage)
: undefined,
userMessageHash: userMessage ? await this._computeHash(userMessage.content) : '',
userMessage: this.includeCustomerSensitiveDataInAnalytics ? userMessage.content : undefined,
// nb: toolCalls here refers to completed calls (i.e. toolResults), we shouldn't have any calls still in progress at this point
assistantMessageSteps: assistantMessage.content.filter(p => p.type === 'step-start').length,
assistantMessageToolCallsCount: toolResultParts.length,
assistantMessageToolNames: toolNames.length === 0 ? undefined : toolNames.join(','),
assistantMessageLines: allAssistantText.split('\n').length,
userMessageLength: userMessage.content.length,
userMessageLines: userMessage.content.split('\n').length
};
}
async sendAssistantMessageComplete(assistantMessage, allMessages, analyticsMetadataContext) {
// Can't use lastIndexOf(assistantMessage) here (as we for for feedback) because allMessages may not yet have been updated with this assistantMessage item
const userMessage = [...allMessages].reverse().find(m => m.role === 'user');
if (!userMessage) {
console.warn('No user message found for assistant message, skipping analytics event');
return;
}
this.gainsightService.triggerEvent('ai.agent.message', {
messageCount: allMessages.length,
...(await this._summarizeMessagesForGainSight(userMessage, assistantMessage)),
...analyticsMetadataContext
});
}
/** Sent when there is a streaming error from the agent */
async sendAssistantMessageError(error, allMessages, analyticsMetadataContext) {
this.gainsightService.triggerEvent('ai.agent.error', {
errorMessage: error,
messageCount: allMessages.length,
...analyticsMetadataContext
});
}
/** Sent when the agent is unavailable, for example because the microservice is not present or has insufficient permissions.
* This is useful to find out what kinds of problems users are mostly having to get access.
*/
async sendAgentUnavailable(errorType) {
this.gainsightService.triggerEvent('ai.agent.unavailable', {
errorType: errorType
});
}
/** Rates an AI message as positive/negative, sending the feedback to GainSight */
async sendFeedbackOnAssistantMessage(assistantMessage, allMessages, positive, analyticsMetadataContext) {
const userMessage = allMessages[allMessages.lastIndexOf(assistantMessage) - 1];
const detailedFeedback = !positive;
this.gainsightService.triggerEvent('ai.agent.feedback', {
positive,
messageCount: allMessages.length,
...(await this._summarizeMessagesForGainSight(userMessage, assistantMessage, detailedFeedback)),
...analyticsMetadataContext
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UserAnalyticsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UserAnalyticsService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: UserAnalyticsService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}] });
class AgentChatComponent {
constructor() {
this.translateService = inject(TranslateService);
this.datePipe = inject(DatePipe);
this.modalService = inject(ModalService);
/** Debug logging function for AI messages. Can be enabled at runtime with localStorage.setItem('c8y-debug-log.agent-chat', 'true'); */
this.debugLog = (category => {
const isDebugEnabled = localStorage.getItem('c8y-debug-log.' + category) === 'true';
return (message, ...args) => isDebugEnabled && console.debug(`[${category} DEBUG] ${message}`, ...args);
})('agent-chat');
/**
* Identifies which AI agent from the AI Agent Manager service will be used by this component.
*
* Where possible, provide a `ClientAgentDefinition` rather than just a name.
*
* It is recommended to define a `toolCallConfig` in the same place you define `ClientAgentDefinition`,
* and to use the keys from `assistantMessageDisplayConfig.toolCallConfig`
* in the `ClientAgentDefinition` tools list.
*/
this.agent = input.required(...(ngDevMode ? [{ debugName: "agent" }] : []));
/**
* Clickable suggestions buttons to display just above the chat, for likely next user actions.
* These may be hardcoded or be set by the AI agent (e.g. in a tool call).
* This input should be `undefined` to indicate that there are no new suggestions yet, and that any restored from the chat history should be used instead.
* You can use the `isWelcoming` output to show different suggestions while the initial welcome message is displayed.
*/
this.suggestions = input([], ...(ngDevMode ? [{ debugName: "suggestions" }] : []));
/** ChatConfig to customize the ai-chat component's configuration if desired. */
this.chatConfig = input(...(ngDevMode ? [undefined, { debugName: "chatConfig" }] : []));
/** Template for customizing the welcome view using `ng-template`. */
this.welcomeTemplate = input(...(ngDevMode ? [undefined, { debugName: "welcomeTemplate" }] : []));
/** Indicates whether the component has zero messages and is showing the welcome page. */
this.isWelcoming = computed(() => this.messages().length === 0, ...(ngDevMode ? [{ debugName: "isWelcoming" }] : []));
/** Configures whether this component should automatically create the provided `agent: ClientAgentDefinition`
* if an agent of that name doesn't already exist. This is an alternative to the usual mechanism of configuring
* agents using a `.agent.c8y.ts` file.
*/
this.autoCreateAgents = input(false, ...(ngDevMode ? [{ debugName: "autoCreateAgents" }] : []));
/** Variables to pass to the AI agent for placeholders specified in the agent's system prompt. */
this.variables = input({}, ...(ngDevMode ? [{ debugName: "variables" }] : []));
/**
* Allows overriding the component used to render messages from the AI assistant, if the default
* AiChatAssistantMessageComponent does not do what you need.
*
* The component must accept a single input `assistantMessageContext: AssistantMessageContext`.
* This object contains all data needed to render the assistant message and is forward-compatible with future additions.
*
* Example usage:
* <c8y-agent-chat [assistantMessageComponent]="MyCustomAssistantMessageComponent"></c8y-agent-chat>
*/
this.assistantMessageComponent = input(AiChatAssistantMessageComponent, ...(ngDevMode ? [{ debugName: "assistantMessageComponent" }] : []));
/**
* Configuration passed to `AiChatAssistantMessageComponent` for controlling how messages from the assistant
* are rendered, including tool calls.
*
* If you are performing any tool calls it is recommended to configure the `toolCallConfig`
* to provide localized display names for the tools used in your `AgentDefinitionConfig`.
*/
this.assistantMessageDisplayConfig = input({}, ...(ngDevMode ? [{ debugName: "assistantMessageDisplayConfig" }] : []));
/**
* Optional function to preprocess messages from the assistant before they are displayed, for example if you need to remove special
* tagged sections from the content to move into simulated tool calls.
*
* This function may be called many times for each message, as more steps, tool calls and content are streamed from the agent.
*
* If you modify any part of the message other than the `changedPart`, you should replace it with a new shallow copy of the object so
* that change detection works correctly. This is not necessary for changedPart.
*
* @param message The message from the AI assistant.
* @param changedPart The part of the message that has changed, which can be modified in-place if desired.
*/
this.preprocessAgentMessage = input(undefined, ...(ngDevMode ? [{ debugName: "preprocessAgentMessage" }] : []));
/** Optional function to override how the message history is prepared and compacted ready for sending with
* agent requests. This overrides the default behaviour from `defaultPruneMessagesForAgent`.
*
* For example this can be used to remove unnecessary information to minimize tokens, reduce the number of messages,
* or to make what is sent to the agent different from what is rendered in the UI.
*/
this.pruneMessagesForAgent = input(undefined, ...(ngDevMode ? [{ debugName: "pruneMessagesForAgent" }] : []));
/** When true, skips the agent health check on initialization.
* Use this when the component is used with the test endpoint (snapshot mode) where the agent
* does not need to exist on the backend. Default: false. */
this.skipHealthCheck = input(false, ...(ngDevMode ? [{ debugName: "skipHealthCheck" }] : []));
/** Input that provides a previously-saved chat history snapshot to restore. */
this.initialChatHistory = input(...(ngDevMode ? [undefined, { debugName: "initialChatHistory" }] : []));
/** Suggestions that were restored from the initial chat history; used if `suggestions` input is not yet set. */
this._restoredSuggestions = [];
/**
* An optional function that returns a message sent in AI agent requests just before each new user message to provide context or "grounding" to
* anchor the AI's response - such as the current state of the document the AI is helping with.
*
* Typically this string would begin with a special prefix (e.g. "[STATE_SNAPSHOT]") that is described by the system prompt.
*
* This message does not form part of the chat history, to avoid confusing the AI agent with older grounding state.
* It is injected into the message history just before the latest user message as per standard recommendations,
* since AI gives more attention to the recent content.
*/
this.groundingContextProvider = input(...(ngDevMode ? [undefined, { debugName: "groundingContextProvider" }] : []));
/**
* Provides application-defined fields to enrich the user feedback tracking data for this component.
*
* Do not include Intellectual Property or Personal Identifiable Information in this object, as it may
* be included in feedback data sent to third-party services.
*/
this.userAnalyticsContext = input(...(ngDevMode ? [undefined, { debugName: "userAnalyticsContext" }] : []));
this.onMessageFinish = output();
/**
* Notified when a tool result is received from the agent.
*
* This is called before it is added to the message displayed in the component.
*
* Tools with a client/UI implementation may provide `output` for the tool
* (e.g. data to render in the UI) by calling `addToolOutput`.
*/
this.onToolResult = output();
/** Notified when the user presses the positive or negative feedback buttons for an assistant message.*/
this.onFeedback = output();
/**
* Emitted after any meaningful mutation of the messages list (send, finish, reprompt, reload, delete, reset).
* Allows the parent to react to conversation changes, e.g. to persist the conversation.
*/
this.onMessagesChange = output();
this._isLoading = signal(true, ...(ngDevMode ? [{ debugName: "_isLoading" }] : [])); // initially true while we do our initial ping of the agent health - prevents message flashing up before ready
this.aiAgentManagerApplicationName = gettext('AI Agent Manager');
/** Stores any error with the agent or backend microservice. This error message is already translated. */
this._agentHealthError = signal(this.translateService.instant(gettext('Connecting to AI agent…')), ...(ngDevMode ? [{ debugName: "_agentHealthError" }] : []));
/** Stores the most recent agent health check response, or `undefined` while the initial check is in progress. */
this._agentHealth = signal(undefined, ...(ngDevMode ? [{ debugName: "_agentHealth" }] : []));
/** Stores any detailed error messages from the backend. */
this.agentHealthDetailedMessages = signal(undefined, ...(ngDevMode ? [{ debugName: "agentHealthDetailedMessages" }] : []));
/**
* Stores any error from LLM call.
*
* If possible this string is translated already or registered with `gettext`.
* In practice, this may not be translated if it comes from the backend.
*/
this.agentRequestError = signal(undefined, ...(ngDevMode ? [{ debugName: "agentRequestError" }] : []));
// A stream of AI messages representing the conversation.
// NB: this is NOT part of the API of this component and may change in future
// (e.g. we may improve AIMessage class)
this.messages = signal([], ...(ngDevMode ? [{ debugName: "messages" }] : []));
/** Computed cumulative token usage across all messages in the conversation. */
this.cumulativeUsage = computed(() => {
const msgs = this.messages();
return msgs.reduce((acc, msg) => {
if ('usage' in msg && msg.usage) {
acc.inputTokens += msg.usage.inputTokens || 0;
acc.outputTokens += msg.usage.outputTokens || 0;
acc.totalTokens += msg.usage.totalTokens || 0;
}
return acc;
}, { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
}, ...(ngDevMode ? [{ debugName: "cumulativeUsage" }] : []));
/**
* Computed signal holding the last assistant message, which is the one which may be rapidly changing as we
* stream it back. This allows us to update the UI reactively without affecting the rest of the message history.
*/
this.lastAssistantMessageContext = computed(() => {
const msgs = this.messages();
const lastMsg = msgs[msgs.length - 1];
const config = this.assistantMessageDisplayConfig();
const isLoadingAiResponse = this.isLoadingAiResponse();
if (lastMsg?.role !== 'assistant')
return null;
return {
message: lastMsg,
config: config,
isMessageLoading: isLoadingAiResponse,
messageDisplayIndex: 0
};
}, ...(ngDevMode ? [{ debugName: "lastAssistantMessageContext" }] : []));
/** If the create agent button should be shown to the user. */
this.canCreate = false;
this.prompt = '';
this.aiService = inject(AIService);
this.userAnalyticsService = inject(UserAnalyticsService);
this.alertService = inject(AlertService);
this.chatHistoryService = inject(ChatHistoryService);
this.pluginContextPath = inject(C8Y_PLUGIN_CONTEXT_PATH, { optional: true });
this.appState = inject(AppStateService);
this.assistantMessageComponents = viewChildren(AiChatAssistantMessageComponent, ...(ngDevMode ? [{ debugName: "assistantMessageComponents" }] : []));
/**
* Stores client-supplied tool outputs to override tool results for a given toolCallId in the current assistant message.
* Cleared at the start of each new message.
*/
this._clientToolOutputs = {};
}
get agentName() {
const value = this.agent();
return typeof value === 'string' ? value : value.definition.name;
}
get agentDefinition() {
const value = this.agent();
if (!value || typeof value === 'string')
return undefined;
return value;
}
/** Is an AI agent request currently in progress. This can be used to disable UI elements while a reply (which might change the state) is being loaded. */
get isLoadingAiResponse() {
return this._isLoading.asReadonly();
}
/** Returns the most recent agent health check status such as `ready`, or an error. */
get agentHealthStatus() {
return this._agentHealth()?.status || 'loading';
}
get currentContextPath() {
return this.pluginContextPath || this.appState.currentApplication?.value?.contextPath || '';
}
async ngOnInit() {
await this.initializeChat();
}
async ngOnChanges(changes) {
if ((changes['agent'] && this.agent()) || changes['initialChatHistory']) {
this.resetMessages();
await this.initializeChat();
}
}
/**
* Save the current chat history and suggestions to an opaque, versioned, JSON-serializable snapshot of the chat history for persistence.
*
* To save space, only recent tool results and reasoning are included.
* Additional options for compressing the history can be provided using the config parameters.
*
* @param pruner Optional function to prune/transform messages before serialization.
* If not specified, `ChatHistoryService.createDefaultSerializationMessagePruner()` is used.
*/
saveChatHistory(pruner) {
return this.chatHistoryService.save(this.messages(), this.suggestions(), pruner);
}
/** Sends a message to the AI */
async sendMessage(message) {
if (this.isLoadingAiResponse()) {
// Probably not worth a user-facing error message, but since we assume everywhere that we don't have parallel chats going on at once,
// don't allow this (e.g. if called from a chat button or other part of the application's UI)
console.log('Ignoring chat message because previous AI response is still loading');
return;
}
// Collapse the thinking section of the previous assistant message before adding a new one
const components = this.assistantMessageComponents();
if (components.length > 0) {
components[components.length - 1].setThinkingExpanded(false);
}
this.messages.set([...this.messages(), message]);
this.onMessagesChange.emit(this.messages());
this._isLoading.set(true);
this.agentRequestError.set(undefined); // Clear any previous stream errors
this.abortController = new AbortController();
this._clientToolOutputs = {};
// Prepare messages for AI service, optionally including grounding message
const pruneMessagesForAgent = this.pruneMessagesForAgent() || defaultPruneMessagesForAgent;
const messagesForAI = pruneMessagesForAgent(this.messages());
const groundingProvider = this.groundingContextProvider();
if (groundingProvider) {
// Insert grounding message before the last user message
const groundingMsg = {
role: 'user',
content: groundingProvider()
};
messagesForAI.splice(messagesForAI.length - 1, 0, groundingMsg);
}
const currentAssistantMessage = {
role: 'assistant',
content: []
};
this.messages.set([...this.messages(), currentAssistantMessage]);
// Branch based on agent type: object agents use a single non-streaming request,
// text agents use SSE streaming
if (this.agentDefinition?.definition.type === 'object') {
await this._sendObjectMessage(currentAssistantMessage, messagesForAI);
}
else {
await this._sendStreamMessage(messagesForAI);
}
}
ngOnDestroy() {
this.cancel();
}
async reprompt(userMessage) {
const index = this.messages().indexOf(userMessage);
if (index > -1) {
const formattedTimestamp = userMessage.timestamp
? this.datePipe.transform(userMessage.timestamp, 'adaptiveDate')
: gettext('(unknown time)');
try {
await this.modalService.confirm(gettext('Edit this prompt and delete the following messages'), this.translateService.instant(gettext('Are you sure you want to rewrite the conversation by editing your message from {{timestamp}}, and deleting the following {{messageCount}} assistant and user messages?'), {
timestamp: formattedTimestamp,
messageCount: this.messages().length - index - 1
}), Status.DANGER, { cancel: gettext('Cancel'), ok: gettext('Delete and replace') });
this.messages.set(this.messages().slice(0, index));
this.onMessagesChange.emit(this.messages());
this.prompt = userMessage.content;
}
catch (e) {
if (!e)
return; // User dismissed the dialog, do nothing
throw e; // Unexpected error, rethrow to propagate
}
}
}
/** Rates an AI message as positive/negative, sending the feedback to GainSight */
async rate(assistantMessage, positive) {
this.userAnalyticsService.sendFeedbackOnAssistantMessage(assistantMessage, this.messages(), positive, await this.getAnalyticsMetadataContext());
this.onFeedback.emit({
userMessage: this.messages()[this.messages().lastIndexOf(assistantMessage) - 1],
assistantMessage,
feedbackPositive: positive
});
this.alertService.success(gettext('Thank you for the feedback!'));
}
reload(assistantMessage) {
const index = this.messages().indexOf(assistantMessage);
const userMessage = this.messages()[index - 1];
if (index > -1) {
this.messages.set(this.messages().slice(0, index - 1));
this.onMessagesChange.emit(this.messages());
this.sendMessage({
role: 'user',
content: userMessage.content,
timestamp: new Date().toISOString()
});
}
}
cancel() {
const abortMessage = this.translateService.instant(gettext('AI response cancelled.'));
if (this.abortController) {
this.abortController.abort(abortMessage);
}
if (this.assistantSubscription) {
this.assistantSubscription.unsubscribe();
}
this._isLoading.set(false);
const lastMessage = this.messages()[this.messages().length - 1];
if (lastMessage && lastMessage.role === 'assistant' && !lastMessage.content) {
this.messages.set(this.messages().slice(0, -1));
this.onMessagesChange.emit(this.messages());
// if user aborts sending message immediately, before abort controller is instantiated,
// error message won't be set and we need to do it manually here
if (!this.agentRequestError()) {
this.agentRequestError.set(abortMessage);
}
}
}
/** Clears all messages and cancels any in-flight stream. */
resetMessages() {
this.cancel();
this.messages.set([]);
this.onMessagesChange.emit(this.messages());
}
/** Removes the last user message and all assistant messages that follow it from the conversation. */
deleteLastExchange() {
const msgs = this.messages();
if (msgs.length === 0)
return;
// Find the index of the last user message
let lastUserIndex = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') {
lastUserIndex = i;
break;
}
}
// If no user message found, do nothing
if (lastUserIndex === -1)
return;
// Remove from the last user message onwards
this.messages.set(msgs.slice(0, lastUserIndex));
this.onMessagesChange.emit(this.messages());
}
/**
* Overrides the output of a specific tool result (in the current assistant message), including optionally indicating that an error occurred.
*
* This can be used to provide output for tool calls that are implemented on the client,
* and is typically called from `onToolResult`.
*
* @param output: A string or JSON-serializable object.
*/
addToolOutput(toolCallId, output, error) {
// No validation of the id is performed here; validation is done when applying the override in processAgentMessage
this.debugLog(`Adding client-side tool ${error ? 'error' : 'output'} for ${toolCallId}: `, output);
this._clientToolOutputs[toolCallId] = { output: output, error: error ?? false };
}
async createAgent() {
this._isLoading.set(true);
try {
if (!this.agentDefinition) {
throw new Error('No agent definition provided');
}
await this.aiService.createOrUpdateAgent(this.agentDefinition.definition);
this._agentHealthError.set(undefined);
}
catch (ex) {
// TODO: Distinguish permissions errors to provide a more specific error message.
console.error('Error configuring AI agent:', ex);
this.alertService.danger(gettext('Failed to configure the agent.'));
this._agentHealthError.set(this.translateService.instant(gettext('Failed to configure the agent.')));
}
this._isLoading.set(false);
}
// Private methods go here:
async initializeChat() {
this._restoreChatHistory(this.initialChatHistory());
if (this.skipHealthCheck()) {
this._agentHealthError.set(undefined);
this._isLoading.set(false);
}
else {
await this.checkAgentHealth();
}
}
/** Restore chat history from a previously-saved snapshot. Does nothing if the component already has some messages. */
_restoreChatHistory(history) {
if (!history)
return;
// Ignore updates to history once we have messages
if (this.messages().length > 0) {
return;
}
const restored = this.chatHistoryService.restore(history);
this.messages.set(restored.messages);
this._restoredSuggestions = restored.suggestions;
}
async checkAgentHealth() {
// Set the loading indicator to prevent interactions while we establish whether the agent is useable
this._isLoading.set(true);
try {
const agentHealth = await this.aiService.getAgentHealth(this.agentName, this.currentContextPath);
this._agentHealth.set(agentHealth);
this.agentHealthDetailedMessages.set(agentHealth.messages?.join('\n'));
this._agentHealthError.set(this.handleAgentError(agentHealth));
if (this.agentDefinition && this.agentDefinition.snapshot) {
this.createAgent();
}
}
catch (ex) {
console.warn('Error getting agent manager health:', ex);
this.userAnalyticsService.sendAgentUnavailable('agent-health-check-failed');
this._agentHealthError.set(this.translateService.instant(gettext('This tenant does not have the {{agentManager}} microservice. Please contact your administrator.'), { agentManager: this.translateService.instant(this.aiAgentManagerApplicationName) }));
}
this._isLoading.set(false);
}
async getAnalyticsMetadataContext() {
return await this.userAnalyticsService.getAnalyticsMetadataContext(this.agentName, this.agentDefinition, this.userAnalyticsContext(), this.model, this.systemPrompt);
}
handleAgentError(agentHealth) {
this.canCreate = false;
const agentManager = this.translateService.instant(this.aiAgentManagerApplicationName);
if (agentHealth.status != 'ready')
this.userAnalyticsService.sendAgentUnavailable(agentHealth.status || 'unknown-error');
// All of these errors are displayed under a headline banner stating "AI agent is not available"
// From end-user perspective it makes sense to recommend opening AI Agent Manager to configure their provider/agent
// (or, implicitly, to ask their admin to do that if they don't have access themselves).
// If they're the application author they could create a `.agent.c8y.ts` file instead, but we don't want to
// expose such low-level details in the UI.
switch (agentHealth.status) {
case 'ready':
return undefined;
case 'missing-provider':
return this.translateService.instant(gettext('Configure a provider in the {{agentManager}} to get started.'), { agentManager });
case 'missing-permissions':
return this.translateService.instant(gettext('Add AI Agent permissions to the current user or role to get started.'));
case 'missing-microservice':
return this.translateService.instant(gettext('This tenant does not have the {{agentManager}} microservice. Please contact your administrator.'), { agentManager });
}
// Otherwise it's a missing agent definition
if (agentHealth.canCreate && this.agentDefinition) {
this.canCreate = true;
if (this.autoCreateAgents() && this.agentDefinition) {
this.createAgent();
return this.translateService.instant(gettext('Creating the "{{agentName}}" AI agent now…'), { agentName: this.agentName });
}
// In this case we show a "Create agent" button
return this.translateService.instant(gettext('Create the "{{agentName}}" AI agent to get started.'), { agentName: this.agentName });
}
return this.translateService.instant(gettext('Configure the "{{agentName}}" agent using the {{agentManager}} to get started.'), { agentName: this.agentName, agentManager });
}
/**
* Called as responses are incrementally streamed from the agent.
* Reads the current assistant message from the end of the messages array, processes the update,
* and replaces it with a new immutable message object to trigger change detection.
* @param updatedAssistantMsg contains the latest data received from the agent
* @param changedPart if provided, indicates which part of the message was changed in this update
*/
async processAgentMessage(updatedAssistantMsg, changedPart) {
const currentAssistantMsg = this.messages()[this.messages().length - 1];
// Must ensure changedPart is a new instance, otherwise change detection can be missed
if (changedPart && changedPart?.type !== 'response-metadata') {
const newInstance = { ...changedPart };
const idx = updatedAssistantMsg.content.findIndex(p => p === changedPart);
if (idx !== -1) {
updatedAssistantMsg.content[idx] = newInstance;
}
else {
// Should never happen; if it does we need to know
console.error('Internal error - changed part not found in updated assistant message content', changedPart, updatedAssistantMsg);
}
changedPart = newInstance;
}
// Apply preprocessing if provided. Is permitted to modify updatedAssistantMsg.content array
const preprocess = this.preprocessAgentMessage();
if (preprocess) {
updatedAssistantMsg = preprocess(updatedAssistantMsg, changedPart);
}
// Invoke tool callback - which may call addToolOutput
if (changedPart?.type === 'tool-result') {
this.debugLog(`Received tool result: ${changedPart.toolName}`, changedPart);
this.onToolResult.emit(changedPart);
}
// Create new message object with updated content (immutable update)
// don't need a deep copy for content itself, and must avoid that so pre-processor can add steps
const newMsg = {
...currentAssistantMsg,
content: updatedAssistantMsg.content
};
// Apply any pending tool output overrides - and check that none of them refers to a non-existent item
if (Object.keys(this._clientToolOutputs).length > 0) {
for (const [toolCallId, override] of Object.entries(this._clientToolOutputs)) {
const toolResult = newMsg.content.find((p) => p.type === 'tool-result' && p.toolCallId === toolCallId);
if (toolResult) {
toolResult.output = override.output;
toolResult.error = override.error;
}
else {
throw new Error(`addToolOutput was called with a toolCallId '${toolCallId}' that does not exist in the current assistant message`);
}
}
}
if (updatedAssistantMsg.finishReason) {
this.debugLog(`Received assistant message with ${updatedAssistantMsg.content.filter(p => p.type === 'step-start').length} steps: `, updatedAssistantMsg);
try {
this.handleMessageFinish(newMsg, updatedAssistantMsg);
}
finally {
this._isLoading.set(false);
}
}
// Replace streaming message with new immutable instance to trigger change detection
this.messages.update(msgs => [...msgs.slice(0, -1), newMsg]);
}
handleMessageFinish(currentAssistantMsg, updatedAssistantMsg) {
currentAssistantMsg.timestamp = new Date().toISOString();
for (const part of currentAssistantMsg.content) {
if (part.type === 'tool-input-streaming' || part.type === 'tool-executing') {
part.error = true;
}
}
if (updatedAssistantMsg.finishReason === 'stop') {
currentAssistantMsg.usage = updatedAssistantMsg.usage;
this.onMessagesChange.emit(this.messages());
this.onMessageFinish.emit(currentAssistantMsg);
void this.getAnalyticsMetadataContext()
.then(analyticsContext => {
this.userAnalyticsService.sendAssistantMessageComplete(updatedAssistantMsg, this.messages(), analyticsContext);
})
.catch(error => {
console.error('Error sending analytics:', error);
});
if (currentAssistantMsg.content.length === 0) {
// Should be impossible, but adding this as a debugging aid, as it was seen once by a user
console.warn('Received finish reason "stop" but assistant message content is empty. This may indicate an issue with the AI service or agent configuration.', currentAssistantMsg);
}
}
else if (updatedAssistantMsg.finishReason ===