@c8y/ngx-components
Version:
Angular modules for Cumulocity IoT applications
547 lines (543 loc) • 25.2 kB
JavaScript
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