UNPKG

@microsoft/agents-hosting

Version:

Microsoft 365 Agents SDK for JavaScript

360 lines (320 loc) 10.9 kB
/** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { Activity, addAIToActivity, Attachment, Entity, ClientCitation, SensitivityUsageInfo } from '@microsoft/agents-activity' import { TurnContext } from '../../turnContext' import { Citation } from './citation' import { CitationUtil } from './citationUtil' import { debug } from '@microsoft/agents-activity/logger' const logger = debug('agents:streamingResponse') /** * A helper class for streaming responses to the client. * @remarks * This class is used to send a series of updates to the client in a single response. The expected * sequence of calls is: * * `queueInformativeUpdate()`, `queueTextChunk()`, `queueTextChunk()`, ..., `endStream()`. * * Once `endStream()` is called, the stream is considered ended and no further updates can be sent. */ export class StreamingResponse { private readonly _context: TurnContext private _nextSequence: number = 1 private _streamId?: string private _message: string = '' private _attachments?: Attachment[] private _ended = false // Queue for outgoing activities private _queue: Array<() => Activity> = [] private _queueSync: Promise<void> | undefined private _chunkQueued = false // Powered by AI feature flags private _enableFeedbackLoop = false private _feedbackLoopType?: 'default' | 'custom' private _enableGeneratedByAILabel = false private _citations?: ClientCitation[] = [] private _sensitivityLabel?: SensitivityUsageInfo /** * Creates a new StreamingResponse instance. * @param {TurnContext} context - Context for the current turn of conversation with the user. * @returns {TurnContext} - The context for the current turn of conversation with the user. */ public constructor (context: TurnContext) { this._context = context } /** * Gets the stream ID of the current response. * @returns {string | undefined} - The stream ID of the current response. * @remarks * Assigned after the initial update is sent. */ public get streamId (): string | undefined { return this._streamId } /** * Gets the citations of the current response. */ public get citations (): ClientCitation[] | undefined { return this._citations } /** * Gets the number of updates sent for the stream. * @returns {number} - The number of updates sent for the stream. */ public get updatesSent (): number { return this._nextSequence - 1 } /** * Queues an informative update to be sent to the client. * @param {string} text Text of the update to send. */ public queueInformativeUpdate (text: string): void { if (this._ended) { throw new Error('The stream has already ended.') } // Queue a typing activity this.queueActivity(() => Activity.fromObject({ type: 'typing', text, entities: [{ type: 'streaminfo', streamType: 'informative', streamSequence: this._nextSequence++ }] })) } /** * Queues a chunk of partial message text to be sent to the client * @remarks * The text we be sent as quickly as possible to the client. Chunks may be combined before * delivery to the client. * @param {string} text Partial text of the message to send. * @param {Citation[]} citations Citations to be included in the message. */ public queueTextChunk (text: string, citations?: Citation[]): void { if (this._ended) { throw new Error('The stream has already ended.') } // Update full message text this._message += text // If there are citations, modify the content so that the sources are numbers instead of [doc1], [doc2], etc. this._message = CitationUtil.formatCitationsResponse(this._message) // Queue the next chunk this.queueNextChunk() } /** * Ends the stream by sending the final message to the client. * @returns {Promise<void>} - A promise representing the async operation */ public endStream (): Promise<void> { if (this._ended) { throw new Error('The stream has already ended.') } // Queue final message this._ended = true this.queueNextChunk() // Wait for the queue to drain return this.waitForQueue() } /** * Sets the attachments to attach to the final chunk. * @param attachments List of attachments. */ public setAttachments (attachments: Attachment[]): void { this._attachments = attachments } /** * Sets the sensitivity label to attach to the final chunk. * @param sensitivityLabel The sensitivty label. */ public setSensitivityLabel (sensitivityLabel: SensitivityUsageInfo): void { this._sensitivityLabel = sensitivityLabel } /** * Sets the citations for the full message. * @param {Citation[]} citations Citations to be included in the message. */ public setCitations (citations: Citation[]): void { if (citations.length > 0) { if (!this._citations) { this._citations = [] } let currPos = this._citations.length for (const citation of citations) { const clientCitation: ClientCitation = { '@type': 'Claim', position: currPos + 1, appearance: { '@type': 'DigitalDocument', name: citation.title || `Document #${currPos + 1}`, abstract: CitationUtil.snippet(citation.content, 477) } } currPos++ this._citations.push(clientCitation) } } } /** * Sets the Feedback Loop in Teams that allows a user to * give thumbs up or down to a response. * Default is `false`. * @param enableFeedbackLoop If true, the feedback loop is enabled. */ public setFeedbackLoop (enableFeedbackLoop: boolean): void { this._enableFeedbackLoop = enableFeedbackLoop } /** * Sets the type of UI to use for the feedback loop. * @param feedbackLoopType The type of the feedback loop. */ public setFeedbackLoopType (feedbackLoopType: 'default' | 'custom'): void { this._feedbackLoopType = feedbackLoopType } /** * Sets the the Generated by AI label in Teams * Default is `false`. * @param enableGeneratedByAILabel If true, the label is added. */ public setGeneratedByAILabel (enableGeneratedByAILabel: boolean): void { this._enableGeneratedByAILabel = enableGeneratedByAILabel } /** * Returns the most recently streamed message. * @returns The streamed message. */ public getMessage (): string { return this._message } /** * Waits for the outgoing activity queue to be empty. * @returns {Promise<void>} - A promise representing the async operation. */ public waitForQueue (): Promise<void> { return this._queueSync || Promise.resolve() } /** * Queues the next chunk of text to be sent to the client. * @private */ private queueNextChunk (): void { // Are we already waiting to send a chunk? if (this._chunkQueued) { return } // Queue a chunk of text to be sent this._chunkQueued = true this.queueActivity(() => { this._chunkQueued = false if (this._ended) { // Send final message return Activity.fromObject({ type: 'message', text: this._message || 'end of stream response', attachments: this._attachments, entities: [{ type: 'streaminfo', streamType: 'final', streamSequence: this._nextSequence++ }] }) } else { // Send typing activity return Activity.fromObject({ type: 'typing', text: this._message, entities: [{ type: 'streaminfo', streamType: 'streaming', streamSequence: this._nextSequence++ }] }) } }) } /** * Queues an activity to be sent to the client. */ private queueActivity (factory: () => Activity): void { this._queue.push(factory) // If there's no sync in progress, start one if (!this._queueSync) { this._queueSync = this.drainQueue().catch((err) => { logger.error(`Error occurred when sending activity while streaming: "${JSON.stringify(err)}".`) // throw err }) } } /** * Sends any queued activities to the client until the queue is empty. * @returns {Promise<void>} - A promise that will be resolved once the queue is empty. * @private */ private async drainQueue (): Promise<void> { // eslint-disable-next-line no-async-promise-executor return new Promise<void>(async (resolve, reject) => { try { logger.debug(`Draining queue with ${this._queue.length} activities.`) while (this._queue.length > 0) { const factory = this._queue.shift()! const activity = factory() await this.sendActivity(activity) } resolve() } catch (err) { reject(err) } finally { this._queueSync = undefined } }) } /** * Sends an activity to the client and saves the stream ID returned. * @param {Activity} activity - The activity to send. * @returns {Promise<void>} - A promise representing the async operation. * @private */ private async sendActivity (activity: Activity): Promise<void> { // Set activity ID to the assigned stream ID if (this._streamId) { activity.id = this._streamId if (!activity.entities) { activity.entities = [] } if (!activity.entities[0]) { activity.entities[0] = {} as Entity } activity.entities[0].streamId = this._streamId } if (this._citations && this._citations.length > 0 && !this._ended) { // Filter out the citations unused in content. const currCitations = CitationUtil.getUsedCitations(this._message, this._citations) ?? undefined activity.entities!.push({ type: 'https://schema.org/Message', '@type': 'Message', '@context': 'https://schema.org', '@id': '', citation: currCitations } as unknown as Entity) } // Add in Powered by AI feature flags if (this._ended) { activity.channelData = { feedbackLoopEnabled: this._enableFeedbackLoop ?? false, ...(this._feedbackLoopType ? { type: this._feedbackLoopType } : {}) } // Add in Generated by AI if (this._enableGeneratedByAILabel) { addAIToActivity(activity, this._citations, this._sensitivityLabel) } } // Send activity const response = await this._context.sendActivity(activity) await new Promise((resolve) => setTimeout(resolve, 1500)) // Save assigned stream ID if (!this._streamId) { this._streamId = response?.id } } }