@webex/internal-plugin-llm 3.12.0-task-refactor.1 → 3.12.0-webex-services-ready.2
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.
- package/README.md +83 -12
- package/dist/constants.js +9 -1
- package/dist/constants.js.map +1 -1
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -1
- package/dist/llm.js +486 -51
- package/dist/llm.js.map +1 -1
- package/dist/llm.types.js +6 -0
- package/dist/llm.types.js.map +1 -1
- package/package.json +6 -6
- package/src/constants.ts +13 -0
- package/src/index.ts +3 -0
- package/src/llm.ts +502 -34
- package/src/llm.types.ts +66 -6
- package/test/unit/spec/llm.js +612 -52
package/src/llm.ts
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import Mercury from '@webex/internal-plugin-mercury';
|
|
4
4
|
|
|
5
|
-
import {LLM} from './constants';
|
|
6
5
|
// eslint-disable-next-line no-unused-vars
|
|
7
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
LLM,
|
|
8
|
+
DATA_CHANNEL_WITH_JWT_TOKEN,
|
|
9
|
+
AWARE_DATA_CHANNEL,
|
|
10
|
+
SUBSCRIPTION_AWARE_SUBCHANNELS_PARAM,
|
|
11
|
+
LLM_DEFAULT_SESSION,
|
|
12
|
+
} from './constants';
|
|
13
|
+
import {ILLMChannel, DataChannelTokenType} from './llm.types';
|
|
8
14
|
|
|
9
15
|
export const config = {
|
|
10
16
|
llm: {
|
|
@@ -42,90 +48,552 @@ export const config = {
|
|
|
42
48
|
*/
|
|
43
49
|
export default class LLMChannel extends (Mercury as any) implements ILLMChannel {
|
|
44
50
|
namespace = LLM;
|
|
45
|
-
|
|
51
|
+
defaultSessionId = LLM_DEFAULT_SESSION;
|
|
46
52
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* @
|
|
50
|
-
* @
|
|
53
|
+
* Map to store connection-specific data for multiple LLM connections
|
|
54
|
+
* Key: sessionId
|
|
55
|
+
* @private
|
|
56
|
+
* @type {Map<string, {webSocketUrl?: string; binding?: string; locusUrl?: string; datachannelUrl?: string}>}
|
|
51
57
|
*/
|
|
58
|
+
private connections: Map<
|
|
59
|
+
string,
|
|
60
|
+
{
|
|
61
|
+
webSocketUrl?: string;
|
|
62
|
+
binding?: string;
|
|
63
|
+
locusUrl?: string;
|
|
64
|
+
datachannelUrl?: string;
|
|
65
|
+
ownerMeetingId?: string;
|
|
66
|
+
refreshHandler?: () => Promise<{
|
|
67
|
+
body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
|
|
68
|
+
}>;
|
|
69
|
+
}
|
|
70
|
+
> = new Map();
|
|
52
71
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
private datachannelUrl?: string;
|
|
72
|
+
// Session-keyed token cache is intentionally decoupled from connection state.
|
|
73
|
+
// Disconnecting a socket session must not implicitly wipe token cache.
|
|
74
|
+
private datachannelTokens: Record<string, string | undefined> = {
|
|
75
|
+
[DataChannelTokenType.Default]: undefined,
|
|
76
|
+
[DataChannelTokenType.PracticeSession]: undefined,
|
|
77
|
+
};
|
|
60
78
|
|
|
61
79
|
/**
|
|
62
80
|
* Register to the websocket
|
|
63
81
|
* @param {string} llmSocketUrl
|
|
82
|
+
* @param {string} datachannelToken
|
|
83
|
+
* @param {string} sessionId - Connection identifier
|
|
64
84
|
* @returns {Promise<void>}
|
|
65
85
|
*/
|
|
66
|
-
private register = (
|
|
67
|
-
|
|
86
|
+
private register = async (
|
|
87
|
+
llmSocketUrl: string,
|
|
88
|
+
datachannelToken?: string,
|
|
89
|
+
sessionId: string = LLM_DEFAULT_SESSION
|
|
90
|
+
): Promise<void> => {
|
|
91
|
+
const isDataChannelTokenEnabled = await this.isDataChannelTokenEnabled();
|
|
92
|
+
|
|
93
|
+
return this.request({
|
|
68
94
|
method: 'POST',
|
|
69
95
|
url: llmSocketUrl,
|
|
70
96
|
body: {deviceUrl: this.webex.internal.device.url},
|
|
97
|
+
headers:
|
|
98
|
+
isDataChannelTokenEnabled && datachannelToken
|
|
99
|
+
? {'Data-Channel-Auth-Token': datachannelToken}
|
|
100
|
+
: {},
|
|
71
101
|
})
|
|
72
102
|
.then((res: {body: {webSocketUrl: string; binding: string}}) => {
|
|
73
|
-
|
|
74
|
-
|
|
103
|
+
// Get or create connection data
|
|
104
|
+
const sessionData = this.connections.get(sessionId) || {};
|
|
105
|
+
sessionData.webSocketUrl = res.body.webSocketUrl;
|
|
106
|
+
sessionData.binding = res.body.binding;
|
|
107
|
+
this.connections.set(sessionId, sessionData);
|
|
75
108
|
})
|
|
76
109
|
.catch((error: any) => {
|
|
77
|
-
this.logger.error(`Error connecting to websocket: ${error}`);
|
|
110
|
+
this.logger.error(`Error connecting to websocket for ${sessionId}: ${error}`);
|
|
78
111
|
throw error;
|
|
79
112
|
});
|
|
113
|
+
};
|
|
80
114
|
|
|
81
115
|
/**
|
|
82
116
|
* Register and connect to the websocket
|
|
83
117
|
* @param {string} locusUrl
|
|
84
118
|
* @param {string} datachannelUrl
|
|
119
|
+
* @param {string} datachannelToken
|
|
120
|
+
* @param {string} sessionId - Connection identifier
|
|
85
121
|
* @returns {Promise<void>}
|
|
86
122
|
*/
|
|
87
|
-
public registerAndConnect = (
|
|
88
|
-
|
|
123
|
+
public registerAndConnect = (
|
|
124
|
+
locusUrl: string,
|
|
125
|
+
datachannelUrl: string,
|
|
126
|
+
datachannelToken?: string,
|
|
127
|
+
sessionId: string = LLM_DEFAULT_SESSION
|
|
128
|
+
): Promise<void> => {
|
|
129
|
+
// Pre-populate locusUrl and datachannelUrl before register() fires the
|
|
130
|
+
// HTTP POST, so that any token refresh triggered during registration can
|
|
131
|
+
// be routed via connections without falling back to a locusInfo URL scan.
|
|
132
|
+
if (locusUrl && datachannelUrl) {
|
|
133
|
+
const sessionData = this.connections.get(sessionId) || {};
|
|
134
|
+
sessionData.locusUrl = locusUrl;
|
|
135
|
+
sessionData.datachannelUrl = datachannelUrl;
|
|
136
|
+
this.connections.set(sessionId, sessionData);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return this.register(datachannelUrl, datachannelToken, sessionId).then(async () => {
|
|
89
140
|
if (!locusUrl || !datachannelUrl) return undefined;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
141
|
+
|
|
142
|
+
// locusUrl and datachannelUrl were pre-populated before register(); here
|
|
143
|
+
// we only need to read the existing session data to get webSocketUrl/binding
|
|
144
|
+
// that register() filled in.
|
|
145
|
+
const sessionData = this.connections.get(sessionId) || {};
|
|
146
|
+
|
|
147
|
+
const isDataChannelTokenEnabled = await this.isDataChannelTokenEnabled();
|
|
148
|
+
|
|
149
|
+
const connectUrl = isDataChannelTokenEnabled
|
|
150
|
+
? LLMChannel.buildUrlWithAwareSubchannels(sessionData.webSocketUrl, AWARE_DATA_CHANNEL)
|
|
151
|
+
: sessionData.webSocketUrl;
|
|
152
|
+
|
|
153
|
+
return this.connect(connectUrl, sessionId);
|
|
93
154
|
});
|
|
155
|
+
};
|
|
94
156
|
|
|
95
157
|
/**
|
|
96
158
|
* Tells if LLM socket is connected
|
|
159
|
+
* @param {string} sessionId - Connection identifier
|
|
97
160
|
* @returns {boolean} connected
|
|
98
161
|
*/
|
|
99
|
-
public isConnected = (): boolean =>
|
|
162
|
+
public isConnected = (sessionId = LLM_DEFAULT_SESSION): boolean => {
|
|
163
|
+
const socket = this.getSocket(sessionId);
|
|
164
|
+
|
|
165
|
+
return socket ? socket.connected : false;
|
|
166
|
+
};
|
|
100
167
|
|
|
101
168
|
/**
|
|
102
169
|
* Tells if LLM socket is binding
|
|
170
|
+
* @param {string} sessionId - Connection identifier
|
|
103
171
|
* @returns {string} binding
|
|
104
172
|
*/
|
|
105
|
-
public getBinding = (): string =>
|
|
173
|
+
public getBinding = (sessionId = LLM_DEFAULT_SESSION): string => {
|
|
174
|
+
const sessionData = this.connections.get(sessionId);
|
|
175
|
+
|
|
176
|
+
return sessionData?.binding;
|
|
177
|
+
};
|
|
106
178
|
|
|
107
179
|
/**
|
|
108
180
|
* Get Locus URL for the connection
|
|
181
|
+
* @param {string} sessionId - Connection identifier
|
|
109
182
|
* @returns {string} locus Url
|
|
110
183
|
*/
|
|
111
|
-
public getLocusUrl = (): string =>
|
|
184
|
+
public getLocusUrl = (sessionId = LLM_DEFAULT_SESSION): string => {
|
|
185
|
+
const sessionData = this.connections.get(sessionId);
|
|
186
|
+
|
|
187
|
+
return sessionData?.locusUrl;
|
|
188
|
+
};
|
|
112
189
|
|
|
113
190
|
/**
|
|
114
191
|
* Get data channel URL for the connection
|
|
192
|
+
* @param {string} sessionId - Connection identifier
|
|
115
193
|
* @returns {string} data channel Url
|
|
116
194
|
*/
|
|
117
|
-
public getDatachannelUrl = (): string =>
|
|
195
|
+
public getDatachannelUrl = (sessionId = LLM_DEFAULT_SESSION): string => {
|
|
196
|
+
const sessionData = this.connections.get(sessionId);
|
|
197
|
+
|
|
198
|
+
return sessionData?.datachannelUrl;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Set the owner meeting ID for a given LLM session. Used by the meetings
|
|
203
|
+
* plugin to tag which Meeting instance currently owns the (default) LLM
|
|
204
|
+
* connection so that other Meeting instances can avoid disconnecting or
|
|
205
|
+
* re-initializing a connection they do not own.
|
|
206
|
+
*
|
|
207
|
+
* Does NOT create a connections entry if one does not already exist — this
|
|
208
|
+
* method is a no-op when there is no active session data. Callers should
|
|
209
|
+
* invoke it after a successful `registerAndConnect` or during an explicit
|
|
210
|
+
* ownership handoff.
|
|
211
|
+
*
|
|
212
|
+
* @param {string | undefined} ownerMeetingId - Meeting ID (or undefined to clear)
|
|
213
|
+
* @param {string} sessionId - Connection identifier (defaults to default session)
|
|
214
|
+
* @returns {void}
|
|
215
|
+
*/
|
|
216
|
+
public setOwnerMeetingId = (
|
|
217
|
+
ownerMeetingId: string | undefined,
|
|
218
|
+
sessionId: string = LLM_DEFAULT_SESSION
|
|
219
|
+
): void => {
|
|
220
|
+
const sessionData = this.connections.get(sessionId);
|
|
221
|
+
|
|
222
|
+
if (!sessionData) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
sessionData.ownerMeetingId = ownerMeetingId;
|
|
227
|
+
this.connections.set(sessionId, sessionData);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get the owner meeting ID currently associated with an LLM session.
|
|
232
|
+
* Returns undefined when no owner has been assigned (e.g. before the
|
|
233
|
+
* first successful `registerAndConnect`, or after `disconnectLLM`).
|
|
234
|
+
*
|
|
235
|
+
* @param {string} sessionId - Connection identifier (defaults to default session)
|
|
236
|
+
* @returns {string | undefined} ownerMeetingId
|
|
237
|
+
*/
|
|
238
|
+
public getOwnerMeetingId = (sessionId: string = LLM_DEFAULT_SESSION): string | undefined => {
|
|
239
|
+
const sessionData = this.connections.get(sessionId);
|
|
240
|
+
|
|
241
|
+
return sessionData?.ownerMeetingId;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve ownership information for an LLM session.
|
|
246
|
+
*
|
|
247
|
+
* Rules:
|
|
248
|
+
* - no current owner => caller may proceed
|
|
249
|
+
* - caller has no identity to assert => treat as owner
|
|
250
|
+
* - otherwise caller must match current owner
|
|
251
|
+
*
|
|
252
|
+
* @param {string | undefined} ownerMeetingId - Candidate owner to evaluate
|
|
253
|
+
* @param {string} sessionId - Connection identifier (defaults to default session)
|
|
254
|
+
* @returns {{currentOwner: (string|undefined), isOwner: boolean}}
|
|
255
|
+
*/
|
|
256
|
+
public resolveSessionOwnership = (
|
|
257
|
+
ownerMeetingId?: string,
|
|
258
|
+
sessionId: string = LLM_DEFAULT_SESSION
|
|
259
|
+
): {
|
|
260
|
+
currentOwner: string | undefined;
|
|
261
|
+
isOwner: boolean;
|
|
262
|
+
} => {
|
|
263
|
+
const currentOwner = this.getOwnerMeetingId(sessionId);
|
|
264
|
+
const isOwner = !currentOwner || !ownerMeetingId || currentOwner === ownerMeetingId;
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
currentOwner,
|
|
268
|
+
isOwner,
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get data channel token for the connection
|
|
274
|
+
* @param {DataChannelTokenType|string} tokenKey
|
|
275
|
+
* @param {string | undefined} ownerMeetingId - Meeting id asserting read ownership
|
|
276
|
+
* @returns {string | undefined} data channel token
|
|
277
|
+
*/
|
|
278
|
+
public getDatachannelToken = (
|
|
279
|
+
tokenKey?: DataChannelTokenType | string,
|
|
280
|
+
ownerMeetingId?: string
|
|
281
|
+
): string | undefined => {
|
|
282
|
+
const resolvedTokenKey = tokenKey ?? DataChannelTokenType.Default;
|
|
283
|
+
|
|
284
|
+
const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedTokenKey);
|
|
285
|
+
|
|
286
|
+
if (!isOwner) {
|
|
287
|
+
this.logger.info(
|
|
288
|
+
`llm#getDatachannelToken --> skip read for session ${resolvedTokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return this.datachannelTokens[resolvedTokenKey];
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Set data channel token for the connection
|
|
299
|
+
* @param {string} datachannelToken - data channel token
|
|
300
|
+
* @param {DataChannelTokenType|string} [tokenKey]
|
|
301
|
+
* @param {string | undefined} ownerMeetingId - Meeting id asserting write ownership
|
|
302
|
+
* @returns {void}
|
|
303
|
+
*/
|
|
304
|
+
public setDatachannelToken = (
|
|
305
|
+
datachannelToken: string,
|
|
306
|
+
tokenKey?: DataChannelTokenType | string,
|
|
307
|
+
ownerMeetingId?: string
|
|
308
|
+
): void => {
|
|
309
|
+
const resolvedTokenKey = tokenKey ?? DataChannelTokenType.Default;
|
|
310
|
+
|
|
311
|
+
const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedTokenKey);
|
|
312
|
+
|
|
313
|
+
if (!isOwner) {
|
|
314
|
+
this.logger.info(
|
|
315
|
+
`llm#setDatachannelToken --> skip write for session ${resolvedTokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this.datachannelTokens[resolvedTokenKey] = datachannelToken;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Clears a single session's data channel token.
|
|
326
|
+
* @param {DataChannelTokenType|string} tokenKey
|
|
327
|
+
* @param {string} ownerMeetingId - Meeting id asserting delete ownership
|
|
328
|
+
* @returns {void}
|
|
329
|
+
*/
|
|
330
|
+
public clearDatachannelToken = (
|
|
331
|
+
tokenKey: DataChannelTokenType | string,
|
|
332
|
+
ownerMeetingId: string
|
|
333
|
+
): void => {
|
|
334
|
+
const resolvedTokenKey = tokenKey;
|
|
335
|
+
|
|
336
|
+
const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedTokenKey);
|
|
337
|
+
|
|
338
|
+
if (!isOwner) {
|
|
339
|
+
this.logger.info(
|
|
340
|
+
`llm#clearDatachannelToken --> skip clear for session ${resolvedTokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
this.datachannelTokens[resolvedTokenKey] = undefined;
|
|
347
|
+
delete this.datachannelTokens[resolvedTokenKey];
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Set the handler used to refresh the DataChannel token
|
|
352
|
+
*
|
|
353
|
+
* @param {function} handler - Function that returns a refreshed token
|
|
354
|
+
* @param {string} [sessionId] - Connection identifier
|
|
355
|
+
* @param {string | undefined} ownerMeetingId - Meeting id asserting refresh-handler ownership
|
|
356
|
+
* @returns {void}
|
|
357
|
+
*/
|
|
358
|
+
public setRefreshHandler(
|
|
359
|
+
handler: () => Promise<{
|
|
360
|
+
body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
|
|
361
|
+
}>,
|
|
362
|
+
sessionId?: string,
|
|
363
|
+
ownerMeetingId?: string
|
|
364
|
+
) {
|
|
365
|
+
const resolvedSessionId = sessionId ?? LLM_DEFAULT_SESSION;
|
|
366
|
+
|
|
367
|
+
const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedSessionId);
|
|
368
|
+
|
|
369
|
+
if (!isOwner) {
|
|
370
|
+
this.logger.info(
|
|
371
|
+
`llm#setRefreshHandler --> skip write for session ${resolvedSessionId}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const sessionData = this.connections.get(resolvedSessionId);
|
|
378
|
+
|
|
379
|
+
if (sessionData) {
|
|
380
|
+
sessionData.refreshHandler = handler;
|
|
381
|
+
if (ownerMeetingId) {
|
|
382
|
+
sessionData.ownerMeetingId = ownerMeetingId;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Intentionally allow a pre-connection session shape here.
|
|
389
|
+
// Some flows inject refreshHandler before register/connect so token refresh
|
|
390
|
+
// is already wired when the socket lifecycle starts. register()/
|
|
391
|
+
// registerAndConnect() will later fill webSocketUrl/binding/locusUrl/
|
|
392
|
+
// datachannelUrl into this same session entry.
|
|
393
|
+
this.connections.set(resolvedSessionId, {
|
|
394
|
+
refreshHandler: handler,
|
|
395
|
+
ownerMeetingId,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Refresh the data channel token using the injected handler.
|
|
401
|
+
* Logs a descriptive error if the handler is missing or fails.
|
|
402
|
+
* @param {string} sessionId - Connection identifier (defaults to default session)
|
|
403
|
+
* @returns {Promise<string>} The refreshed token.
|
|
404
|
+
*/
|
|
405
|
+
public async refreshDataChannelToken(sessionId: string = LLM_DEFAULT_SESSION) {
|
|
406
|
+
const refreshHandler = this.connections.get(sessionId)?.refreshHandler;
|
|
407
|
+
|
|
408
|
+
if (!refreshHandler) {
|
|
409
|
+
this.logger.warn(
|
|
410
|
+
`llm#refreshDataChannelToken --> LLM refreshHandler is not set for session ${sessionId}, skipping token refresh`
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const res = await refreshHandler();
|
|
418
|
+
|
|
419
|
+
return res;
|
|
420
|
+
} catch (error: any) {
|
|
421
|
+
this.logger.warn(
|
|
422
|
+
`llm#refreshDataChannelToken --> DataChannel token refresh failed (likely locus changed or participant left): ${
|
|
423
|
+
error?.message || error
|
|
424
|
+
}`
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
118
430
|
|
|
119
431
|
/**
|
|
120
432
|
* Disconnects websocket connection
|
|
121
433
|
* @param {{code: number, reason: string}} options - The disconnect option object with code and reason
|
|
434
|
+
* @param {string} sessionId - Connection identifier
|
|
435
|
+
* @param {string} ownerMeetingId - Meeting id asserting disconnect ownership
|
|
436
|
+
* @returns {Promise<boolean>} True when disconnect was performed, false when skipped
|
|
437
|
+
*/
|
|
438
|
+
public disconnectLLM = (
|
|
439
|
+
options: {code: number; reason: string},
|
|
440
|
+
sessionId?: string,
|
|
441
|
+
ownerMeetingId?: string
|
|
442
|
+
): Promise<boolean> => {
|
|
443
|
+
const resolvedSessionId = sessionId ?? LLM_DEFAULT_SESSION;
|
|
444
|
+
|
|
445
|
+
// Backward-compat path: historically callers could omit ownerMeetingId
|
|
446
|
+
// (and sometimes sessionId). Reuse current owner when available so legacy
|
|
447
|
+
// calls remain best-effort without throwing at teardown time.
|
|
448
|
+
const resolvedOwnerMeetingId = ownerMeetingId || this.getOwnerMeetingId(resolvedSessionId);
|
|
449
|
+
|
|
450
|
+
if (!ownerMeetingId) {
|
|
451
|
+
this.logger.warn(
|
|
452
|
+
`llm#disconnectLLM --> ownerMeetingId is omitted for session ${resolvedSessionId}; using legacy compatibility path`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const {currentOwner, isOwner} = this.resolveSessionOwnership(
|
|
457
|
+
resolvedOwnerMeetingId,
|
|
458
|
+
resolvedSessionId
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
if (!isOwner) {
|
|
462
|
+
this.logger.info(
|
|
463
|
+
`llm#disconnectLLM --> skip disconnect for session ${resolvedSessionId}; owned by ${currentOwner}, candidate ${resolvedOwnerMeetingId}`
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
return Promise.resolve(false);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return this.disconnect(options, resolvedSessionId).then(() => {
|
|
470
|
+
// Clear owner tag before cleanup to ensure it's not lingering
|
|
471
|
+
// if another meeting claimed it during disconnect
|
|
472
|
+
this.setOwnerMeetingId(undefined, resolvedSessionId);
|
|
473
|
+
|
|
474
|
+
// Clean up sessions data
|
|
475
|
+
this.connections.delete(resolvedSessionId);
|
|
476
|
+
|
|
477
|
+
return true;
|
|
478
|
+
});
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Disconnects all LLM websocket connections
|
|
483
|
+
* @param {{code: number, reason: string}} options - The disconnect option object with code and reason
|
|
122
484
|
* @returns {Promise<void>}
|
|
123
485
|
*/
|
|
124
|
-
public
|
|
125
|
-
this.
|
|
126
|
-
|
|
127
|
-
this.
|
|
128
|
-
this.binding = undefined;
|
|
129
|
-
this.webSocketUrl = undefined;
|
|
486
|
+
public disconnectAllLLM = (options?: {code: number; reason: string}): Promise<void> =>
|
|
487
|
+
this.disconnectAll(options).then(() => {
|
|
488
|
+
// Clean up all connection data
|
|
489
|
+
this.connections.clear();
|
|
130
490
|
});
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Get all active LLM connections
|
|
494
|
+
* @returns {Map} Map of sessionId to session data
|
|
495
|
+
*/
|
|
496
|
+
public getAllConnections = (): Map<
|
|
497
|
+
string,
|
|
498
|
+
{
|
|
499
|
+
webSocketUrl?: string;
|
|
500
|
+
binding?: string;
|
|
501
|
+
locusUrl?: string;
|
|
502
|
+
datachannelUrl?: string;
|
|
503
|
+
ownerMeetingId?: string;
|
|
504
|
+
}
|
|
505
|
+
> => new Map(this.connections);
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Look up the locusUrl associated with a datachannel request URL.
|
|
509
|
+
* Iterates all active LLM sessions and returns the locusUrl of the
|
|
510
|
+
* session whose stored datachannelUrl is a prefix of the given request URL.
|
|
511
|
+
*
|
|
512
|
+
* @param {string} requestUrl - The in-flight request URL to match
|
|
513
|
+
* @returns {string | undefined} The matching locusUrl, or undefined if not found
|
|
514
|
+
*/
|
|
515
|
+
public getLocusUrlByDatachannelUrl(requestUrl: string): string | undefined {
|
|
516
|
+
for (const [, connection] of this.connections) {
|
|
517
|
+
if (
|
|
518
|
+
connection.datachannelUrl &&
|
|
519
|
+
LLMChannel.matchesDatachannelRequestUrl(requestUrl, connection.datachannelUrl)
|
|
520
|
+
) {
|
|
521
|
+
return connection.locusUrl;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Look up the sessionId associated with a datachannel request URL.
|
|
530
|
+
* Iterates all active LLM sessions and returns the sessionId whose
|
|
531
|
+
* stored datachannelUrl is a prefix of the given request URL.
|
|
532
|
+
*
|
|
533
|
+
* @param {string} requestUrl - The in-flight request URL to match
|
|
534
|
+
* @returns {string | undefined} The matching sessionId, or undefined if not found
|
|
535
|
+
*/
|
|
536
|
+
public getSessionIdByDatachannelUrl(requestUrl: string): string | undefined {
|
|
537
|
+
for (const [sessionId, connection] of this.connections) {
|
|
538
|
+
if (
|
|
539
|
+
connection.datachannelUrl &&
|
|
540
|
+
LLMChannel.matchesDatachannelRequestUrl(requestUrl, connection.datachannelUrl)
|
|
541
|
+
) {
|
|
542
|
+
return sessionId;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Matches a request URL to a stored datachannel registration URL.
|
|
551
|
+
* Host can differ (e.g. rewritten by hostmap interceptor), so we first
|
|
552
|
+
* try full URL prefix and then fall back to pathname prefix.
|
|
553
|
+
* @param {string} requestUrl
|
|
554
|
+
* @param {string} registrationUrl
|
|
555
|
+
* @returns {boolean}
|
|
556
|
+
*/
|
|
557
|
+
public static matchesDatachannelRequestUrl(requestUrl: string, registrationUrl: string): boolean {
|
|
558
|
+
if (!requestUrl || !registrationUrl) {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (requestUrl.startsWith(registrationUrl)) {
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
const request = new URL(requestUrl);
|
|
568
|
+
const registration = new URL(registrationUrl);
|
|
569
|
+
|
|
570
|
+
return request.pathname.startsWith(registration.pathname);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Returns true if data channel token is enabled, false otherwise
|
|
578
|
+
* @returns {Promise<boolean>} resolves with true if data channel token is enabled
|
|
579
|
+
*/
|
|
580
|
+
public isDataChannelTokenEnabled(): Promise<boolean> {
|
|
581
|
+
// @ts-ignore
|
|
582
|
+
return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Builds a WebSocket URL with the `subscriptionAwareSubchannels` query parameter.
|
|
587
|
+
*
|
|
588
|
+
* @param {string} baseUrl - The original WebSocket URL.
|
|
589
|
+
* @param {string[]} subchannels - List of subchannels to declare as subscription-aware.
|
|
590
|
+
* @returns {string} The final URL with updated query parameters.
|
|
591
|
+
*/
|
|
592
|
+
|
|
593
|
+
public static buildUrlWithAwareSubchannels = (baseUrl: string, subchannels: string[]) => {
|
|
594
|
+
const urlObj = new URL(baseUrl);
|
|
595
|
+
urlObj.searchParams.set(SUBSCRIPTION_AWARE_SUBCHANNELS_PARAM, subchannels.join(','));
|
|
596
|
+
|
|
597
|
+
return urlObj.toString();
|
|
598
|
+
};
|
|
131
599
|
}
|
package/src/llm.types.ts
CHANGED
|
@@ -1,9 +1,69 @@
|
|
|
1
|
+
export enum DataChannelTokenType {
|
|
2
|
+
Default = 'llm-default-session',
|
|
3
|
+
PracticeSession = 'llm-practice-session',
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type DataChannelTokenKey = DataChannelTokenType | string;
|
|
7
|
+
|
|
1
8
|
interface ILLMChannel {
|
|
2
|
-
registerAndConnect: (
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
registerAndConnect: (
|
|
10
|
+
locusUrl: string,
|
|
11
|
+
datachannelUrl: string,
|
|
12
|
+
datachannelToken?: string,
|
|
13
|
+
sessionId?: string
|
|
14
|
+
) => Promise<void>;
|
|
15
|
+
isConnected: (sessionId?: string) => boolean;
|
|
16
|
+
getBinding: (sessionId?: string) => string;
|
|
17
|
+
getLocusUrl: (sessionId?: string) => string;
|
|
18
|
+
getDatachannelUrl: (sessionId?: string) => string;
|
|
19
|
+
disconnectLLM: (
|
|
20
|
+
options: {code: number; reason: string},
|
|
21
|
+
sessionId?: string,
|
|
22
|
+
ownerMeetingId?: string
|
|
23
|
+
) => Promise<boolean>;
|
|
24
|
+
disconnectAllLLM: (options?: {code: number; reason: string}) => Promise<void>;
|
|
25
|
+
setOwnerMeetingId: (ownerMeetingId: string | undefined, sessionId?: string) => void;
|
|
26
|
+
getOwnerMeetingId: (sessionId?: string) => string | undefined;
|
|
27
|
+
resolveSessionOwnership: (
|
|
28
|
+
ownerMeetingId?: string,
|
|
29
|
+
sessionId?: string
|
|
30
|
+
) => {
|
|
31
|
+
currentOwner: string | undefined;
|
|
32
|
+
isOwner: boolean;
|
|
33
|
+
};
|
|
34
|
+
getDatachannelToken: (
|
|
35
|
+
tokenKey?: DataChannelTokenKey,
|
|
36
|
+
ownerMeetingId?: string
|
|
37
|
+
) => string | undefined;
|
|
38
|
+
setDatachannelToken: (
|
|
39
|
+
datachannelToken: string,
|
|
40
|
+
tokenKey?: DataChannelTokenKey,
|
|
41
|
+
ownerMeetingId?: string
|
|
42
|
+
) => void;
|
|
43
|
+
clearDatachannelToken: (tokenKey: DataChannelTokenKey, ownerMeetingId: string) => void;
|
|
44
|
+
setRefreshHandler: (
|
|
45
|
+
handler: () => Promise<{
|
|
46
|
+
body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
|
|
47
|
+
}>,
|
|
48
|
+
sessionId?: string,
|
|
49
|
+
ownerMeetingId?: string
|
|
50
|
+
) => void;
|
|
51
|
+
refreshDataChannelToken: (sessionId?: string) => Promise<{
|
|
52
|
+
body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
|
|
53
|
+
} | null>;
|
|
54
|
+
getLocusUrlByDatachannelUrl: (requestUrl: string) => string | undefined;
|
|
55
|
+
getSessionIdByDatachannelUrl: (requestUrl: string) => string | undefined;
|
|
56
|
+
getAllConnections: () => Map<
|
|
57
|
+
string,
|
|
58
|
+
{
|
|
59
|
+
webSocketUrl?: string;
|
|
60
|
+
binding?: string;
|
|
61
|
+
locusUrl?: string;
|
|
62
|
+
datachannelUrl?: string;
|
|
63
|
+
ownerMeetingId?: string;
|
|
64
|
+
}
|
|
65
|
+
>;
|
|
7
66
|
}
|
|
67
|
+
|
|
8
68
|
// eslint-disable-next-line import/prefer-default-export
|
|
9
|
-
export type {ILLMChannel};
|
|
69
|
+
export type {ILLMChannel, DataChannelTokenKey};
|