@microsoft/agents-copilotstudio-client 1.1.0-alpha.9.g154c2c8a32 → 1.1.4-g8d884129e7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,8 @@
3
3
  * Licensed under the MIT License.
4
4
  */
5
5
 
6
+ import { createEventSource, EventSourceClient } from 'eventsource-client'
6
7
  import { ConnectionSettings } from './connectionSettings'
7
- import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
8
8
  import { getCopilotStudioConnectionUrl, getTokenAudience } from './powerPlatformEnvironment'
9
9
  import { Activity, ActivityTypes, ConversationAccount } from '@microsoft/agents-activity'
10
10
  import { ExecuteTurnRequest } from './executeTurnRequest'
@@ -14,11 +14,6 @@ import os from 'os'
14
14
 
15
15
  const logger = debug('copilot-studio:client')
16
16
 
17
- interface streamRead {
18
- done: boolean,
19
- value: string
20
- }
21
-
22
17
  /**
23
18
  * Client for interacting with Microsoft Copilot Studio services.
24
19
  * Provides functionality to start conversations and send messages to Copilot Studio bots.
@@ -33,8 +28,8 @@ export class CopilotStudioClient {
33
28
  private conversationId: string = ''
34
29
  /** The connection settings for the client. */
35
30
  private readonly settings: ConnectionSettings
36
- /** The Axios instance used for HTTP requests. */
37
- private readonly client: AxiosInstance
31
+ /** The authenticaton token. */
32
+ private readonly token: string
38
33
 
39
34
  /**
40
35
  * Returns the scope URL needed to connect to Copilot Studio from the connection settings.
@@ -51,79 +46,97 @@ export class CopilotStudioClient {
51
46
  */
52
47
  constructor (settings: ConnectionSettings, token: string) {
53
48
  this.settings = settings
54
- this.client = axios.create()
55
- this.client.defaults.headers.common.Authorization = `Bearer ${token}`
56
- this.client.defaults.headers.common['User-Agent'] = CopilotStudioClient.getProductInfo()
49
+ this.token = token
57
50
  }
58
51
 
59
- private async postRequestAsync (axiosConfig: AxiosRequestConfig): Promise<Activity[]> {
60
- const activities: Activity[] = []
61
-
62
- logger.debug(`>>> SEND TO ${axiosConfig.url}`)
63
-
64
- const response = await this.client(axiosConfig)
65
-
66
- if (this.settings.useExperimentalEndpoint && !this.settings.directConnectUrl?.trim()) {
67
- const islandExperimentalUrl = response.headers?.[CopilotStudioClient.islandExperimentalUrlHeaderKey]
68
- if (islandExperimentalUrl) {
69
- this.settings.directConnectUrl = islandExperimentalUrl
70
- logger.debug(`Island Experimental URL: ${islandExperimentalUrl}`)
71
- }
72
- }
73
-
74
- this.conversationId = response.headers?.[CopilotStudioClient.conversationIdHeaderKey] ?? ''
75
- if (this.conversationId) {
76
- logger.debug(`Conversation ID: ${this.conversationId}`)
77
- }
52
+ /**
53
+ * Streams activities from the Copilot Studio service using eventsource-client.
54
+ * @param url The connection URL for Copilot Studio.
55
+ * @param body Optional. The request body (for POST).
56
+ * @param method Optional. The HTTP method (default: POST).
57
+ * @returns An async generator yielding the Agent's Activities.
58
+ */
59
+ private async * postRequestAsync (url: string, body?: any, method: string = 'POST'): AsyncGenerator<Activity> {
60
+ logger.debug(`>>> SEND TO ${url}`)
78
61
 
79
- const sanitizedHeaders = { ...response.headers }
80
- delete sanitizedHeaders['Authorization']
81
- delete sanitizedHeaders[CopilotStudioClient.conversationIdHeaderKey]
82
- logger.debug('Headers received:', sanitizedHeaders)
62
+ const streamMap = new Map<string, { text: string, sequence: number }[]>()
83
63
 
84
- const stream = response.data
85
- const reader = stream.pipeThrough(new TextDecoderStream()).getReader()
86
- let result: string = ''
87
- const results: string[] = []
88
-
89
- const processEvents = async ({ done, value }: streamRead): Promise<string[]> => {
90
- if (done) {
91
- logger.debug('Stream complete')
92
- result += value
93
- results.push(result)
94
- return results
64
+ const eventSource: EventSourceClient = createEventSource({
65
+ url,
66
+ headers: {
67
+ Authorization: `Bearer ${this.token}`,
68
+ 'User-Agent': CopilotStudioClient.getProductInfo(),
69
+ 'Content-Type': 'application/json',
70
+ Accept: 'text/event-stream'
71
+ },
72
+ body: body ? JSON.stringify(body) : undefined,
73
+ method,
74
+ fetch: async (url, init) => {
75
+ const response = await fetch(url, init)
76
+ this.processResponseHeaders(response.headers)
77
+ return response
95
78
  }
96
- logger.info('Agent is typing ...')
97
- result += value
79
+ })
98
80
 
99
- return await processEvents(await reader.read())
100
- }
81
+ try {
82
+ for await (const { data, event } of eventSource) {
83
+ if (data && event === 'activity') {
84
+ try {
85
+ const activity = Activity.fromJson(data)
101
86
 
102
- const events: string[] = await reader.read().then(processEvents)
103
-
104
- events.forEach(event => {
105
- const values: string[] = event.toString().split('\n')
106
- const validEvents = values.filter(e => e.substring(0, 4) === 'data' && e !== 'data: end\r')
107
- validEvents.forEach(ve => {
108
- try {
109
- const act = Activity.fromJson(ve.substring(5, ve.length))
110
- if (act.type === ActivityTypes.Message) {
111
- activities.push(act)
112
- if (!this.conversationId.trim()) {
113
- // Did not get it from the header.
114
- this.conversationId = act.conversation?.id ?? ''
115
- logger.debug(`Conversation ID: ${this.conversationId}`)
87
+ // check to see if this activity is part of the streamed response, in which case we need to accumulate the text
88
+ const streamingEntity = activity.entities?.find(e => e.type === 'streaminfo' && e.streamType === 'streaming')
89
+ switch (activity.type) {
90
+ case ActivityTypes.Message:
91
+ if (!this.conversationId.trim()) { // Did not get it from the header.
92
+ this.conversationId = activity.conversation?.id ?? ''
93
+ logger.debug(`Conversation ID: ${this.conversationId}`)
94
+ }
95
+ yield activity
96
+ break
97
+ case ActivityTypes.Typing:
98
+ logger.debug(`Activity type: ${activity.type}`)
99
+ // Accumulate the text as it comes in from the stream.
100
+ // This also accounts for the "old style" of streaming where the stream info is in channelData.
101
+ if (streamingEntity || activity.channelData?.streamType === 'streaming') {
102
+ const text = activity.text ?? ''
103
+ const id = (streamingEntity?.streamId ?? activity.channelData?.streamId)
104
+ const sequence = (streamingEntity?.streamSequence ?? activity.channelData?.streamSequence)
105
+ // Accumulate the text chunks based on stream ID and sequence number.
106
+ if (id && sequence) {
107
+ if (streamMap.has(id)) {
108
+ const existing = streamMap.get(id)!
109
+ existing.push({ text, sequence })
110
+ streamMap.set(id, existing)
111
+ } else {
112
+ streamMap.set(id, [{ text, sequence }])
113
+ }
114
+ activity.text = streamMap.get(id)?.sort((a, b) => a.sequence - b.sequence).map(item => item.text).join('') || ''
115
+ }
116
+ }
117
+ yield activity
118
+ break
119
+ default:
120
+ logger.debug(`Activity type: ${activity.type}`)
121
+ yield activity
122
+ break
116
123
  }
117
- } else {
118
- logger.debug(`Activity type: ${act.type}`)
124
+ } catch (error) {
125
+ logger.error('Failed to parse activity:', error)
119
126
  }
120
- } catch (e) {
121
- logger.error('Error: ', e)
122
- throw e
127
+ } else if (event === 'end') {
128
+ logger.debug('Stream complete')
129
+ break
123
130
  }
124
- })
125
- })
126
- return activities
131
+
132
+ if (eventSource.readyState === 'closed') {
133
+ logger.debug('Connection closed')
134
+ break
135
+ }
136
+ }
137
+ } finally {
138
+ eventSource.close()
139
+ }
127
140
  }
128
141
 
129
142
  /**
@@ -146,43 +159,83 @@ export class CopilotStudioClient {
146
159
  return userAgent
147
160
  }
148
161
 
162
+ private processResponseHeaders (responseHeaders: Headers): void {
163
+ if (this.settings.useExperimentalEndpoint && !this.settings.directConnectUrl?.trim()) {
164
+ const islandExperimentalUrl = responseHeaders?.get(CopilotStudioClient.islandExperimentalUrlHeaderKey)
165
+ if (islandExperimentalUrl) {
166
+ this.settings.directConnectUrl = islandExperimentalUrl
167
+ logger.debug(`Island Experimental URL: ${islandExperimentalUrl}`)
168
+ }
169
+ }
170
+
171
+ this.conversationId = responseHeaders?.get(CopilotStudioClient.conversationIdHeaderKey) ?? ''
172
+ if (this.conversationId) {
173
+ logger.debug(`Conversation ID: ${this.conversationId}`)
174
+ }
175
+
176
+ const sanitizedHeaders = new Headers()
177
+ responseHeaders.forEach((value, key) => {
178
+ if (key.toLowerCase() !== 'authorization' && key.toLowerCase() !== CopilotStudioClient.conversationIdHeaderKey.toLowerCase()) {
179
+ sanitizedHeaders.set(key, value)
180
+ }
181
+ })
182
+ logger.debug('Headers received:', sanitizedHeaders)
183
+ }
184
+
149
185
  /**
150
186
  * Starts a new conversation with the Copilot Studio service.
151
187
  * @param emitStartConversationEvent Whether to emit a start conversation event. Defaults to true.
152
- * @returns A promise that resolves to the initial activity of the conversation.
188
+ * @returns An async generator yielding the Agent's Activities.
153
189
  */
154
- public async startConversationAsync (emitStartConversationEvent: boolean = true): Promise<Activity> {
190
+ public async * startConversationStreaming (emitStartConversationEvent: boolean = true): AsyncGenerator<Activity> {
155
191
  const uriStart: string = getCopilotStudioConnectionUrl(this.settings)
156
192
  const body = { emitStartConversationEvent }
157
193
 
158
- const config: AxiosRequestConfig = {
159
- method: 'post',
160
- url: uriStart,
161
- headers: {
162
- Accept: 'text/event-stream',
163
- 'Content-Type': 'application/json',
164
- },
165
- data: body,
166
- responseType: 'stream',
167
- adapter: 'fetch'
168
- }
169
-
170
194
  logger.info('Starting conversation ...')
171
- const values = await this.postRequestAsync(config)
172
- const act = values[0]
173
- logger.info(`Conversation '${act.conversation?.id}' started. Received ${values.length} activities.`, values)
174
- return act
195
+
196
+ yield * this.postRequestAsync(uriStart, body, 'POST')
197
+ }
198
+
199
+ /**
200
+ * Sends an activity to the Copilot Studio service and retrieves the response activities.
201
+ * @param activity The activity to send.
202
+ * @param conversationId The ID of the conversation. Defaults to the current conversation ID.
203
+ * @returns An async generator yielding the Agent's Activities.
204
+ */
205
+ public async * sendActivityStreaming (activity: Activity, conversationId: string = this.conversationId) : AsyncGenerator<Activity> {
206
+ const localConversationId = activity.conversation?.id ?? conversationId
207
+ const uriExecute = getCopilotStudioConnectionUrl(this.settings, localConversationId)
208
+ const qbody: ExecuteTurnRequest = new ExecuteTurnRequest(activity)
209
+
210
+ logger.info('Sending activity...', activity)
211
+ yield * this.postRequestAsync(uriExecute, qbody, 'POST')
212
+ }
213
+
214
+ /**
215
+ * Starts a new conversation with the Copilot Studio service.
216
+ * @param emitStartConversationEvent Whether to emit a start conversation event. Defaults to true.
217
+ * @returns A promise yielding an array of activities.
218
+ * @deprecated Use startConversationStreaming instead.
219
+ */
220
+ public async startConversationAsync (emitStartConversationEvent: boolean = true): Promise<Activity[]> {
221
+ const result: Activity[] = []
222
+ for await (const value of this.startConversationStreaming(emitStartConversationEvent)) {
223
+ result.push(value)
224
+ }
225
+ return result
175
226
  }
176
227
 
177
228
  /**
178
229
  * Sends a question to the Copilot Studio service and retrieves the response activities.
179
230
  * @param question The question to ask.
180
231
  * @param conversationId The ID of the conversation. Defaults to the current conversation ID.
181
- * @returns A promise that resolves to an array of activities containing the responses.
232
+ * @returns A promise yielding an array of activities.
233
+ * @deprecated Use sendActivityStreaming instead.
182
234
  */
183
- public async askQuestionAsync (question: string, conversationId: string = this.conversationId) {
235
+ public async askQuestionAsync (question: string, conversationId?: string) : Promise<Activity[]> {
236
+ const localConversationId = conversationId?.trim() ? conversationId : this.conversationId
184
237
  const conversationAccount: ConversationAccount = {
185
- id: conversationId
238
+ id: localConversationId
186
239
  }
187
240
  const activityObj = {
188
241
  type: 'message',
@@ -191,34 +244,25 @@ export class CopilotStudioClient {
191
244
  }
192
245
  const activity = Activity.fromObject(activityObj)
193
246
 
194
- return this.sendActivity(activity)
247
+ const result: Activity[] = []
248
+ for await (const value of this.sendActivityStreaming(activity, conversationId)) {
249
+ result.push(value)
250
+ }
251
+ return result
195
252
  }
196
253
 
197
254
  /**
198
255
  * Sends an activity to the Copilot Studio service and retrieves the response activities.
199
256
  * @param activity The activity to send.
200
257
  * @param conversationId The ID of the conversation. Defaults to the current conversation ID.
201
- * @returns A promise that resolves to an array of activities containing the responses.
258
+ * @returns A promise yielding an array of activities.
259
+ * @deprecated Use sendActivityStreaming instead.
202
260
  */
203
- public async sendActivity (activity: Activity, conversationId: string = this.conversationId) {
204
- const localConversationId = activity.conversation?.id ?? conversationId
205
- const uriExecute = getCopilotStudioConnectionUrl(this.settings, localConversationId)
206
- const qbody: ExecuteTurnRequest = new ExecuteTurnRequest(activity)
207
-
208
- const config: AxiosRequestConfig = {
209
- method: 'post',
210
- url: uriExecute,
211
- headers: {
212
- Accept: 'text/event-stream',
213
- 'Content-Type': 'application/json',
214
- },
215
- data: qbody,
216
- responseType: 'stream',
217
- adapter: 'fetch'
261
+ public async sendActivity (activity: Activity, conversationId: string = this.conversationId) : Promise<Activity[]> {
262
+ const result: Activity[] = []
263
+ for await (const value of this.sendActivityStreaming(activity, conversationId)) {
264
+ result.push(value)
218
265
  }
219
- logger.info('Sending activity...', activity)
220
- const values = await this.postRequestAsync(config)
221
- logger.info(`Received ${values.length} activities.`, values)
222
- return values
266
+ return result
223
267
  }
224
268
  }
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { v4 as uuid } from 'uuid'
7
7
 
8
- import { Activity, ConversationAccount } from '@microsoft/agents-activity'
8
+ import { Activity, Attachment, ConversationAccount } from '@microsoft/agents-activity'
9
9
  import { Observable, BehaviorSubject, type Subscriber } from 'rxjs'
10
10
 
11
11
  import { CopilotStudioClient } from './copilotStudioClient'
@@ -201,7 +201,7 @@ export class CopilotStudioWebChat {
201
201
  static createConnection (
202
202
  client: CopilotStudioClient,
203
203
  settings?: CopilotStudioWebChatSettings
204
- ):CopilotStudioWebChatConnection {
204
+ ): CopilotStudioWebChatConnection {
205
205
  logger.info('--> Creating connection between Copilot Studio and WebChat ...')
206
206
  let sequence = 0
207
207
  let activitySubscriber: Subscriber<Partial<Activity>> | undefined
@@ -211,17 +211,25 @@ export class CopilotStudioWebChat {
211
211
  const activity$ = createObservable<Partial<Activity>>(async (subscriber) => {
212
212
  activitySubscriber = subscriber
213
213
 
214
- if (connectionStatus$.value < 2) {
214
+ const handleAcknowledgementOnce = async (): Promise<void> => {
215
215
  connectionStatus$.next(2)
216
- return
216
+ await 0 // Webchat requires an extra tick to process the connection status change
217
217
  }
218
218
 
219
219
  logger.debug('--> Connection established.')
220
220
  notifyTyping()
221
- const activity = await client.startConversationAsync()
222
- conversation = activity.conversation
223
- sequence = 0
224
- notifyActivity(activity)
221
+
222
+ if (connectionStatus$.value < 2) {
223
+ for await (const activity of client.startConversationStreaming()) {
224
+ delete activity.replyToId
225
+ if (!conversation && activity.conversation) {
226
+ conversation = activity.conversation
227
+ await handleAcknowledgementOnce()
228
+ }
229
+
230
+ notifyActivity(activity)
231
+ }
232
+ }
225
233
  })
226
234
 
227
235
  const notifyActivity = (activity: Partial<Activity>) => {
@@ -230,9 +238,10 @@ export class CopilotStudioWebChat {
230
238
  timestamp: new Date().toISOString(),
231
239
  channelData: {
232
240
  ...activity.channelData,
233
- 'webchat:sequence-id': sequence++,
241
+ 'webchat:sequence-id': sequence,
234
242
  },
235
243
  }
244
+ sequence++
236
245
  logger.debug(`Notify '${newActivity.type}' activity to WebChat:`, newActivity)
237
246
  activitySubscriber?.next(newActivity)
238
247
  }
@@ -264,22 +273,26 @@ export class CopilotStudioWebChat {
264
273
 
265
274
  return createObservable<string>(async (subscriber) => {
266
275
  try {
267
- const id = uuid()
268
-
269
276
  logger.info('--> Sending activity to Copilot Studio ...')
277
+ const newActivity = Activity.fromObject({
278
+ ...activity,
279
+ id: uuid(),
280
+ attachments: await processAttachments(activity)
281
+ })
270
282
 
271
- notifyActivity({ ...activity, id })
283
+ notifyActivity(newActivity)
272
284
  notifyTyping()
273
285
 
274
- const activities = await client.sendActivity(activity)
286
+ // Notify WebChat immediately that the message was sent
287
+ subscriber.next(newActivity.id!)
275
288
 
276
- for (const responseActivity of activities) {
289
+ // Stream the agent's response, but don't block the UI
290
+ for await (const responseActivity of client.sendActivityStreaming(newActivity)) {
277
291
  notifyActivity(responseActivity)
292
+ logger.info('<-- Activity received correctly from Copilot Studio.')
278
293
  }
279
294
 
280
- subscriber.next(id)
281
295
  subscriber.complete()
282
- logger.info('--> Activity received correctly from Copilot Studio.')
283
296
  } catch (error) {
284
297
  logger.error('Error sending Activity to Copilot Studio:', error)
285
298
  subscriber.error(error)
@@ -299,6 +312,74 @@ export class CopilotStudioWebChat {
299
312
  }
300
313
  }
301
314
 
315
+ /**
316
+ * Processes activity attachments.
317
+ * @param activity The activity to process for attachments.
318
+ * @returns A promise that resolves to the activity with all attachments converted.
319
+ */
320
+ async function processAttachments (activity: Activity): Promise<Attachment[]> {
321
+ if (activity.type !== 'message' || !activity.attachments?.length) {
322
+ return activity.attachments || []
323
+ }
324
+
325
+ const attachments: Attachment[] = []
326
+ for (const attachment of activity.attachments) {
327
+ const processed = await processBlobAttachment(attachment)
328
+ attachments.push(processed)
329
+ }
330
+
331
+ return attachments
332
+ }
333
+
334
+ /**
335
+ * Processes a blob attachment to convert its content URL to a data URL.
336
+ * @param attachment The attachment to process.
337
+ * @returns A promise that resolves to the processed attachment.
338
+ */
339
+ async function processBlobAttachment (attachment: Attachment): Promise<Attachment> {
340
+ let newContentUrl = attachment.contentUrl
341
+ if (!newContentUrl?.startsWith('blob:')) {
342
+ return attachment
343
+ }
344
+
345
+ try {
346
+ const response = await fetch(newContentUrl)
347
+ if (!response.ok) {
348
+ throw new Error(`Failed to fetch blob URL: ${response.status} ${response.statusText}`)
349
+ }
350
+
351
+ const blob = await response.blob()
352
+ const arrayBuffer = await blob.arrayBuffer()
353
+ const base64 = arrayBufferToBase64(arrayBuffer)
354
+ newContentUrl = `data:${blob.type};base64,${base64}`
355
+ } catch (error) {
356
+ newContentUrl = attachment.contentUrl
357
+ logger.error('Error processing blob attachment:', newContentUrl, error)
358
+ }
359
+
360
+ return { ...attachment, contentUrl: newContentUrl }
361
+ }
362
+
363
+ /**
364
+ * Converts an ArrayBuffer to a base64 string.
365
+ * @param buffer The ArrayBuffer to convert.
366
+ * @returns The base64 encoded string.
367
+ */
368
+ function arrayBufferToBase64 (buffer: ArrayBuffer): string {
369
+ // Node.js environment
370
+ const BufferClass = typeof globalThis.Buffer === 'function' ? globalThis.Buffer : undefined
371
+ if (BufferClass && typeof BufferClass.from === 'function') {
372
+ return BufferClass.from(buffer).toString('base64')
373
+ }
374
+
375
+ // Browser environment
376
+ let binary = ''
377
+ for (const byte of new Uint8Array(buffer)) {
378
+ binary += String.fromCharCode(byte)
379
+ }
380
+ return btoa(binary)
381
+ }
382
+
302
383
  /**
303
384
  * Creates an RxJS Observable that wraps an asynchronous function execution.
304
385
  *