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