Skip to content

Commit 863a448

Browse files
committed
feat: llmrefactor and mercury revert (#5036)
1 parent 03f7c94 commit 863a448

18 files changed

Lines changed: 1884 additions & 1541 deletions

File tree

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import * as WebexCore from '@webex/webex-core';
2-
import LLMChannel, {config} from './llm';
2+
import LLMPlugin, {config} from './llm-plugin';
33
import {DataChannelTokenType} from './llm.types';
44

5-
WebexCore.registerInternalPlugin('llm', LLMChannel, {
5+
WebexCore.registerInternalPlugin('llm', LLMPlugin, {
66
config,
7+
onBeforeLogout() {
8+
return this.disconnectAllLLM();
9+
},
710
});
811

912
export {DataChannelTokenType};
1013
export {LLM_DEFAULT_SESSION, LLM_PRACTICE_SESSION} from './constants';
1114
export {default} from './llm';
15+
export {default as LLMPlugin} from './llm-plugin';
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
/* eslint-disable require-jsdoc */
2+
import {WebexPlugin} from '@webex/webex-core';
3+
import LLMChannel, {config} from './llm';
4+
import {DATA_CHANNEL_WITH_JWT_TOKEN, LLM_DEFAULT_SESSION} from './constants';
5+
import {DataChannelTokenType} from './llm.types';
6+
7+
/**
8+
* LLMPlugin — registered as `webex.internal.llm`.
9+
*
10+
* Maintains a Map<sessionId, LLMChannel> so multiple simultaneous LLM
11+
* connections (e.g. default session + practice session) can coexist.
12+
* All existing callers continue to work unchanged via the session-keyed API.
13+
*
14+
* sessionId values are the LLM_DEFAULT_SESSION / LLM_PRACTICE_SESSION constants,
15+
* which match the DataChannelTokenType enum values so token keys and session keys
16+
* are the same namespace.
17+
*/
18+
export class LLMPlugin extends (WebexPlugin as any) {
19+
namespace = 'llm';
20+
21+
private sessions = new Map<string, LLMChannel>();
22+
23+
private getOrCreateSession(sessionId: string): LLMChannel {
24+
let channel = this.sessions.get(sessionId);
25+
26+
if (!channel) {
27+
// @ts-ignore — WebexPlugin children require {parent: this.webex}
28+
channel = new LLMChannel({parent: this.webex});
29+
// Forward all events emitted by the channel up through the plugin so that
30+
// callers doing llm.on('event:relay.event', ...) or llm.on('online', ...)
31+
// receive events from whichever session channel emits them.
32+
// Non-default sessions emit events with :<sessionId> suffix so that
33+
// practice-session consumers can subscribe to session-specific names
34+
// like `event:relay.event:llm-practice-session`.
35+
channel.on('all', (eventName: string, ...args: any[]) => {
36+
if (sessionId === LLM_DEFAULT_SESSION) {
37+
this.trigger(eventName, ...args);
38+
} else {
39+
this.trigger(`${eventName}:${sessionId}`, ...args);
40+
}
41+
});
42+
this.sessions.set(sessionId, channel);
43+
}
44+
45+
return channel;
46+
}
47+
48+
private getSession(sessionId: string): LLMChannel | undefined {
49+
return this.sessions.get(sessionId);
50+
}
51+
52+
// Before webex.internal.llm was LLChannel instance which extended Mercury so it was directly available
53+
get hasEverConnected(): boolean {
54+
for (const ch of this.sessions.values()) {
55+
if (ch.hasEverConnected) return true;
56+
}
57+
58+
return false;
59+
}
60+
61+
public registerAndConnect(
62+
locusUrl: string,
63+
datachannelUrl: string,
64+
datachannelToken?: string,
65+
sessionId: string = LLM_DEFAULT_SESSION
66+
): Promise<void> {
67+
const channel = this.getOrCreateSession(sessionId);
68+
69+
return channel.registerAndConnect(locusUrl, datachannelUrl, datachannelToken);
70+
}
71+
72+
public disconnectLLM(
73+
options: {code: number; reason: string},
74+
// eslint-disable-next-line default-param-last
75+
sessionId: string = LLM_DEFAULT_SESSION,
76+
ownerMeetingId?: string
77+
): Promise<boolean | void> {
78+
const channel = this.getSession(sessionId);
79+
80+
if (!channel) return Promise.resolve();
81+
82+
const {isOwner} = this.resolveSessionOwnership(ownerMeetingId, sessionId);
83+
84+
if (!isOwner) {
85+
this.logger.info(`llm#disconnectLLM --> skipping, not owner of session ${sessionId}`);
86+
87+
return Promise.resolve(false);
88+
}
89+
90+
return channel.disconnectLLM(options).then(() => {
91+
this.sessions.delete(sessionId);
92+
93+
return true;
94+
});
95+
}
96+
97+
public disconnectAllLLM(options?: {code: number; reason: string}): Promise<void> {
98+
const promises = Array.from(this.sessions.entries()).map(([sessionId, channel]) =>
99+
channel.disconnectLLM(options).then(() => this.sessions.delete(sessionId))
100+
);
101+
102+
return Promise.all(promises).then(() => undefined);
103+
}
104+
105+
public isConnected(sessionId: string = LLM_DEFAULT_SESSION): boolean {
106+
const session = this.getSession(sessionId);
107+
const connected = session?.isConnected() ?? false;
108+
109+
return connected;
110+
}
111+
112+
public getBinding(sessionId: string = LLM_DEFAULT_SESSION): string | undefined {
113+
return this.getSession(sessionId)?.getBinding();
114+
}
115+
116+
public getSocket(sessionId: string = LLM_DEFAULT_SESSION): any {
117+
return this.getSession(sessionId)?.socket;
118+
}
119+
120+
// Backwards-compatibility: callers that access llm.socket directly
121+
// (e.g. voicea's getPublishTransport) get the default session socket.
122+
get socket(): any {
123+
return this.getSocket(LLM_DEFAULT_SESSION);
124+
}
125+
126+
public getLocusUrl(sessionId: string = LLM_DEFAULT_SESSION): string | undefined {
127+
return this.getSession(sessionId)?.getLocusUrl();
128+
}
129+
130+
public getDatachannelUrl(sessionId: string = LLM_DEFAULT_SESSION): string | undefined {
131+
return this.getSession(sessionId)?.getDatachannelUrl();
132+
}
133+
134+
// tokenKey IS the sessionId (DataChannelTokenType enum values equal LLM_*_SESSION constants)
135+
136+
public getDatachannelToken(
137+
// eslint-disable-next-line default-param-last
138+
tokenKey: string = LLM_DEFAULT_SESSION,
139+
ownerMeetingId?: string
140+
): string | undefined {
141+
const channel = this.getSession(tokenKey);
142+
143+
if (!channel) return undefined;
144+
145+
const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey);
146+
147+
if (!isOwner) {
148+
this.logger.info(
149+
`llm#getDatachannelToken --> skip read for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
150+
);
151+
152+
return undefined;
153+
}
154+
155+
return channel.getDatachannelToken();
156+
}
157+
158+
public setDatachannelToken(
159+
datachannelToken: string,
160+
ownerMeetingId?: string,
161+
tokenKey: string = LLM_DEFAULT_SESSION
162+
): void {
163+
const channel = this.getOrCreateSession(tokenKey);
164+
const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey);
165+
166+
if (!isOwner) {
167+
this.logger.info(
168+
`llm#setDatachannelToken --> skip write for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
169+
);
170+
171+
return;
172+
}
173+
174+
channel.setDatachannelToken(datachannelToken);
175+
}
176+
177+
public clearDatachannelToken(tokenKey: string, ownerMeetingId: string): void {
178+
const channel = this.getSession(tokenKey);
179+
180+
if (!channel) return;
181+
182+
const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey);
183+
184+
if (!isOwner) {
185+
this.logger.info(
186+
`llm#clearDatachannelToken --> skip clear for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
187+
);
188+
189+
return;
190+
}
191+
192+
channel.clearDatachannelToken();
193+
}
194+
195+
public setRefreshHandler(
196+
handler: () => Promise<{
197+
body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
198+
}>,
199+
ownerMeetingId?: string,
200+
sessionId: string = LLM_DEFAULT_SESSION
201+
): void {
202+
const channel = this.getOrCreateSession(sessionId);
203+
const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, sessionId);
204+
205+
if (!isOwner) {
206+
this.logger.info(
207+
`llm#setRefreshHandler --> skip write for session ${sessionId}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
208+
);
209+
210+
return;
211+
}
212+
213+
channel.setRefreshHandler(handler);
214+
}
215+
216+
public refreshDataChannelToken(sessionId: string = LLM_DEFAULT_SESSION): Promise<any> {
217+
const channel = this.getSession(sessionId);
218+
219+
if (!channel) {
220+
this.logger.warn(`llm#refreshDataChannelToken --> no channel for session ${sessionId}`);
221+
222+
return Promise.resolve(null);
223+
}
224+
225+
return channel.refreshDataChannelToken();
226+
}
227+
228+
public setOwnerMeetingId(
229+
ownerMeetingId: string | undefined,
230+
sessionId: string = LLM_DEFAULT_SESSION
231+
): void {
232+
const channel = this.getSession(sessionId);
233+
234+
if (channel) channel.ownerMeetingId = ownerMeetingId;
235+
}
236+
237+
public getOwnerMeetingId(sessionId: string = LLM_DEFAULT_SESSION): string | undefined {
238+
return this.getSession(sessionId)?.ownerMeetingId;
239+
}
240+
241+
public resolveSessionOwnership(
242+
ownerMeetingId?: string,
243+
sessionId: string = LLM_DEFAULT_SESSION
244+
): {currentOwner: string | undefined; isOwner: boolean} {
245+
const currentOwner = this.getOwnerMeetingId(sessionId);
246+
const isOwner = !currentOwner || !ownerMeetingId || currentOwner === ownerMeetingId;
247+
248+
return {currentOwner, isOwner};
249+
}
250+
251+
public getConnectionByDatachannelUrl(url: string): LLMChannel | undefined {
252+
for (const channel of this.sessions.values()) {
253+
const datachannelUrl = channel.getDatachannelUrl();
254+
255+
if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(url, datachannelUrl)) {
256+
return channel;
257+
}
258+
}
259+
260+
return undefined;
261+
}
262+
263+
public getLocusUrlByDatachannelUrl(requestUrl: string): string | undefined {
264+
for (const channel of this.sessions.values()) {
265+
const datachannelUrl = channel.getDatachannelUrl();
266+
267+
if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(requestUrl, datachannelUrl)) {
268+
return channel.getLocusUrl();
269+
}
270+
}
271+
272+
return undefined;
273+
}
274+
275+
public getSessionIdByDatachannelUrl(requestUrl: string): string | undefined {
276+
for (const [sessionId, channel] of this.sessions.entries()) {
277+
const datachannelUrl = channel.getDatachannelUrl();
278+
279+
if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(requestUrl, datachannelUrl)) {
280+
return sessionId;
281+
}
282+
}
283+
284+
return undefined;
285+
}
286+
287+
public isDataChannelTokenEnabled(): Promise<boolean> {
288+
// @ts-ignore
289+
return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN);
290+
}
291+
292+
public getAllConnections(): Map<string, LLMChannel> {
293+
return new Map(this.sessions);
294+
}
295+
}
296+
297+
export {config};
298+
export default LLMPlugin;

0 commit comments

Comments
 (0)