UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

892 lines (889 loc) 87.3 kB
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 ===