-
Notifications
You must be signed in to change notification settings - Fork 404
Removed sessionId usage #5092
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: llmrefactor
Are you sure you want to change the base?
Removed sessionId usage #5092
Changes from 11 commits
c41cd78
fa0a3a7
3aefb4a
f09dbed
8eb7b13
ed14722
db2f777
a3b5d62
0be0198
46f5b65
4c5c2c3
9a8e462
e8ff25d
32e07b7
96ef24b
77b2897
b5d2ced
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,255 +1,88 @@ | ||
| /* eslint-disable require-jsdoc */ | ||
| import {WebexPlugin} from '@webex/webex-core'; | ||
| import LLMChannel, {config} from './llm'; | ||
| import {DATA_CHANNEL_WITH_JWT_TOKEN, LLM_DEFAULT_SESSION} from './constants'; | ||
| import {DataChannelTokenType} from './llm.types'; | ||
| import {DATA_CHANNEL_WITH_JWT_TOKEN} from './constants'; | ||
|
|
||
| /** | ||
| * LLMPlugin — registered as `webex.internal.llm`. | ||
| * | ||
| * Maintains a Map<sessionId, LLMChannel> so multiple simultaneous LLM | ||
| * connections (e.g. default session + practice session) can coexist. | ||
| * All existing callers continue to work unchanged via the session-keyed API. | ||
| * Factory for creating LLMChannel instances. Each Meeting creates and owns | ||
| * its own LLMChannel(s), allowing multiple independent connections without | ||
| * the need for session IDs or ownership tracking. | ||
| * | ||
| * sessionId values are the LLM_DEFAULT_SESSION / LLM_PRACTICE_SESSION constants, | ||
| * which match the DataChannelTokenType enum values so token keys and session keys | ||
| * are the same namespace. | ||
| * This is a breaking API change from the previous design where the plugin | ||
| * maintained a Map of sessions and exposed session-keyed methods. | ||
| * | ||
| * Old usage (no longer supported): | ||
| * webex.internal.llm.isConnected() | ||
| * webex.internal.llm.registerAndConnect(url, dcUrl, token, sessionId) | ||
| * | ||
| * New usage: | ||
| * const llm = webex.internal.llm.createConnection(); | ||
| * await llm.registerAndConnect(url, dcUrl, token); | ||
| * llm.isConnected(); | ||
| * // When done: | ||
| * await llm.disconnect(); | ||
| */ | ||
| export class LLMPlugin extends (WebexPlugin as any) { | ||
| namespace = 'llm'; | ||
|
|
||
| private sessions = new Map<string, LLMChannel>(); | ||
|
|
||
| private getOrCreateSession(sessionId: string): LLMChannel { | ||
| let channel = this.sessions.get(sessionId); | ||
|
|
||
| if (!channel) { | ||
| // @ts-ignore — WebexPlugin children require {parent: this.webex} | ||
| channel = new LLMChannel({parent: this.webex}); | ||
| // Forward all events emitted by the channel up through the plugin so that | ||
| // callers doing llm.on('event:relay.event', ...) or llm.on('online', ...) | ||
| // receive events from whichever session channel emits them. | ||
| // Non-default sessions emit events with :<sessionId> suffix so that | ||
| // practice-session consumers can subscribe to session-specific names | ||
| // like `event:relay.event:llm-practice-session`. | ||
| channel.on('all', (eventName: string, ...args: any[]) => { | ||
| if (sessionId === LLM_DEFAULT_SESSION) { | ||
| this.trigger(eventName, ...args); | ||
| } else { | ||
| this.trigger(`${eventName}:${sessionId}`, ...args); | ||
| } | ||
| }); | ||
| this.sessions.set(sessionId, channel); | ||
| } | ||
|
|
||
| return channel; | ||
| } | ||
|
|
||
| private getSession(sessionId: string): LLMChannel | undefined { | ||
| return this.sessions.get(sessionId); | ||
| } | ||
|
|
||
| // Before webex.internal.llm was LLChannel instance which extended Mercury so it was directly available | ||
| get hasEverConnected(): boolean { | ||
| for (const ch of this.sessions.values()) { | ||
| if (ch.hasEverConnected) return true; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| public registerAndConnect( | ||
| locusUrl: string, | ||
| datachannelUrl: string, | ||
| datachannelToken?: string, | ||
| sessionId: string = LLM_DEFAULT_SESSION | ||
| ): Promise<void> { | ||
| const channel = this.getOrCreateSession(sessionId); | ||
|
|
||
| return channel.registerAndConnect(locusUrl, datachannelUrl, datachannelToken); | ||
| } | ||
|
|
||
| public disconnectLLM( | ||
| options: {code: number; reason: string}, | ||
| // eslint-disable-next-line default-param-last | ||
| sessionId: string = LLM_DEFAULT_SESSION, | ||
| ownerMeetingId?: string | ||
| ): Promise<boolean | void> { | ||
| const channel = this.getSession(sessionId); | ||
|
|
||
| if (!channel) return Promise.resolve(); | ||
|
|
||
| const {isOwner} = this.resolveSessionOwnership(ownerMeetingId, sessionId); | ||
|
|
||
| if (!isOwner) { | ||
| this.logger.info(`llm#disconnectLLM --> skipping, not owner of session ${sessionId}`); | ||
|
|
||
| return Promise.resolve(false); | ||
| } | ||
|
|
||
| return channel.disconnect(options).then(() => { | ||
| this.sessions.delete(sessionId); | ||
|
|
||
| return true; | ||
| /** | ||
| * Registry of active LLM channels for interceptor lookup. | ||
| * Channels are registered when created and unregistered on disconnect. | ||
| */ | ||
| private channels = new Set<LLMChannel>(); | ||
|
|
||
| /** | ||
| * Creates a new LLMChannel instance. The caller owns the channel and is | ||
| * responsible for connecting, disconnecting, and cleaning it up. | ||
| * | ||
| * @example | ||
| * const llm = webex.internal.llm.createConnection(); | ||
| * llm.setRefreshHandler(() => meeting.refreshDataChannelToken()); | ||
| * await llm.registerAndConnect(locusUrl, datachannelUrl, token); | ||
| * | ||
| * // Subscribe to events directly on the channel | ||
| * llm.on('event:relay.event', handler); | ||
| * llm.on('online', onlineHandler); | ||
| * | ||
| * // When done | ||
| * await llm.disconnect(); | ||
| * | ||
| * @returns {LLMChannel} A new LLM connection instance | ||
| */ | ||
| public createConnection(): LLMChannel { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the function is called "createConnection" but it returns an LLMChannel - it would be better to settle on one name and use it consistently everywhere - either "connection" or "channel" note that there are more places that use "connection" like getConnectionByDatachannelUrl, getAllConnections, etc |
||
| // @ts-ignore — WebexPlugin children require {parent: this.webex} | ||
| const channel = new LLMChannel({parent: this.webex}); | ||
|
|
||
| this.channels.add(channel); | ||
|
|
||
| // Auto-unregister when disconnected | ||
| channel.on('offline', () => { | ||
| this.channels.delete(channel); | ||
|
marcin-bazyl marked this conversation as resolved.
Outdated
|
||
| }); | ||
| } | ||
|
|
||
| public disconnectAllLLM(options?: {code: number; reason: string}): Promise<void> { | ||
| const promises = Array.from(this.sessions.entries()).map(([sessionId, channel]) => | ||
| channel.disconnect(options).then(() => this.sessions.delete(sessionId)) | ||
| ); | ||
|
|
||
| return Promise.all(promises).then(() => undefined); | ||
| } | ||
|
|
||
| public isConnected(sessionId: string = LLM_DEFAULT_SESSION): boolean { | ||
| const session = this.getSession(sessionId); | ||
| const connected = session?.isConnected() ?? false; | ||
|
|
||
| return connected; | ||
| } | ||
|
|
||
| public getBinding(sessionId: string = LLM_DEFAULT_SESSION): string | undefined { | ||
| return this.getSession(sessionId)?.getBinding(); | ||
| } | ||
|
|
||
| public getSocket(sessionId: string = LLM_DEFAULT_SESSION): any { | ||
| return this.getSession(sessionId)?.socket; | ||
| } | ||
|
|
||
| // Backwards-compatibility: callers that access llm.socket directly | ||
| // (e.g. voicea's getPublishTransport) get the default session socket. | ||
| get socket(): any { | ||
| return this.getSocket(LLM_DEFAULT_SESSION); | ||
| } | ||
|
|
||
| public getLocusUrl(sessionId: string = LLM_DEFAULT_SESSION): string | undefined { | ||
| return this.getSession(sessionId)?.getLocusUrl(); | ||
| } | ||
|
|
||
| public getDatachannelUrl(sessionId: string = LLM_DEFAULT_SESSION): string | undefined { | ||
| return this.getSession(sessionId)?.getDatachannelUrl(); | ||
| } | ||
|
|
||
| // tokenKey IS the sessionId (DataChannelTokenType enum values equal LLM_*_SESSION constants) | ||
|
|
||
| public getDatachannelToken( | ||
| // eslint-disable-next-line default-param-last | ||
| tokenKey: string = LLM_DEFAULT_SESSION, | ||
| ownerMeetingId?: string | ||
| ): string | undefined { | ||
| const channel = this.getSession(tokenKey); | ||
|
|
||
| if (!channel) return undefined; | ||
|
|
||
| const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey); | ||
|
|
||
| if (!isOwner) { | ||
| this.logger.info( | ||
| `llm#getDatachannelToken --> skip read for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}` | ||
| ); | ||
|
|
||
| return undefined; | ||
| } | ||
|
|
||
| return channel.getDatachannelToken(); | ||
| } | ||
|
|
||
| public setDatachannelToken( | ||
| datachannelToken: string, | ||
| ownerMeetingId?: string, | ||
| tokenKey: string = LLM_DEFAULT_SESSION | ||
| ): void { | ||
| const channel = this.getOrCreateSession(tokenKey); | ||
| const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey); | ||
|
|
||
| if (!isOwner) { | ||
| this.logger.info( | ||
| `llm#setDatachannelToken --> skip write for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}` | ||
| ); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| channel.setDatachannelToken(datachannelToken); | ||
| } | ||
|
|
||
| public clearDatachannelToken(tokenKey: string, ownerMeetingId: string): void { | ||
| const channel = this.getSession(tokenKey); | ||
|
|
||
| if (!channel) return; | ||
|
|
||
| const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey); | ||
|
|
||
| if (!isOwner) { | ||
| this.logger.info( | ||
| `llm#clearDatachannelToken --> skip clear for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}` | ||
| ); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| channel.clearDatachannelToken(); | ||
| } | ||
|
|
||
| public setRefreshHandler( | ||
| handler: () => Promise<{ | ||
| body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType}; | ||
| }>, | ||
| ownerMeetingId?: string, | ||
| sessionId: string = LLM_DEFAULT_SESSION | ||
| ): void { | ||
| const channel = this.getOrCreateSession(sessionId); | ||
| const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, sessionId); | ||
|
|
||
| if (!isOwner) { | ||
| this.logger.info( | ||
| `llm#setRefreshHandler --> skip write for session ${sessionId}; owned by ${currentOwner}, candidate ${ownerMeetingId}` | ||
| ); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| channel.setRefreshHandler(handler); | ||
| } | ||
|
|
||
| public refreshDataChannelToken(sessionId: string = LLM_DEFAULT_SESSION): Promise<any> { | ||
| const channel = this.getSession(sessionId); | ||
|
|
||
| if (!channel) { | ||
| this.logger.warn(`llm#refreshDataChannelToken --> no channel for session ${sessionId}`); | ||
|
|
||
| return Promise.resolve(null); | ||
| } | ||
|
|
||
| return channel.refreshDataChannelToken(); | ||
| } | ||
|
|
||
| public setOwnerMeetingId( | ||
| ownerMeetingId: string | undefined, | ||
| sessionId: string = LLM_DEFAULT_SESSION | ||
| ): void { | ||
| const channel = this.getSession(sessionId); | ||
|
|
||
| if (channel) channel.ownerMeetingId = ownerMeetingId; | ||
| } | ||
|
|
||
| public getOwnerMeetingId(sessionId: string = LLM_DEFAULT_SESSION): string | undefined { | ||
| return this.getSession(sessionId)?.ownerMeetingId; | ||
| return channel; | ||
| } | ||
|
|
||
| public resolveSessionOwnership( | ||
| ownerMeetingId?: string, | ||
| sessionId: string = LLM_DEFAULT_SESSION | ||
| ): {currentOwner: string | undefined; isOwner: boolean} { | ||
| const currentOwner = this.getOwnerMeetingId(sessionId); | ||
| const isOwner = !currentOwner || !ownerMeetingId || currentOwner === ownerMeetingId; | ||
|
|
||
| return {currentOwner, isOwner}; | ||
| /** | ||
| * Returns true if the data channel token feature flag is enabled. | ||
| * This is a global check, not per-connection. | ||
| * @returns {Promise<boolean>} | ||
| */ | ||
| public isDataChannelTokenEnabled(): Promise<boolean> { | ||
| // @ts-ignore | ||
| return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN); | ||
| } | ||
|
|
||
| /** | ||
| * Find a connection by its datachannel URL. Used by the interceptor to | ||
| * route token refresh requests to the correct channel. | ||
| * @param {string} url - The request URL to match | ||
| * @returns {LLMChannel | undefined} | ||
| */ | ||
| public getConnectionByDatachannelUrl(url: string): LLMChannel | undefined { | ||
| for (const channel of this.sessions.values()) { | ||
| for (const channel of this.channels) { | ||
| const datachannelUrl = channel.getDatachannelUrl(); | ||
|
|
||
| if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(url, datachannelUrl)) { | ||
|
|
@@ -260,37 +93,37 @@ export class LLMPlugin extends (WebexPlugin as any) { | |
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Find a locus URL by matching a request URL to an active connection's | ||
| * datachannel URL. Used by the interceptor. | ||
| * @param {string} requestUrl - The request URL to match | ||
| * @returns {string | undefined} | ||
| */ | ||
| public getLocusUrlByDatachannelUrl(requestUrl: string): string | undefined { | ||
|
marcin-bazyl marked this conversation as resolved.
Outdated
|
||
| for (const channel of this.sessions.values()) { | ||
| const datachannelUrl = channel.getDatachannelUrl(); | ||
| const channel = this.getConnectionByDatachannelUrl(requestUrl); | ||
|
|
||
| if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(requestUrl, datachannelUrl)) { | ||
| return channel.getLocusUrl(); | ||
| } | ||
| } | ||
|
|
||
| return undefined; | ||
| return channel?.getLocusUrl(); | ||
| } | ||
|
|
||
| public getSessionIdByDatachannelUrl(requestUrl: string): string | undefined { | ||
| for (const [sessionId, channel] of this.sessions.entries()) { | ||
| const datachannelUrl = channel.getDatachannelUrl(); | ||
|
|
||
| if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(requestUrl, datachannelUrl)) { | ||
| return sessionId; | ||
| } | ||
| } | ||
|
|
||
| return undefined; | ||
| /** | ||
| * Get all active connections. Useful for diagnostics/debugging. | ||
| * @returns {Set<LLMChannel>} | ||
| */ | ||
| public getAllConnections(): Set<LLMChannel> { | ||
| return new Set(this.channels); | ||
| } | ||
|
|
||
| public isDataChannelTokenEnabled(): Promise<boolean> { | ||
| // @ts-ignore | ||
| return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN); | ||
| } | ||
| /** | ||
| * Disconnect all active connections. Useful for cleanup on logout. | ||
| * @param {object} [options] - Disconnect options | ||
| * @param {number} [options.code] - WebSocket close code | ||
| * @param {string} [options.reason] - WebSocket close reason | ||
| * @returns {Promise<void>} | ||
| */ | ||
| public async disconnectAll(options?: {code: number; reason: string}): Promise<void> { | ||
| const promises = Array.from(this.channels).map((channel) => channel.disconnect(options)); | ||
|
|
||
| public getAllConnections(): Map<string, LLMChannel> { | ||
| return new Map(this.sessions); | ||
| await Promise.all(promises); | ||
| } | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.