diff --git a/docs/samples/browser-plugin-meetings/app.js b/docs/samples/browser-plugin-meetings/app.js index 2e4db88b0eb..393dd666683 100644 --- a/docs/samples/browser-plugin-meetings/app.js +++ b/docs/samples/browser-plugin-meetings/app.js @@ -804,6 +804,8 @@ const generalControlsDtmfTones = document.querySelector('#gc-dtmf-tones'); const generalControlsDtmfStatus = document.querySelector('#gc-dtmf-status'); const generalToggleTranscription = document.querySelector('#gc-toggle-transcription'); const generalTranscriptionContent = document.querySelector('#gc-transcription-content'); +const generalHighlightTranscription = document.querySelector('#gc-toggle-highlights'); +const generalHighlightContent = document.querySelector('#gc-highlights-content'); const sourceDevicesGetMedia = document.querySelector('#sd-get-media-devices'); const sourceDevicesAudioInput = document.querySelector('#sd-audio-input-devices'); @@ -1104,6 +1106,44 @@ async function toggleTranscription(enable = false){ } } +async function toggleHighlights() { + const isEnabled = generalHighlightTranscription.getAttribute('data-enabled') === "true"; + if (isEnabled) { + try { + await meeting.toggleHighlighting(!isEnabled); + generalHighlightTranscription.setAttribute('data-enabled', "false"); + generalHighlightTranscription.innerText = "Start Highlight"; + generalHighlightContent.innerHTML = 'Highlight Content: Webex Assistant must be enabled, check the console!'; + } + catch (e) { + console.error("Error stopping highlight", e); + } + } + else { + let firsttime = generalHighlightTranscription.dataset.firsttime; + if (firsttime === undefined) { + const currentMeeting = getCurrentMeeting(); + if (currentMeeting) { + generalHighlightContent.innerHTML = ''; + meeting.on('meeting:highlight-created', (payload) => { + generalHighlightContent.innerHTML = `\n${JSON.stringify(payload,null,4)}`; + }); + } + generalHighlightTranscription.dataset.firsttime = "yes"; + } + try { + generalHighlightContent.innerHTML = ''; + await meeting.toggleHighlighting(!isEnabled); + generalHighlightTranscription.setAttribute('data-enabled', "true"); + generalHighlightTranscription.innerText = "Stop Highlight"; + } + catch (e) { + generalHighlightContent.innerHTML = 'Highlight Content: Webex Assistant must be enabled, check the console!'; + console.error("Error starting highlight", e); + } + } +} + function setTranscriptEvents() { const meeting = getCurrentMeeting(); @@ -1144,6 +1184,9 @@ function setTranscriptEvents() { meeting.on('meeting:receiveTranscription:stopped', () => { generalToggleTranscription.innerText = "Start Transcription"; generalTranscriptionContent.innerHTML = 'Transcription Content: Webex Assistant must be enabled, check the console!'; + generalHighlightTranscription.setAttribute('data-enabled', "false"); + generalHighlightTranscription.innerText = "Start Highlight"; + generalHighlightContent.innerHTML = 'Highlight Content: Webex Assistant must be enabled, check the console!'; }); } else { diff --git a/docs/samples/browser-plugin-meetings/index.html b/docs/samples/browser-plugin-meetings/index.html index 1df78750757..9d002bb9d5a 100644 --- a/docs/samples/browser-plugin-meetings/index.html +++ b/docs/samples/browser-plugin-meetings/index.html @@ -851,6 +851,8 @@

+
Caption Language:
Spoken Language: @@ -858,6 +860,8 @@

+ diff --git a/packages/@webex/internal-plugin-voicea/src/index.ts b/packages/@webex/internal-plugin-voicea/src/index.ts index ab6b0539c5f..97e86e7c421 100644 --- a/packages/@webex/internal-plugin-voicea/src/index.ts +++ b/packages/@webex/internal-plugin-voicea/src/index.ts @@ -1,10 +1,11 @@ import * as WebexCore from '@webex/webex-core'; import VoiceaChannel from './voicea'; -import type {MeetingTranscriptPayload} from './voicea.types'; +import type {MeetingTranscriptPayload, MeetingHighlightPayload} from './voicea.types'; WebexCore.registerInternalPlugin('voicea', VoiceaChannel, {}); export {default} from './voicea'; export {type MeetingTranscriptPayload}; +export {type MeetingHighlightPayload}; export {EVENT_TRIGGERS, TURN_ON_CAPTION_STATUS} from './constants'; diff --git a/packages/@webex/internal-plugin-voicea/src/voicea.types.ts b/packages/@webex/internal-plugin-voicea/src/voicea.types.ts index 52ab28f2dcc..41a597fc0d9 100644 --- a/packages/@webex/internal-plugin-voicea/src/voicea.types.ts +++ b/packages/@webex/internal-plugin-voicea/src/voicea.types.ts @@ -103,6 +103,15 @@ type MeetingTranscriptPayload = { transcripts: Array; }; +type MeetingHighlightPayload = { + csis: string; + highlightId: string; + text: string; + highlightLabel: string; + highlightSource: string; + timestamp?: string; +}; + export type { AnnouncementPayload, CaptionLanguageResponse, @@ -111,4 +120,5 @@ export type { Highlight, IVoiceaChannel, MeetingTranscriptPayload, + MeetingHighlightPayload, }; diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index 26fbfcd6c7b..fcd24f74f98 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -365,6 +365,7 @@ export const EVENT_TRIGGERS = { MEETING_STOPPED_RECEIVING_TRANSCRIPTION: 'meeting:receiveTranscription:stopped', MEETING_MANUAL_CAPTION_UPDATED: 'meeting:manualCaptionControl:updated', MEETING_CAPTION_RECEIVED: 'meeting:caption-received', + MEETING_HIGHLIGHT_CREATED: 'meeting:highlight-created', }; export const EVENT_TYPES = { diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index e255e3f633b..18655baa6c8 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -42,9 +42,10 @@ import { EVENT_TRIGGERS as VOICEAEVENTS, TURN_ON_CAPTION_STATUS, type MeetingTranscriptPayload, + type MeetingHighlightPayload, } from '@webex/internal-plugin-voicea'; -import {processNewCaptions} from './voicea-meeting'; +import {processHighlightCreated, processNewCaptions} from './voicea-meeting'; import { MeetingNotActiveError, @@ -183,6 +184,17 @@ export type CaptionData = { speaker: string; }; +export type Highlight = { + id: string; + meta: { + label: string; + source: string; + }; + text: string; + timestamp: string; + speaker: any; +}; + export type Transcription = { languageOptions: { captionLanguages?: string; // list of supported caption languages from backend @@ -201,6 +213,7 @@ export type Transcription = { isCaptioning: boolean; speakerProxy: Map; interimCaptions: Map; + highlights: Array; }; export type LocalStreams = { @@ -680,6 +693,21 @@ export default class Meeting extends StatelessWebexPlugin { interimCaptions: this.transcription.interimCaptions, }); }, + [VOICEAEVENTS.HIGHLIGHT_CREATED]: (data: MeetingHighlightPayload) => { + processHighlightCreated({data, meeting: this}); + + LoggerProxy.logger.debug( + `${EventsUtil.getScopeLog({ + file: 'meeting/index', + function: 'setUpVoiceaListeners', + })}event#${EVENT_TRIGGERS.MEETING_HIGHLIGHT_CREATED}` + ); + + // @ts-ignore + this.trigger(EVENT_TRIGGERS.MEETING_HIGHLIGHT_CREATED, { + highlights: this.transcription.highlights, + }); + }, }; private addMediaData: { @@ -4944,6 +4972,48 @@ export default class Meeting extends StatelessWebexPlugin { } } + /** + * This method will toggle the highlights for the current meeting if the meeting has enabled/supports Webex Assistant + * @param {Boolean} activate flag to enable/disable highlights + * @param {Object} options object with spokenlanguage setting + * @public + * @returns {Promise} a promise to open the WebSocket connection + */ + public async toggleHighlighting(activate: boolean, options?: {spokenLanguage?: string}) { + if (this.isJoined() && this.isTranscriptionSupported()) { + LoggerProxy.logger.info( + 'Meeting:index#toggleHighlighting --> Attempting to enable highlights!' + ); + + try { + if (activate) { + // @ts-ignore + this.webex.internal.voicea.on( + VOICEAEVENTS.HIGHLIGHT_CREATED, + this.voiceaListenerCallbacks[VOICEAEVENTS.HIGHLIGHT_CREATED] + ); + } else { + // @ts-ignore + this.webex.internal.voicea.off( + VOICEAEVENTS.HIGHLIGHT_CREATED, + this.voiceaListenerCallbacks[VOICEAEVENTS.HIGHLIGHT_CREATED] + ); + } + // @ts-ignore + await this.webex.internal.voicea.toggleTranscribing(activate, options?.spokenLanguage); + } catch (error) { + LoggerProxy.logger.error(`Meeting:index#toggleHighlighting --> ${error}`); + Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.RECEIVE_TRANSCRIPTION_FAILURE, { + correlation_id: this.correlationId, + reason: error.message, + stack: error.stack, + }); + } + } else { + throw new Error('This operation is not allowed in your org. Contact org administrator.'); + } + } + /** * Callback called when a relay event is received from meeting LLM Connection * @param {RelayEvent} e Event object coming from LLM Connection diff --git a/packages/@webex/plugin-meetings/src/meeting/voicea-meeting.ts b/packages/@webex/plugin-meetings/src/meeting/voicea-meeting.ts index ae5e0e2b273..3e067a71a3b 100644 --- a/packages/@webex/plugin-meetings/src/meeting/voicea-meeting.ts +++ b/packages/@webex/plugin-meetings/src/meeting/voicea-meeting.ts @@ -1,4 +1,7 @@ -import {type MeetingTranscriptPayload} from '@webex/internal-plugin-voicea'; +import { + type MeetingTranscriptPayload, + type MeetingHighlightPayload, +} from '@webex/internal-plugin-voicea'; export const getSpeaker = (members, csis = []) => Object.values(members).find((member: any) => { @@ -118,3 +121,41 @@ export const processNewCaptions = ({ } transcriptData.interimCaptions[transcriptId] = interimTranscriptionIds; }; + +export const processHighlightCreated = ({ + data, + meeting, +}: { + data: MeetingHighlightPayload; + meeting: any; +}) => { + const transcriptData = meeting.transcription; + + if (!transcriptData.highlights) { + transcriptData.highlights = []; + } + + const csisKey = data.csis && data.csis.length > 0 ? data.csis[0] : undefined; + const {needsCaching, speaker} = getSpeakerFromProxyOrStore({ + meetingMembers: meeting.members.membersCollection.members, + transcriptData, + csisKey, + }); + + if (needsCaching) { + transcriptData.speakerProxy[csisKey] = speaker; + } + + const highlightCreated = { + id: data.highlightId, + meta: { + label: data.highlightLabel, + source: data.highlightSource, + }, + text: data.text, + timestamp: data.timestamp, + speaker, + }; + + meeting.transcription.highlights.push(highlightCreated); +}; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 24e352c815f..29324ddab67 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -1188,6 +1188,49 @@ describe('plugin-meetings', () => { }); }); + describe('#toggleHighlighting', () => { + beforeEach(() => { + webex.internal.voicea.on = sinon.stub(); + webex.internal.voicea.off = sinon.stub(); + webex.internal.voicea.listenToEvents = sinon.stub(); + webex.internal.voicea.toggleTranscribing = sinon.stub(); + meeting.isTranscriptionSupported = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should reject if transcription is not supported', (done) => { + meeting.isTranscriptionSupported.returns(false); + + meeting.toggleHighlighting(true).catch((error) => { + assert.equal(error.message, 'This operation is not allowed in your org. Contact org administrator.'); + done(); + }); + }); + + it('should enable highlights when meeting is joined and transcription is supported', async () => { + meeting.joinedWith = { + state: 'JOINED', + }; + meeting.isTranscriptionSupported.returns(true); + await meeting.toggleHighlighting(true); + assert.calledOnce(meeting.webex.internal.voicea.toggleTranscribing); + assert.equal(webex.internal.voicea.on.callCount, 1); + }); + + it('should disable highlights when meeting is joined and transcription is supported', async () => { + meeting.joinedWith = { + state: 'JOINED', + }; + meeting.isTranscriptionSupported.returns(true); + await meeting.toggleHighlighting(false) + assert.calledOnce(meeting.webex.internal.voicea.toggleTranscribing); + assert.equal(webex.internal.voicea.off.callCount, 1); + }); + }); + describe('#setCaptionLanguage', () => { beforeEach(() => { meeting.isTranscriptionSupported = sinon.stub(); @@ -1360,6 +1403,14 @@ describe('plugin-meetings', () => { EVENT_TRIGGERS.MEETING_CAPTION_RECEIVED ); }); + + it('should trigger meeting:highlight-created event', () => { + meeting.voiceaListenerCallbacks[VOICEAEVENTS.HIGHLIGHT_CREATED]({}); + assert.calledWith( + meeting.trigger, + EVENT_TRIGGERS.MEETING_HIGHLIGHT_CREATED + ); + }); }); describe('#isReactionsSupported', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/voicea-meeting.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/voicea-meeting.ts index d841cbad207..1c92aea6393 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/voicea-meeting.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/voicea-meeting.ts @@ -1,6 +1,7 @@ import { getSpeakerFromProxyOrStore, processNewCaptions, + processHighlightCreated, } from '@webex/plugin-meetings/src/meeting/voicea-meeting'; import {assert} from '@webex/test-helper-chai'; import { expect } from 'chai'; @@ -253,5 +254,51 @@ describe('plugin-meetings', () => { expect(newCaption.speaker).to.deep.equal(speaker); }); }); + + describe('processHighlightCreated', () => { + beforeEach(() => { + fakeVoiceaPayload = { + highlightId: 'highlight1', + highlightLabel: 'Important', + highlightSource: 'User', + text: 'This is a highlight', + timestamp: '2023-10-10T10:00:00Z', + csis: ['3060099329'], + }; + }); + + it('should initialize highlights array if it does not exist', () => { + fakeMeeting.transcription.highlights = undefined; + + processHighlightCreated({data: fakeVoiceaPayload, meeting: fakeMeeting}); + + expect(fakeMeeting.transcription.highlights).to.be.an('array'); + expect(fakeMeeting.transcription.highlights).to.have.lengthOf(1); + }); + + it('should process highlight creation correctly', () => { + processHighlightCreated({data: fakeVoiceaPayload, meeting: fakeMeeting}); + + const csisKey = fakeVoiceaPayload.csis[0]; + const speaker = fakeMeeting.transcription.speakerProxy[csisKey]; + expect(speaker).to.exist; + + const newHighlight = fakeMeeting.transcription.highlights.find( + (highlight) => highlight.id === fakeVoiceaPayload.highlightId + ); + expect(newHighlight).to.exist; + expect(newHighlight).to.deep.include({ + id: fakeVoiceaPayload.highlightId, + meta: { + label: fakeVoiceaPayload.highlightLabel, + source: fakeVoiceaPayload.highlightSource, + }, + text: fakeVoiceaPayload.text, + timestamp: fakeVoiceaPayload.timestamp, + }); + + expect(newHighlight.speaker).to.deep.equal(speaker); + }); + }); }); }); \ No newline at end of file