From b8b1549e9eee23c66486b7cc8d285a9ed6520c48 Mon Sep 17 00:00:00 2001 From: Christin <164907691+scottcmg@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:14:21 -0700 Subject: [PATCH 1/4] feat: add method for cac citations --- .../conversational-agent.models.ts | 36 ++++++- .../conversational-agent.ts | 85 +++++++++++++++++ .../conversational-agent/helpers/citation.ts | 62 ++++++++++++ .../conversational-agent/citation.test.ts | 52 ++++++++++ .../conversational-agent.test.ts | 95 +++++++++++++++++++ 5 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 src/services/conversational-agent/helpers/citation.ts create mode 100644 tests/unit/helpers/conversational-agent/citation.test.ts diff --git a/src/models/conversational-agent/conversational-agent.models.ts b/src/models/conversational-agent/conversational-agent.models.ts index c0ff40e5b..0edfdffca 100644 --- a/src/models/conversational-agent/conversational-agent.models.ts +++ b/src/models/conversational-agent/conversational-agent.models.ts @@ -2,7 +2,7 @@ import type { AgentGetResponse, AgentGetByIdResponse } from './agents'; -import type { ConversationServiceModel } from './conversations'; +import type { CitationSourceMedia, ConversationServiceModel } from './conversations'; import type { FeatureFlags } from './feature-flags.types'; import type { UserSettingsServiceModel } from './user'; import type { ConnectionStatus } from '@/core/websocket'; @@ -141,6 +141,40 @@ export interface ConversationalAgentServiceModel { */ getById(id: number, folderId: number): Promise; + /** + * Downloads the document behind a media citation as an authenticated `Blob`. + * + * Media citation sources expose a `downloadUrl` pointing at an auth-gated + * UiPath endpoint; opening it directly sends no token and fails with `401`. + * This performs the request with the SDK's access token and returns the bytes, + * with the `Blob` type resolved from the source `mimeType` (falling back to the + * response Content-Type, then the title's file extension). Use `source.title` + * as the file name. + * + * HTML content is intentionally returned as `application/octet-stream` (a + * download) rather than `text/html`, so previewing the blob inline can't + * execute citation markup in your app's origin. + * + * @param source - A media citation source ({@link CitationSourceMedia}) with a `downloadUrl` + * @returns Promise resolving to the document as a `Blob` + * @throws ValidationError if the source has no `downloadUrl` + * @throws A typed HTTP error for error responses — e.g. `AuthenticationError` + * (401), `AuthorizationError` (403), `NotFoundError` (404) — or + * `NetworkError` on a connection failure, matching other SDK calls + * + * @example + * ```typescript + * import { isCitationSourceMedia } from '@uipath/uipath-typescript/conversational-agent'; + * + * if (isCitationSourceMedia(source)) { + * const blob = await conversationalAgent.downloadCitationSource(source); + * const url = URL.createObjectURL(blob); + * window.open(url, '_blank'); + * } + * ``` + */ + downloadCitationSource(source: CitationSourceMedia): Promise; + /** * Registers a handler that is called whenever the WebSocket connection status changes. * diff --git a/src/services/conversational-agent/conversational-agent.ts b/src/services/conversational-agent/conversational-agent.ts index 891321571..6366fb810 100644 --- a/src/services/conversational-agent/conversational-agent.ts +++ b/src/services/conversational-agent/conversational-agent.ts @@ -6,12 +6,16 @@ import type { IUiPath } from '@/core/types'; import type { ConnectionStatusChangedHandler } from '@/core/websocket'; import { track } from '@/core/telemetry'; +import { ValidationError } from '@/core/errors'; +import { ErrorFactory } from '@/core/errors/error-factory'; +import { errorResponseParser } from '@/core/errors/parser'; import { BaseService } from '@/services/base'; // Models import type { ConversationalAgentOptions, ConversationalAgentServiceModel, + CitationSourceMedia, FeatureFlags, RawAgentGetResponse, RawAgentGetByIdResponse, @@ -30,6 +34,7 @@ import { transformData } from '@/utils/transform'; // Local imports import { ConversationService } from './conversations'; import { buildConversationalAgentHeaders } from './helpers/header'; +import { resolveCitationMimeType } from './helpers/citation'; import { UserSettingsService } from './user'; /** @@ -42,6 +47,9 @@ export class ConversationalAgentService extends BaseService implements Conversat /** Service for reading and updating the current user's profile/context settings. See {@link UserSettingsServiceModel}. */ public readonly user: UserSettingsService; + /** Configured SDK origin, used to keep citation downloads on the tenant's host. */ + private readonly baseUrl?: string; + /** * Creates an instance of the ConversationalAgent service. * @@ -51,6 +59,7 @@ export class ConversationalAgentService extends BaseService implements Conversat constructor(instance: IUiPath, options?: ConversationalAgentOptions) { super(instance, buildConversationalAgentHeaders(options)); + this.baseUrl = instance.config.baseUrl; // Create conversation service with WebSocket support this.conversations = new ConversationService(instance, options); this.user = new UserSettingsService(instance, options); @@ -134,6 +143,82 @@ export class ConversationalAgentService extends BaseService implements Conversat ); } + /** + * Downloads the document behind a media citation as an authenticated `Blob`. + * + * HTML content is intentionally returned as `application/octet-stream` (a + * download) rather than `text/html`, so that previewing the blob inline can't + * execute citation markup in your app's origin. + * + * @param source - A media citation source (`CitationSourceMedia`) with a `downloadUrl` + * @returns Promise resolving to the document as a `Blob` + * @throws ValidationError if the source has no `downloadUrl` + * @throws A typed HTTP error for error responses — e.g. `AuthenticationError` + * (401), `AuthorizationError` (403), `NotFoundError` (404) — or + * `NetworkError` on a connection failure, matching other SDK calls + * + * @example Preview a PDF citation in a new tab + * ```typescript + * import { isCitationSourceMedia } from '@uipath/uipath-typescript/conversational-agent'; + * + * if (isCitationSourceMedia(source)) { + * const blob = await conversationalAgent.downloadCitationSource(source); + * const url = URL.createObjectURL(blob); + * window.open(url, '_blank'); // remember to URL.revokeObjectURL(url) when done + * } + * ``` + */ + @track('ConversationalAgent.downloadCitationSource') + async downloadCitationSource(source: CitationSourceMedia): Promise { + if (!source.downloadUrl) { + throw new ValidationError({ + message: 'Citation source has no downloadUrl to download' + }); + } + + // Only attach the token to the tenant's own origin. downloadUrl is + // server-provided; if one were ever malformed or injected to point at + // another host, do not send the user's token to that host. + const base = this.baseUrl ? new URL(this.baseUrl) : undefined; + let target: URL; + try { + target = new URL(source.downloadUrl, base); + } catch { + throw new ValidationError({ + message: `Invalid citation downloadUrl` + }); + } + if (base && target.origin !== base.origin) { + throw new ValidationError({ + message: `Refusing to send credentials to a download URL outside the configured origin` + }); + } + + const token = await this.getValidAuthToken(); + + let response: Response; + try { + // The downloadUrl targets the reference service, so only the bearer token + // applies — no conversational agent-specific headers are needed. + response = await fetch(target.href, { + headers: { Authorization: `Bearer ${token}` } + }); + } catch (error) { + throw ErrorFactory.createNetworkError(error); + } + + if (!response.ok) { + const errorInfo = await errorResponseParser.parse(response); + throw ErrorFactory.createFromHttpStatus(response.status, errorInfo); + } + + const blob = await response.blob(); + const mimeType = resolveCitationMimeType(source, blob.type); + return mimeType && mimeType !== blob.type + ? blob.slice(0, blob.size, mimeType) + : blob; + } + /** * Gets feature flags for the current tenant * diff --git a/src/services/conversational-agent/helpers/citation.ts b/src/services/conversational-agent/helpers/citation.ts new file mode 100644 index 000000000..b4f2eca79 --- /dev/null +++ b/src/services/conversational-agent/helpers/citation.ts @@ -0,0 +1,62 @@ +/** + * Helpers for resolving a citation media source into a downloadable document. + */ +import type { CitationSourceMedia } from '@/models/conversational-agent'; + +// The file extensions the context service can reference, mapped to their MIME +// types. Used only as a fallback for the octet-stream case below, so the +// extension set mirrors exactly what the reference service supports. +const EXTENSION_TO_MIME_TYPE: Record = { + '.pdf': 'application/pdf', + '.csv': 'text/csv', + '.json': 'application/json', + '.ods': 'application/vnd.oasis.opendocument.spreadsheet', + '.xls': 'application/vnd.ms-excel', + '.xlsm': 'application/vnd.ms-excel.sheet.macroEnabled.12', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.txt': 'text/plain', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.html': 'text/html' // ECS lists it, but clamped to octet-stream below +}; + +const OCTET_STREAM = 'application/octet-stream'; + +// MIME types that execute script when a downloaded blob is previewed inline: a +// blob URL runs in the embedder's origin, so an