@microsoft/agents-copilotstudio-client
Version:
Microsoft Copilot Studio Client for JavaScript. Copilot Studio Client.
536 lines (484 loc) • 19.7 kB
text/typescript
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { v4 as uuid } from 'uuid'
import { Activity, Attachment, ConversationAccount } from '@microsoft/agents-activity'
import { Observable, BehaviorSubject, type Subscriber } from 'rxjs'
import { CopilotStudioClient } from './copilotStudioClient'
import { debug, trace } from '@microsoft/agents-telemetry'
import { CopilotStudioClientTraceDefinitions } from './observability'
const logger = debug('copilot-studio:webchat')
/**
* Configuration settings for the Copilot Studio WebChat connection.
* These settings control the behavior and appearance of the WebChat interface
* when connected to the Copilot Studio service.
*/
export interface CopilotStudioWebChatSettings {
/**
* Whether to show typing indicators in the WebChat when the agent is processing a response.
* When enabled, users will see a typing indicator while waiting for the agent's reply,
* providing visual feedback that their message is being processed.
* @default false
*/
showTyping?: boolean;
/**
* An existing conversation ID to resume. When provided, the connection will
* send subsequent messages to this conversation instead of starting a new one.
*
* By default, providing a conversationId will skip the initial
* `startConversationStreaming()` call. Override this with the
* `startConversation` setting.
*
* **Note:** The server does not validate conversation IDs. A non-existent
* GUID will silently create a new conversation under that ID, while a
* non-GUID string may cause the server to return no response. Only pass
* IDs that were previously captured from a real conversation.
*/
conversationId?: string;
/**
* Controls whether `startConversationStreaming()` is called when the
* connection is first subscribed to.
*
* - `undefined` (default): starts a new conversation only when no
* `conversationId` is provided (`!conversationId`).
* - `true`: always starts a conversation, even when resuming.
* - `false`: never starts a conversation, even for new connections.
*/
startConversation?: boolean;
}
/**
* Represents a connection interface for integrating Copilot Studio with WebChat.
*
* @remarks
* This interface provides the necessary methods and observables to facilitate
* bidirectional communication between a WebChat client and the Copilot Studio service.
*
* The connection follows the DirectLine protocol pattern, making it compatible with
* Microsoft Bot Framework WebChat components.
*/
export interface CopilotStudioWebChatConnection {
/**
* An observable that emits the current connection status as numeric values.
* This allows WebChat clients to monitor and react to connection state changes.
*
* Connection status values:
* - 0: Disconnected - No active connection to the service
* - 1: Connecting - Attempting to establish connection
* - 2: Connected - Successfully connected and ready for communication
*/
connectionStatus$: BehaviorSubject<number>;
/**
* An observable stream that emits incoming activities from the Copilot Studio service.
* Each activity represents a message, card, or other interactive element sent by the agent.
*
* All emitted activities include:
* - A timestamp indicating when the activity was received
* - A 'webchat:sequence-id' in their channelData for proper message ordering
* - Standard Bot Framework Activity properties (type, text, attachments, etc.)
*/
activity$: Observable<Partial<Activity>>;
/**
* The active conversation ID. Set from `CopilotStudioWebChatSettings.conversationId`
* when resuming, or captured from the first response activity for new conversations.
* Returns `undefined` until a conversation has been established.
*/
readonly conversationId: string | undefined;
/**
* Posts a user activity to the Copilot Studio service and returns an observable
* that emits the activity ID once the message is successfully sent.
*
* The method validates that the activity contains meaningful content and handles
* the complete message flow including optional typing indicators.
*
* @param activity - The user activity to send.
* @returns An observable that emits the unique activity ID upon successful posting.
* @throws Error if the activity text is empty or if the connection is not properly initialized.
*/
postActivity(activity: Activity): Observable<string>;
/**
* Gracefully terminates the connection to the Copilot Studio service.
* This method ensures proper cleanup by completing all active observables
* and releasing associated resources.
*
* After calling this method:
* - The connectionStatus$ observable will be completed
* - The activity$ observable will stop emitting new activities
* - No further activities can be posted through this connection
*/
end(): void;
}
/**
* Creates a wrapper that invokes `fn` at most once.
* On the first call the wrapper invokes `fn(value)` and returns whatever `fn` returns.
* Subsequent calls do nothing and return `undefined`.
*
* @template T - Type of the single argument passed to the wrapped function.
* @param fn Function to be invoked once.
* @returns A wrapper function that calls `fn` at most once.
*/
function once<T = void> (fn: (value: T) => Promise<void>): (value: T) => Promise<void> | void {
let called = false
return value => {
if (!called) {
called = true
return fn(value)
}
}
}
/**
* A utility class that provides WebChat integration capabilities for Copilot Studio services.
*
* @remarks
* This class acts as a bridge between Microsoft Bot Framework WebChat and Copilot Studio,
* enabling seamless communication through a DirectLine-compatible interface.
*
* ## Key Features:
* - DirectLine protocol compatibility for easy WebChat integration
* - Real-time bidirectional messaging with Copilot Studio agents
* - Automatic conversation management and message sequencing
* - Optional typing indicators for enhanced user experience
* - Observable-based architecture for reactive programming patterns
*
* ## Usage Scenarios:
* - Embedding Copilot Studio agents in web applications
* - Creating custom chat interfaces with WebChat components
* - Building conversational AI experiences with Microsoft's bot ecosystem
*
* @example Basic WebChat Integration
* ```typescript
* import { CopilotStudioClient } from '@microsoft/agents-copilotstudio-client';
* import { CopilotStudioWebChat } from '@microsoft/agents-copilotstudio-client';
*
* // Initialize the Copilot Studio client
* const client = new CopilotStudioClient({
* botId: 'your-bot-id',
* tenantId: 'your-tenant-id'
* });
*
* // Create a WebChat-compatible connection
* const directLine = CopilotStudioWebChat.createConnection(client, {
* showTyping: true
* });
*
* // Integrate with WebChat
* window.WebChat.renderWebChat({
* directLine: directLine,
* // ... other WebChat options
* }, document.getElementById('webchat'));
* ```
*
* @example Advanced Usage with Connection Monitoring
* ```typescript
* const connection = CopilotStudioWebChat.createConnection(client);
*
* // Monitor connection status
* connection.connectionStatus$.subscribe(status => {
* switch (status) {
* case 0: console.log('Disconnected'); break;
* case 1: console.log('Connecting...'); break;
* case 2: console.log('Connected and ready'); break;
* }
* });
*
* // Listen for incoming activities
* connection.activity$.subscribe(activity => {
* console.log('Received activity:', activity);
* });
* ```
*/
export class CopilotStudioWebChat {
/**
* Creates a DirectLine-compatible connection for integrating Copilot Studio with WebChat.
*
* @param client - A configured CopilotStudioClient instance that handles the underlying
* communication with the Copilot Studio service. This client should be
* properly authenticated and configured with the target bot details.
*
* @param settings - Optional configuration settings that control the behavior of the
* WebChat connection. These settings allow customization of features
* like typing indicators and other user experience enhancements.
*
* @returns A new CopilotStudioWebChatConnection instance that can be passed directly
* to WebChat's renderWebChat function as the directLine parameter. The
* connection is immediately ready for use and will automatically manage
* the conversation lifecycle.
*
* @throws Error if the provided client is not properly configured or if there are
* issues establishing the initial connection to the Copilot Studio service.
*
* @remarks
* This method establishes a real-time communication channel between WebChat and the
* Copilot Studio service. The returned connection object implements the DirectLine
* protocol, making it fully compatible with Microsoft Bot Framework WebChat components.
*
* ## Connection Lifecycle:
* 1. **Initialization**: Creates observables for connection status and activity streaming
* 2. **Conversation Start**: Automatically initiates conversation when first activity is posted
* 3. **Message Flow**: Handles bidirectional message exchange with proper sequencing
* 4. **Cleanup**: Provides graceful connection termination
*
* ## Message Processing:
* - User messages are validated and sent to Copilot Studio
* - Agent responses are received and formatted for WebChat
* - All activities include timestamps and sequence IDs for proper ordering
* - Optional typing indicators provide visual feedback during processing
*
* @example
* ```typescript
* const connection = CopilotStudioWebChat.createConnection(client, {
* showTyping: true
* });
*
* // Use with WebChat
* window.WebChat.renderWebChat({
* directLine: connection
* }, document.getElementById('webchat'));
* ```
*/
static createConnection (
client: CopilotStudioClient,
settings?: CopilotStudioWebChatSettings
): CopilotStudioWebChatConnection {
const managed = trace(CopilotStudioClientTraceDefinitions.createConnection)
managed.record({ showTyping: settings?.showTyping })
try {
logger.info('--> Creating connection between Copilot Studio and WebChat ...')
const normalizedConversationId =
settings?.conversationId && settings.conversationId.trim() !== ''
? settings.conversationId.trim()
: undefined
const shouldStart = settings?.startConversation ?? !normalizedConversationId
let sequence = 0
let activitySubscriber: Subscriber<Partial<Activity>> | undefined
let conversation: ConversationAccount | undefined
let activeConversationId: string | undefined = normalizedConversationId
let ended = false
let started = false
const connectionStatus$ = new BehaviorSubject(0)
const activity$ = createObservable<Partial<Activity>>(async (subscriber) => {
try {
activitySubscriber = subscriber
const handleAcknowledgementOnce = once(async (): Promise<void> => {
connectionStatus$.next(2)
await Promise.resolve() // Webchat requires an extra tick to process the connection status change
})
// When resuming (shouldStart === false), transition straight to connected
if (!shouldStart || started) {
await handleAcknowledgementOnce()
return
}
started = true
logger.debug('--> Connection established.')
notifyTyping()
for await (const activity of client.startConversationStreaming()) {
delete activity.replyToId
if (!conversation && activity.conversation) {
conversation = activity.conversation
}
if (activity.conversation?.id) {
activeConversationId = activity.conversation.id
}
await handleAcknowledgementOnce()
notifyActivity(activity)
managed.actions.receivedFromCopilot(activity)
}
// If no activities received from bot, we should still acknowledge.
await handleAcknowledgementOnce()
} catch (error) {
throw managed.fail(error)
} finally {
managed.end()
}
})
const notifyActivity = (activity: Partial<Activity>) => {
const newActivity = {
...activity,
timestamp: new Date().toISOString(),
channelData: {
...activity.channelData,
'webchat:sequence-id': sequence,
},
}
sequence++
logger.debug(`Notify '${newActivity.type}' activity to WebChat:`, newActivity)
activitySubscriber?.next(newActivity)
}
const notifyTyping = () => {
if (!settings?.showTyping) {
return
}
const from = conversation
? { id: conversation.id, name: conversation.name }
: { id: 'agent', name: 'Agent' }
notifyActivity({ type: 'typing', from })
}
return {
connectionStatus$,
activity$,
get conversationId () {
return activeConversationId
},
postActivity (activity: Activity) {
try {
logger.info('--> Preparing to send activity to Copilot Studio ...')
if (!activity) {
throw new Error('Activity cannot be null.')
}
if (ended) {
throw new Error('Connection has been ended.')
}
if (!activitySubscriber) {
throw new Error('Activity subscriber is not initialized.')
}
const result = createObservable<string>(async (subscriber) => {
try {
logger.info('--> Sending activity to Copilot Studio ...')
const newActivity = Activity.fromObject({
...activity,
id: uuid(),
attachments: await processAttachments(activity)
})
notifyActivity(newActivity)
managed.actions.sentToWebChat(newActivity)
notifyTyping()
// Notify WebChat immediately that the message was sent
subscriber.next(newActivity.id!)
// Stream the agent's response, passing activeConversationId for URL routing
for await (const responseActivity of client.sendActivityStreaming(newActivity, activeConversationId)) {
if (!activeConversationId && responseActivity.conversation?.id) {
activeConversationId = responseActivity.conversation.id
}
notifyActivity(responseActivity)
managed.actions.receivedFromCopilot(responseActivity)
logger.info('<-- Activity received correctly from Copilot Studio.')
}
subscriber.complete()
} catch (error) {
logger.error('Error sending Activity to Copilot Studio:', error)
subscriber.error(error)
managed.fail(error)
} finally {
managed.end()
}
})
return result
} catch (error) {
throw managed.fail(error)
} finally {
managed.end()
}
},
end () {
logger.info('--> Ending connection between Copilot Studio and WebChat ...')
ended = true
connectionStatus$.complete()
if (activitySubscriber) {
activitySubscriber.complete()
activitySubscriber = undefined
}
// End the connection span
managed.end()
},
}
} catch (error) {
throw managed.fail(error)
} finally {
managed.end()
}
}
}
/**
* Processes activity attachments.
* @param activity The activity to process for attachments.
* @returns A promise that resolves to the activity with all attachments converted.
*/
async function processAttachments (activity: Activity): Promise<Attachment[]> {
if (activity.type !== 'message' || !activity.attachments?.length) {
return activity.attachments || []
}
const attachments: Attachment[] = []
for (const attachment of activity.attachments) {
const processed = await processBlobAttachment(attachment)
attachments.push(processed)
}
return attachments
}
/**
* Processes a blob attachment to convert its content URL to a data URL.
* @param attachment The attachment to process.
* @returns A promise that resolves to the processed attachment.
*/
async function processBlobAttachment (attachment: Attachment): Promise<Attachment> {
let newContentUrl = attachment.contentUrl
if (!newContentUrl?.startsWith('blob:')) {
return attachment
}
try {
const response = await fetch(newContentUrl)
if (!response.ok) {
throw new Error(`Failed to fetch blob URL: ${response.status} ${response.statusText}`)
}
const blob = await response.blob()
const arrayBuffer = await blob.arrayBuffer()
const base64 = arrayBufferToBase64(arrayBuffer)
newContentUrl = `data:${blob.type};base64,${base64}`
} catch (error) {
newContentUrl = attachment.contentUrl
logger.error('Error processing blob attachment:', newContentUrl, error)
}
return { ...attachment, contentUrl: newContentUrl }
}
/**
* Converts an ArrayBuffer to a base64 string.
* @param buffer The ArrayBuffer to convert.
* @returns The base64 encoded string.
*/
function arrayBufferToBase64 (buffer: ArrayBuffer): string {
// Node.js environment
const BufferClass = typeof globalThis.Buffer === 'function' ? globalThis.Buffer : undefined
if (BufferClass && typeof BufferClass.from === 'function') {
return BufferClass.from(buffer).toString('base64')
}
// Browser environment
let binary = ''
for (const byte of new Uint8Array(buffer)) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
/**
* Creates an RxJS Observable that wraps an asynchronous function execution.
*
* @typeParam T - The type of value that the observable will emit
* @param fn - An asynchronous function that receives a Subscriber and performs
* the desired async operation. The function should call subscriber.next()
* with results and subscriber.complete() when finished.
* @returns A new Observable that executes the provided function and emits its results
*
* @remarks
* This utility function provides a clean way to convert async/await patterns
* into Observable streams, enabling integration with reactive programming patterns
* used throughout the WebChat connection implementation.
*
* The created Observable handles promise resolution and rejection automatically,
* converting them to appropriate next/error signals for subscribers.
*
* @example
* ```typescript
* const dataObservable = createObservable<string>(async (subscriber) => {
* try {
* const result = await fetchData();
* subscriber.next(result);
* subscriber.complete();
* } catch (error) {
* subscriber.error(error);
* }
* });
* ```
*/
function createObservable<T> (fn: (subscriber: Subscriber<T>) => void): Observable<T> {
return new Observable<T>((subscriber: Subscriber<T>) => {
Promise.resolve(fn(subscriber)).catch((error) => subscriber.error(error))
})
}