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

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,70 @@ 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
- }
78
-
79
- const sanitizedHeaders = { ...response.headers }
80
- delete sanitizedHeaders['Authorization']
81
- delete sanitizedHeaders[CopilotStudioClient.conversationIdHeaderKey]
82
- logger.debug('Headers received:', sanitizedHeaders)
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}`)
83
61
 
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
62
+ const eventSource: EventSourceClient = createEventSource({
63
+ url,
64
+ headers: {
65
+ Authorization: `Bearer ${this.token}`,
66
+ 'User-Agent': CopilotStudioClient.getProductInfo(),
67
+ 'Content-Type': 'application/json',
68
+ Accept: 'text/event-stream'
69
+ },
70
+ body: body ? JSON.stringify(body) : undefined,
71
+ method,
72
+ fetch: async (url, init) => {
73
+ const response = await fetch(url, init)
74
+ this.processResponseHeaders(response.headers)
75
+ return response
95
76
  }
96
- logger.info('Agent is typing ...')
97
- result += value
98
-
99
- return await processEvents(await reader.read())
100
- }
77
+ })
101
78
 
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}`)
79
+ try {
80
+ for await (const { data, event } of eventSource) {
81
+ if (data && event === 'activity') {
82
+ try {
83
+ const activity = Activity.fromJson(data)
84
+ switch (activity.type) {
85
+ case ActivityTypes.Message:
86
+ if (!this.conversationId.trim()) { // Did not get it from the header.
87
+ this.conversationId = activity.conversation?.id ?? ''
88
+ logger.debug(`Conversation ID: ${this.conversationId}`)
89
+ }
90
+ yield activity
91
+ break
92
+ default:
93
+ logger.debug(`Activity type: ${activity.type}`)
94
+ yield activity
95
+ break
116
96
  }
117
- } else {
118
- logger.debug(`Activity type: ${act.type}`)
97
+ } catch (error) {
98
+ logger.error('Failed to parse activity:', error)
119
99
  }
120
- } catch (e) {
121
- logger.error('Error: ', e)
122
- throw e
100
+ } else if (event === 'end') {
101
+ logger.debug('Stream complete')
102
+ break
123
103
  }
124
- })
125
- })
126
- return activities
104
+
105
+ if (eventSource.readyState === 'closed') {
106
+ logger.debug('Connection closed')
107
+ break
108
+ }
109
+ }
110
+ } finally {
111
+ eventSource.close()
112
+ }
127
113
  }
128
114
 
129
115
  /**
@@ -146,43 +132,83 @@ export class CopilotStudioClient {
146
132
  return userAgent
147
133
  }
148
134
 
135
+ private processResponseHeaders (responseHeaders: Headers): void {
136
+ if (this.settings.useExperimentalEndpoint && !this.settings.directConnectUrl?.trim()) {
137
+ const islandExperimentalUrl = responseHeaders?.get(CopilotStudioClient.islandExperimentalUrlHeaderKey)
138
+ if (islandExperimentalUrl) {
139
+ this.settings.directConnectUrl = islandExperimentalUrl
140
+ logger.debug(`Island Experimental URL: ${islandExperimentalUrl}`)
141
+ }
142
+ }
143
+
144
+ this.conversationId = responseHeaders?.get(CopilotStudioClient.conversationIdHeaderKey) ?? ''
145
+ if (this.conversationId) {
146
+ logger.debug(`Conversation ID: ${this.conversationId}`)
147
+ }
148
+
149
+ const sanitizedHeaders = new Headers()
150
+ responseHeaders.forEach((value, key) => {
151
+ if (key.toLowerCase() !== 'authorization' && key.toLowerCase() !== CopilotStudioClient.conversationIdHeaderKey.toLowerCase()) {
152
+ sanitizedHeaders.set(key, value)
153
+ }
154
+ })
155
+ logger.debug('Headers received:', sanitizedHeaders)
156
+ }
157
+
149
158
  /**
150
159
  * Starts a new conversation with the Copilot Studio service.
151
160
  * @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.
161
+ * @returns An async generator yielding the Agent's Activities.
153
162
  */
154
- public async startConversationAsync (emitStartConversationEvent: boolean = true): Promise<Activity> {
163
+ public async * startConversationStreaming (emitStartConversationEvent: boolean = true): AsyncGenerator<Activity> {
155
164
  const uriStart: string = getCopilotStudioConnectionUrl(this.settings)
156
165
  const body = { emitStartConversationEvent }
157
166
 
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
167
  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
168
+
169
+ yield * this.postRequestAsync(uriStart, body, 'POST')
170
+ }
171
+
172
+ /**
173
+ * Sends an activity to the Copilot Studio service and retrieves the response activities.
174
+ * @param activity The activity to send.
175
+ * @param conversationId The ID of the conversation. Defaults to the current conversation ID.
176
+ * @returns An async generator yielding the Agent's Activities.
177
+ */
178
+ public async * sendActivityStreaming (activity: Activity, conversationId: string = this.conversationId) : AsyncGenerator<Activity> {
179
+ const localConversationId = activity.conversation?.id ?? conversationId
180
+ const uriExecute = getCopilotStudioConnectionUrl(this.settings, localConversationId)
181
+ const qbody: ExecuteTurnRequest = new ExecuteTurnRequest(activity)
182
+
183
+ logger.info('Sending activity...', activity)
184
+ yield * this.postRequestAsync(uriExecute, qbody, 'POST')
185
+ }
186
+
187
+ /**
188
+ * Starts a new conversation with the Copilot Studio service.
189
+ * @param emitStartConversationEvent Whether to emit a start conversation event. Defaults to true.
190
+ * @returns A promise yielding an array of activities.
191
+ * @deprecated Use startConversationStreaming instead.
192
+ */
193
+ public async startConversationAsync (emitStartConversationEvent: boolean = true): Promise<Activity[]> {
194
+ const result: Activity[] = []
195
+ for await (const value of this.startConversationStreaming(emitStartConversationEvent)) {
196
+ result.push(value)
197
+ }
198
+ return result
175
199
  }
176
200
 
177
201
  /**
178
202
  * Sends a question to the Copilot Studio service and retrieves the response activities.
179
203
  * @param question The question to ask.
180
204
  * @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.
205
+ * @returns A promise yielding an array of activities.
206
+ * @deprecated Use sendActivityStreaming instead.
182
207
  */
183
- public async askQuestionAsync (question: string, conversationId: string = this.conversationId) {
208
+ public async askQuestionAsync (question: string, conversationId?: string) : Promise<Activity[]> {
209
+ const localConversationId = conversationId?.trim() ? conversationId : this.conversationId
184
210
  const conversationAccount: ConversationAccount = {
185
- id: conversationId
211
+ id: localConversationId
186
212
  }
187
213
  const activityObj = {
188
214
  type: 'message',
@@ -191,34 +217,25 @@ export class CopilotStudioClient {
191
217
  }
192
218
  const activity = Activity.fromObject(activityObj)
193
219
 
194
- return this.sendActivity(activity)
220
+ const result: Activity[] = []
221
+ for await (const value of this.sendActivityStreaming(activity, conversationId)) {
222
+ result.push(value)
223
+ }
224
+ return result
195
225
  }
196
226
 
197
227
  /**
198
228
  * Sends an activity to the Copilot Studio service and retrieves the response activities.
199
229
  * @param activity The activity to send.
200
230
  * @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.
231
+ * @returns A promise yielding an array of activities.
232
+ * @deprecated Use sendActivityStreaming instead.
202
233
  */
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'
234
+ public async sendActivity (activity: Activity, conversationId: string = this.conversationId) : Promise<Activity[]> {
235
+ const result: Activity[] = []
236
+ for await (const value of this.sendActivityStreaming(activity, conversationId)) {
237
+ result.push(value)
218
238
  }
219
- logger.info('Sending activity...', activity)
220
- const values = await this.postRequestAsync(config)
221
- logger.info(`Received ${values.length} activities.`, values)
222
- return values
239
+ return result
223
240
  }
224
241
  }
@@ -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'
@@ -218,10 +218,12 @@ export class CopilotStudioWebChat {
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
+ for await (const activity of client.startConversationStreaming()) {
223
+ delete activity.replyToId
224
+ conversation = activity.conversation
225
+ notifyActivity(activity)
226
+ }
225
227
  })
226
228
 
227
229
  const notifyActivity = (activity: Partial<Activity>) => {
@@ -230,9 +232,10 @@ export class CopilotStudioWebChat {
230
232
  timestamp: new Date().toISOString(),
231
233
  channelData: {
232
234
  ...activity.channelData,
233
- 'webchat:sequence-id': sequence++,
235
+ 'webchat:sequence-id': sequence,
234
236
  },
235
237
  }
238
+ sequence++
236
239
  logger.debug(`Notify '${newActivity.type}' activity to WebChat:`, newActivity)
237
240
  activitySubscriber?.next(newActivity)
238
241
  }
@@ -264,22 +267,26 @@ export class CopilotStudioWebChat {
264
267
 
265
268
  return createObservable<string>(async (subscriber) => {
266
269
  try {
267
- const id = uuid()
268
-
269
270
  logger.info('--> Sending activity to Copilot Studio ...')
271
+ const newActivity = Activity.fromObject({
272
+ ...activity,
273
+ id: uuid(),
274
+ attachments: await processAttachments(activity)
275
+ })
270
276
 
271
- notifyActivity({ ...activity, id })
277
+ notifyActivity(newActivity)
272
278
  notifyTyping()
273
279
 
274
- const activities = await client.sendActivity(activity)
280
+ // Notify WebChat immediately that the message was sent
281
+ subscriber.next(newActivity.id!)
275
282
 
276
- for (const responseActivity of activities) {
283
+ // Stream the agent's response, but don't block the UI
284
+ for await (const responseActivity of client.sendActivityStreaming(newActivity)) {
277
285
  notifyActivity(responseActivity)
286
+ logger.info('<-- Activity received correctly from Copilot Studio.')
278
287
  }
279
288
 
280
- subscriber.next(id)
281
289
  subscriber.complete()
282
- logger.info('--> Activity received correctly from Copilot Studio.')
283
290
  } catch (error) {
284
291
  logger.error('Error sending Activity to Copilot Studio:', error)
285
292
  subscriber.error(error)
@@ -299,6 +306,74 @@ export class CopilotStudioWebChat {
299
306
  }
300
307
  }
301
308
 
309
+ /**
310
+ * Processes activity attachments.
311
+ * @param activity The activity to process for attachments.
312
+ * @returns A promise that resolves to the activity with all attachments converted.
313
+ */
314
+ async function processAttachments (activity: Activity): Promise<Attachment[]> {
315
+ if (activity.type !== 'message' || !activity.attachments?.length) {
316
+ return activity.attachments || []
317
+ }
318
+
319
+ const attachments: Attachment[] = []
320
+ for (const attachment of activity.attachments) {
321
+ const processed = await processBlobAttachment(attachment)
322
+ attachments.push(processed)
323
+ }
324
+
325
+ return attachments
326
+ }
327
+
328
+ /**
329
+ * Processes a blob attachment to convert its content URL to a data URL.
330
+ * @param attachment The attachment to process.
331
+ * @returns A promise that resolves to the processed attachment.
332
+ */
333
+ async function processBlobAttachment (attachment: Attachment): Promise<Attachment> {
334
+ let newContentUrl = attachment.contentUrl
335
+ if (!newContentUrl?.startsWith('blob:')) {
336
+ return attachment
337
+ }
338
+
339
+ try {
340
+ const response = await fetch(newContentUrl)
341
+ if (!response.ok) {
342
+ throw new Error(`Failed to fetch blob URL: ${response.status} ${response.statusText}`)
343
+ }
344
+
345
+ const blob = await response.blob()
346
+ const arrayBuffer = await blob.arrayBuffer()
347
+ const base64 = arrayBufferToBase64(arrayBuffer)
348
+ newContentUrl = `data:${blob.type};base64,${base64}`
349
+ } catch (error) {
350
+ newContentUrl = attachment.contentUrl
351
+ logger.error('Error processing blob attachment:', newContentUrl, error)
352
+ }
353
+
354
+ return { ...attachment, contentUrl: newContentUrl }
355
+ }
356
+
357
+ /**
358
+ * Converts an ArrayBuffer to a base64 string.
359
+ * @param buffer The ArrayBuffer to convert.
360
+ * @returns The base64 encoded string.
361
+ */
362
+ function arrayBufferToBase64 (buffer: ArrayBuffer): string {
363
+ // Node.js environment
364
+ const BufferClass = typeof globalThis.Buffer === 'function' ? globalThis.Buffer : undefined
365
+ if (BufferClass && typeof BufferClass.from === 'function') {
366
+ return BufferClass.from(buffer).toString('base64')
367
+ }
368
+
369
+ // Browser environment
370
+ let binary = ''
371
+ for (const byte of new Uint8Array(buffer)) {
372
+ binary += String.fromCharCode(byte)
373
+ }
374
+ return btoa(binary)
375
+ }
376
+
302
377
  /**
303
378
  * Creates an RxJS Observable that wraps an asynchronous function execution.
304
379
  *