UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

547 lines (543 loc) 25.2 kB
import * as i0 from '@angular/core'; import { inject, Injectable } from '@angular/core'; import * as i1 from '@c8y/client'; import { FetchClient } from '@c8y/client'; import { Observable } from 'rxjs'; import { gettext } from '@c8y/ngx-components/gettext'; // Internal implementation detail; maybe we don't even need this enum since var DataStreamType; (function (DataStreamType) { DataStreamType["TEXT_DELTA"] = "text-delta"; DataStreamType["TOOL_CALL"] = "tool-call"; DataStreamType["TOOL_INPUT_START"] = "tool-input-start"; DataStreamType["TOOL_INPUT_DELTA"] = "tool-input-delta"; DataStreamType["TOOL_CALL_STREAMING"] = "tool-call-streaming"; DataStreamType["TOOL_CALL_DELTA"] = "tool-call-delta"; DataStreamType["TOOL_RESULT"] = "tool-result"; DataStreamType["REASONING"] = "reasoning"; DataStreamType["REASONING_DELTA"] = "reasoning-delta"; DataStreamType["REDACTED_REASONING"] = "redacted-reasoning"; DataStreamType["REASONING_SIGNATURE"] = "reasoning-signature"; DataStreamType["FINISH_REASONING"] = "finish-reasoning"; DataStreamType["FINISH"] = "finish"; DataStreamType["FINISH_STEP"] = "finish-step"; DataStreamType["ERROR"] = "error"; DataStreamType["DATA"] = "data"; DataStreamType["MESSAGE_ANNOTATIONS"] = "message-annotations"; DataStreamType["SOURCE"] = "source"; DataStreamType["FILE"] = "file"; DataStreamType["STEP_START"] = "start-step"; DataStreamType["STEP_FINISH"] = "finish-step"; })(DataStreamType || (DataStreamType = {})); /** This service manages communication with the AI Agent Manager microservice, supporting * management of agents, and sending requests to the agents. */ class AIService { // nb: allowing the client to be passed in the constructor allows efficient unit testing without setting up the Angular TestBed constructor(client) { this.baseUrl = '/service/ai'; this.client = client ?? inject(FetchClient); } /** * Creates or updates an agent. * @param def The agent definition */ async createOrUpdateAgent(def) { const health = await this.getAgentHealth(def.name); let resource = `${this.baseUrl}/agent/${def.type}`; let method = 'POST'; if (health.exists) { resource = `${this.baseUrl}/agent/${def.type}/${def.name}`; method = 'PUT'; } const response = await this.client.fetch(resource, { body: JSON.stringify(def), method, headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`Failed to create agent: ${response.statusText}`); } } /** * Check if an agent exists and is ready for use. * @param name Agent name (optional) * @param fromApp The app context path to check for an agent. This can be used, if the agent should get distributed by the plugin (optional). * @returns Agent health check response. */ async getAgentHealth(name = '', fromApp) { const response = await this.client.fetch(`${this.baseUrl}/agent/test/${name || crypto.randomUUID()}${fromApp ? `?fromApp=${fromApp}` : ''}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { return { status: response.status === 403 ? 'missing-permissions' : 'missing-microservice', exists: false, canCreate: false, isProviderConfigured: false, messages: response.status === 403 ? [] : [response.statusText] }; } const json = await response.json(); if (!json.isProviderConfigured) { json.status = 'missing-provider'; } else if (!json.exists) { json.status = 'missing-agent'; } else { json.status = 'ready'; } return json; } /** * Send a text message to the agent. * @param name Agent name * @param messages The message to send, including any previous message history. * Typically you should use `defaultPruneMessagesForAgent` or a custom function to prepare the message history * by removing unnecessary content such as large/old tool inputs/outputs. * @param variables Variables to include * @param fromApp The app context path to check for an agent. This can be used, if the agent should get distributed by the plugin (optional). * @returns Text response from the agent. */ async text(name, messages, variables, fromApp) { const parsedMessages = this.convertToAgentMessageFormat(messages); const data = this.client.fetch(`${this.baseUrl}/agent/text/${name}${fromApp ? `?fromApp=${fromApp}` : ''}`, { body: JSON.stringify({ messages: parsedMessages, variables }), method: 'POST', headers: { 'Content-Type': 'application/json' } }); const response = await data; if (!response.ok) { throw new Error(`Failed to talk with agent: ${response.statusText}`); } const text = await response.text(); return text; } /** * Send messages to an object-type agent via the non-streaming test endpoint. * Object agents return a single structured JSON response rather than a stream. * * @param agent A ClientAgentDefinition with `snapshot: true` (required for test endpoint). * @param messages Messages to send * @param variables Variables to include * @param abortController An AbortController to cancel the request. * @returns The full response including the structured object and usage information. */ async callObjectAgent(agent, messages, variables, abortController) { const parsedMessages = this.convertToAgentMessageFormat(messages); const agentName = typeof agent === 'string' ? agent : agent.definition.name; const useTest = typeof agent !== 'string' && agent.snapshot; const baseUrl = useTest ? `${this.baseUrl}/agent/test/${typeof agent !== 'string' ? agent.definition.type : 'object'}` : `${this.baseUrl}/agent/${typeof agent !== 'string' ? agent.definition.type : 'object'}/${agentName}`; let body = JSON.stringify({ messages: parsedMessages, variables }); if (useTest) { const agentCopy = { ...agent.definition }; agentCopy.agent.messages = parsedMessages; agentCopy.agent.variables = variables; body = JSON.stringify(agentCopy); } const response = await this.client.fetch(`${baseUrl}?fullResponse=true`, { method: 'POST', body, headers: { ...this.client.defaultHeaders, 'content-type': 'application/json' }, signal: abortController.signal }); if (response.status > 300) { const data = await response.json(); throw new Error(JSON.stringify(data, null, 2)); } const data = await response.json(); if (!('object' in data)) { throw new Error('Invalid response: missing required "object" field'); } if (typeof data.object !== 'object' || data.object === null) { throw new Error('Invalid response: "object" field must be an object'); } // Validate optional totalUsage field if present if (data.totalUsage !== undefined) { if (typeof data.totalUsage !== 'object' || data.totalUsage === null) { throw new Error('Invalid response: "totalUsage" must be an object'); } } return data; } /** * Stream a text message to the agent. * @param agent Agent name or an agent definition. * @param messages The message to send, including any previous message history. * Typically you should use `defaultPruneMessagesForAgent` or a custom function to prepare the message history * by removing unnecessary content such as large/old tool inputs/outputs. * @param variables Variables to include * @param abortController An AbortController to cancel the request. * @param fromApp The app context path to check for an agent. This can be used, if the agent should get distributed by the plugin (optional). * @returns An observable that emits AIStreamResponse including partial `AIMessage` objects as they are received. * When additional useful metadata is available, this will be streamed with type indicating it is metadata. * The observable can be cancelled using the provided AbortController. * The observable will emit an error if the request fails or is aborted. * The observable will complete when the stream is finished. * * The messages sent to the agent can include special options: * - `hiddenContent`: If set, this content will be sent to the agent instead of the `content` field. * - `skipToLLM`: If set to true, this message will be skipped when sending to the agent. * * Example usage: * ```typescript * const abortController = new AbortController(); * const messages: AIMessage[] = [ * { role: 'user', content: 'Hello' }, * { role: 'assistant', content: 'Hi there!' }, * { role: 'user', content: 'Tell me a joke.', options: { hiddenContent: 'Tell me a joke about cats.' } } * ]; * const observable = aiService.stream$('my-agent', messages, {}, abortController); * const subscription = observable.subscribe({ * next: (response) => { console.log('Received message update:', response.message); }, * error: (err) => console.error('Error:', err), * complete: () => console.log('Stream complete') * }); * * // To cancel the request: * abortController.abort(); * subscription.unsubscribe(); * ``` */ async stream$(agent, messages, variables, abortController, fromApp) { const parsedMessages = this.convertToAgentMessageFormat(messages); const agentName = typeof agent === 'string' ? agent : agent.definition.name; const useTest = typeof agent !== 'string' && agent.snapshot; const baseUrl = useTest ? `${this.baseUrl}/agent/test/text` : `${this.baseUrl}/agent/text/${agentName}`; let body = JSON.stringify({ messages: parsedMessages, variables }); if (useTest) { const agentCopy = { ...agent.definition }; agentCopy.agent['messages'] = parsedMessages; agentCopy.agent['variables'] = variables; body = JSON.stringify(agentCopy); } const response = await this.client.fetch(`${baseUrl}?fullResponse=true${fromApp ? `&fromApp=${fromApp}` : ''}`, { method: 'POST', body, headers: { ...this.client.defaultHeaders, 'content-type': 'application/json', accept: 'text/event-stream' }, signal: abortController.signal }); const stream = response.body; const decoder = new TextDecoder(); return new Observable(observer => { if (response.status > 300) { response .json() .then(data => { observer.error(`Error communicating with server: ${JSON.stringify(data, null, 2)}`); }) .catch(err => { observer.error(`Error communicating with server: HTTP ${response.status}: ${response.statusText}; ${err}`); }); return; } if (!stream) { observer.error('Server error - no response body'); return; } const reader = stream.getReader(); const message = { role: 'assistant', content: [] }; const abortHandler = (e) => { const reason = e?.target?.reason || gettext('Aborted'); reader.cancel(); observer.error(new DOMException(reason, 'AbortError')); }; abortController.signal.addEventListener('abort', abortHandler); let buffer = ''; const read = () => { reader.read().then(({ done, value }) => { if (done) { if (buffer.trim()) this.processLine(buffer, observer, message); // process any remaining data observer.complete(); return; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.trim()) this.processLine(line, observer, message); } read(); }); }; read(); return () => { abortController.signal.removeEventListener('abort', abortHandler); reader.cancel(); }; }); } convertToAgentMessageFormat(messages) { // We will need to rewrite this to include tool outputs soon (effectively this means targetting LanguageModelV*ToolResultOutput). // Once we have more fields than just role and content, we will need to convert between our format and // the Vercel format expected by the agent manager (e.g. steps->content parts) return messages.map(m => { if (m.role !== 'assistant') { return { role: m.role, content: m.content }; } // For assistant messages, check if there's an object part const objectPart = m.content.find((part) => part.type === 'object'); if (objectPart) { // If there's an object part, parse and send the JSON object as content return { role: m.role, content: objectPart.jsonContent }; } // Otherwise, send text parts as joined string return { role: m.role, content: m.content .filter((part) => part.type === 'text') .map(part => part.text) .join('\n\n') }; }); } /** * Convert a tool output from the MCP/wire format (which is different/more compact than LanguageModelV3ToolResultOutput) * into our own standard representation, which can be roundtripped to the format we need to provide when making future requests. * * Handles different data types from Vercel's streaming API: * - Text: stored as simple string in output field * - JSON objects: stored as-is in output field * - Content arrays: stored in output field with outputType="content" to enable roundtripping * - Single text in content array: unwrapped to simple string for simpler handling (and easier truncation) * - Errors: error flag set to true */ convertStreamingToolOutputToToolCallPart(output, target) { if (!output) { return; } if (output.isError || output.type === 'error-text' || output.type === 'error-json') target.error = true; // Handle content array format from streaming API if (output.content && Array.isArray(output.content)) { // Single text element - unwrap to simple string for simplicity if (output.content.length === 1 && output.content[0].type === 'text') { target.output = output.content[0].text; return; } // Multiple elements or mixed types - preserve as content array target.output = output.content; target.outputType = 'content'; // Mark as content to enable roundtripping return; } // Plain string or structured (JSON-serializable) object - store as-is target.output = output; } /** Add the specified tool call info to the current step. If this toolCallId is already known, * updates it rather than adding a new one, since for UI rendering and compactness of data it's * easiest to just have one item per tool call. * * Returns the changed item. */ addToolCallInfo(type, rawInfo, addTo) { // Vercel v4 seems to set this to "id" not "toolCallId" in some places despite doc to the contrary, so normalize it here if (!rawInfo.toolCallId) rawInfo.toolCallId = rawInfo.id; let existing = addTo.find((tc) => tc.type.startsWith('tool-') && tc.toolCallId === rawInfo.toolCallId); if (existing) { existing.type = type; } else if (!existing) { existing = { type: type, toolName: rawInfo.toolName, toolCallId: rawInfo.toolCallId }; addTo.push(existing); } if (rawInfo?.input) existing.input = rawInfo.input; if (rawInfo?.output) this.convertStreamingToolOutputToToolCallPart(rawInfo.output, existing); return existing; } /** Unpacks data from the Vercel Data Stream Protocol (sent by the Agent Manager over SSE) to update the message * * See streamText().fullStream() from https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#data-stream-protocol * * This is similar to what the Vercel `toUIMessageStreamResponse` API does (though it'd need the agent manager to publish the * UIMessageStream which has a bit more - useful - data than what is included here). * * We always use the same parts object, to make it easier for consumers (eg. agent-chat) * to add items to the list and have them still there on the next update from this service. */ processLine(line, observer, message) { if (!line.trim()) { return; } try { let data = {}; let type = ''; try { data = JSON.parse(line.replace('data: ', '')); type = data.type; } catch (e) { console.error('Error parsing line from AI response: ', line, e); return; } let lastPart = !message.content ? undefined : message.content[message.content.length - 1]; switch (type) { case DataStreamType.STEP_START: if (data.request && data.request.body) { observer.next({ message: message, changedPart: { type: 'response-metadata', model: data.request.body.model, systemPrompt: data.request.body.systemPrompt } }); } // Don't add consecutive duplicate step bounaries if (!lastPart || lastPart.type !== 'step-start') { message.content.push({ type: 'step-start' }); observer.next({ message, changedPart: message.content[message.content.length - 1] }); } return; case DataStreamType.REASONING_DELTA: const reasoning = data.text; if (!reasoning) return; if (lastPart?.type === 'reasoning') { lastPart.text += reasoning; } else { lastPart = { type: 'reasoning', text: reasoning }; message.content.push(lastPart); } observer.next({ message, changedPart: lastPart }); return; case DataStreamType.TEXT_DELTA: const text = data.textDelta || data.text; if (!text) return; if (lastPart?.type === 'text') { lastPart.text += text; } else { lastPart = { type: 'text', text: text }; message.content.push(lastPart); } observer.next({ message, changedPart: lastPart }); return; case DataStreamType.TOOL_INPUT_START: observer.next({ message, changedPart: this.addToolCallInfo('tool-input-streaming', data, message.content) }); return; case DataStreamType.TOOL_CALL: observer.next({ message, changedPart: this.addToolCallInfo('tool-executing', data, message.content) }); return; case DataStreamType.TOOL_RESULT: observer.next({ message, changedPart: this.addToolCallInfo('tool-result', data, message.content) }); return; case DataStreamType.FINISH: message.finishReason = data.finishReason; // FUTURE could be anything, may not match the options in AIMessage message.usage = data.totalUsage; observer.next({ message, changedPart: undefined }); observer.complete(); return; case DataStreamType.ERROR: message.finishReason = 'error'; observer.next({ message, changedPart: undefined }); observer.error(data?.message || data?.errorText || data); // Error is instead of completing the stream return; // Should we also handle other types such as "abort"? } } catch (e) { observer.error(e); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: AIService, deps: [{ token: i1.FetchClient }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: AIService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: AIService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i1.FetchClient }] }); /** * The default implementation of for reducing the size of the message history so it is ready to be sent to the LLM agent. * * This is important since old tool inputs and outputs can be very large and fill up the agent's context window. * * Implements `pruneMessagesFunction`. * * When sending an AI request you can provide your own function instead of using this one, * or you can write your own function that calls this and adds extra logic, * for example to suppress specific tools from the AIMessage, or * retain only the final step text in multi-step responses, etc. * * The current default implementation preserves text content, but does not include any tool calls. * This may change in future releases. */ function defaultPruneMessagesForAgent(messages) { // When we change this to include tool calls, we should remove large inputs and truncate large and/or old outputs // to avoid too many tokens and context window consumption. // Some applications will want to entirely remove specific tool calls that have only transient use, // which can be done by providing their own pruneMessagesForAgent function. return messages.map(m => { if (m.role !== 'assistant') { return { role: m.role, content: m.content }; } const textParts = m.content.filter((part) => part.type === 'text'); const objectParts = m.content.filter((part) => part.type === 'object'); const content = []; // Only add text part if there are text parts if (textParts.length > 0) { content.push({ type: 'text', text: textParts.map(part => part.text).join('\n\n') }); } // Preserve object parts for object agents content.push(...objectParts); return { role: m.role, content }; }); } /** * Generated bundle index. Do not edit. */ export { AIService, DataStreamType, defaultPruneMessagesForAgent }; //# sourceMappingURL=c8y-ngx-components-ai.mjs.map