diff --git a/css/_meeting_intelligence.scss b/css/_meeting_intelligence.scss new file mode 100644 index 0000000..c95b948 --- /dev/null +++ b/css/_meeting_intelligence.scss @@ -0,0 +1,532 @@ +/** + * Meeting Intelligence Dashboard Styles + */ + +.meeting-intelligence-dashboard { + position: fixed; + right: 0; + top: 0; + width: 400px; + height: 100%; + background: rgba(28, 32, 37, 0.98); + border-left: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; + z-index: 350; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3); +} + +.meeting-intelligence-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + .meeting-intelligence-header-left { + display: flex; + align-items: center; + gap: 8px; + } + + h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #fff; + } + + .meeting-intelligence-back-btn, + .meeting-intelligence-close-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + } + } +} + +.meeting-intelligence-tabs { + display: flex; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + .tab-btn { + flex: 1; + padding: 12px 16px; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + position: relative; + + &:hover { + color: #fff; + background: rgba(255, 255, 255, 0.05); + } + + &.active { + color: #246FE5; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: #246FE5; + } + } + } +} + +.meeting-intelligence-content { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +// Recordings List +.recordings-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.recording-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + + .recording-item-icon { + color: rgba(255, 255, 255, 0.5); + } + + .recording-item-info { + flex: 1; + min-width: 0; + } + + .recording-item-title { + font-size: 14px; + font-weight: 500; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .recording-item-meta { + display: flex; + gap: 12px; + margin-top: 4px; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + } + + .status-badge { + font-size: 11px; + font-weight: 500; + padding: 4px 8px; + border-radius: 4px; + color: #fff; + white-space: nowrap; + } +} + +// Loading & Empty States +.recordings-loading, +.recordings-empty, +.transcript-loading, +.transcript-empty, +.summary-loading, +.summary-empty, +.search-hint, +.search-no-results { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: rgba(255, 255, 255, 0.6); + + svg { + margin-bottom: 16px; + opacity: 0.4; + } + + h3 { + margin: 0 0 8px; + font-size: 16px; + font-weight: 500; + color: rgba(255, 255, 255, 0.8); + } + + p { + margin: 0; + font-size: 14px; + line-height: 1.5; + } +} + +.recordings-error, +.transcript-error, +.summary-error, +.search-error { + padding: 16px; + background: rgba(244, 67, 54, 0.1); + border-radius: 8px; + color: #f44336; + font-size: 14px; +} + +// Spinner +.spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: #246FE5; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 12px; +} + +.spinner-small { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +// Transcript Viewer +.transcript-viewer { + .transcript-speakers { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .speaker-tag { + font-size: 12px; + font-weight: 500; + padding: 4px 10px; + border-radius: 12px; + color: #fff; + } + + .transcript-segments { + display: flex; + flex-direction: column; + gap: 16px; + } + + .transcript-group { + .transcript-speaker { + font-size: 13px; + font-weight: 600; + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 8px; + } + + .transcript-time { + font-size: 11px; + font-weight: 400; + color: rgba(255, 255, 255, 0.4); + } + + .transcript-text { + font-size: 14px; + line-height: 1.6; + color: rgba(255, 255, 255, 0.9); + } + } +} + +// Summary Panel +.summary-panel { + .summary-section { + margin-bottom: 24px; + + h3 { + margin: 0 0 12px; + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 0.5px; + } + } + + .summary-text { + font-size: 14px; + line-height: 1.6; + color: #fff; + margin: 0; + } + + .key-points-list, + .decisions-list { + margin: 0; + padding-left: 20px; + + li { + font-size: 14px; + line-height: 1.5; + color: rgba(255, 255, 255, 0.9); + margin-bottom: 8px; + + &::marker { + color: #246FE5; + } + } + } + + .action-items-list { + list-style: none; + margin: 0; + padding: 0; + + li { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 14px; + line-height: 1.5; + color: rgba(255, 255, 255, 0.9); + margin-bottom: 8px; + } + + .action-checkbox { + color: rgba(255, 255, 255, 0.5); + } + + .action-task { + flex: 1; + } + + .action-assignee { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + } + } + + .topics-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .topic-tag { + font-size: 12px; + padding: 4px 12px; + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + color: rgba(255, 255, 255, 0.8); + } + + .summary-export { + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + + .export-buttons { + display: flex; + gap: 8px; + } + } +} + +// Buttons +.btn-primary { + padding: 10px 20px; + background: #246FE5; + border: none; + border-radius: 6px; + color: #fff; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + margin-top: 16px; + + &:hover { + background: #1a5bc4; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.btn-secondary { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 6px; + color: rgba(255, 255, 255, 0.9); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +// Search Panel +.search-panel { + .search-form { + display: flex; + gap: 8px; + margin-bottom: 16px; + } + + .search-input { + flex: 1; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: #fff; + font-size: 14px; + outline: none; + transition: all 0.15s ease; + + &::placeholder { + color: rgba(255, 255, 255, 0.4); + } + + &:focus { + border-color: #246FE5; + background: rgba(255, 255, 255, 0.1); + } + } + + .search-button { + padding: 10px 14px; + background: #246FE5; + border: none; + border-radius: 6px; + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + + &:hover { + background: #1a5bc4; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +.search-results { + .search-results-header { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 12px; + } +} + +.search-result-item { + padding: 12px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + + .search-result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + } + + .search-result-title { + font-size: 14px; + font-weight: 500; + color: #fff; + } + + .search-result-date { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + } + + .search-result-snippet { + font-size: 13px; + line-height: 1.5; + color: rgba(255, 255, 255, 0.8); + margin-bottom: 8px; + } + + .search-result-speaker { + font-weight: 500; + color: #4FC3F7; + margin-right: 4px; + } + + .search-result-meta { + display: flex; + justify-content: space-between; + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + } +} + +// Responsive +@media (max-width: 768px) { + .meeting-intelligence-dashboard { + width: 100%; + } +} diff --git a/css/main.scss b/css/main.scss index 17302b2..8e7267b 100644 --- a/css/main.scss +++ b/css/main.scss @@ -77,5 +77,6 @@ $flagsImagePath: "../images/"; @import 'reactions-menu'; @import 'plan-limit'; @import 'shared_music'; +@import 'meeting_intelligence'; /* Modules END */ diff --git a/lang/main.json b/lang/main.json index bf301b8..d263837 100644 --- a/lang/main.json +++ b/lang/main.json @@ -1385,6 +1385,8 @@ "sharedvideo": "Share video", "sharedmusic": "Share music", "stopSharedMusic": "Stop music", + "meetingIntelligence": "Meeting Intelligence", + "closeMeetingIntelligence": "Close Meeting Intelligence", "shortcuts": "Toggle shortcuts", "show": "Show on stage", "showWhiteboard": "Show whiteboard", @@ -1501,6 +1503,8 @@ "sharedvideo": "Share video", "sharedmusic": "Share music", "stopSharedMusic": "Stop music", + "meetingIntelligence": "Meeting Intelligence", + "closeMeetingIntelligence": "Close Meeting Intelligence", "shortcuts": "View shortcuts", "showWhiteboard": "Show whiteboard", "silence": "Silence", diff --git a/react/features/app/reducers.web.ts b/react/features/app/reducers.web.ts index 238a196..6b890d3 100644 --- a/react/features/app/reducers.web.ts +++ b/react/features/app/reducers.web.ts @@ -19,5 +19,6 @@ import '../talk-while-muted/reducer'; import '../virtual-background/reducer'; import '../web-hid/reducer'; import '../file-sharing/reducer'; +import '../meeting-intelligence/reducer'; import './reducers.any'; diff --git a/react/features/app/types.ts b/react/features/app/types.ts index a7ed827..3ef154a 100644 --- a/react/features/app/types.ts +++ b/react/features/app/types.ts @@ -48,6 +48,7 @@ import { IJaaSState } from '../jaas/reducer'; import { IKeyboardShortcutsState } from '../keyboard-shortcuts/types'; import { ILargeVideoState } from '../large-video/reducer'; import { ILobbyState } from '../lobby/reducer'; +import { IMeetingIntelligenceState } from '../meeting-intelligence/types'; import { IMobileAudioModeState } from '../mobile/audio-mode/reducer'; import { IMobileBackgroundState } from '../mobile/background/reducer'; import { ICallIntegrationState } from '../mobile/call-integration/reducer'; @@ -139,6 +140,7 @@ export interface IReduxState { 'features/keyboard-shortcuts': IKeyboardShortcutsState; 'features/large-video': ILargeVideoState; 'features/lobby': ILobbyState; + 'features/meeting-intelligence': IMeetingIntelligenceState; 'features/mobile/audio-mode': IMobileAudioModeState; 'features/mobile/background': IMobileBackgroundState; 'features/mobile/external-api': IMobileExternalApiState; diff --git a/react/features/base/config/configType.ts b/react/features/base/config/configType.ts index eed32a9..50737f0 100644 --- a/react/features/base/config/configType.ts +++ b/react/features/base/config/configType.ts @@ -478,6 +478,13 @@ export interface IConfig { logging?: ILoggingConfig; mainToolbarButtons?: Array>; maxFullResolutionParticipants?: number; + meetingIntelligence?: { + apiUrl?: string; + autoTranscribe?: boolean; + enabled?: boolean; + exportFormats?: Array<'pdf' | 'markdown' | 'json'>; + standalonePageEnabled?: boolean; + }; microsoftApiApplicationClientID?: string; moderatedRoomServiceUrl?: string; mouseMoveCallbackInterval?: number; diff --git a/react/features/conference/components/web/Conference.tsx b/react/features/conference/components/web/Conference.tsx index 5df9771..fb14f77 100644 --- a/react/features/conference/components/web/Conference.tsx +++ b/react/features/conference/components/web/Conference.tsx @@ -22,6 +22,7 @@ import CalleeInfoContainer from '../../../invite/components/callee-info/CalleeIn import LargeVideo from '../../../large-video/components/LargeVideo.web'; import LobbyScreen from '../../../lobby/components/web/LobbyScreen'; import { getIsLobbyVisible } from '../../../lobby/functions'; +import { MeetingIntelligenceDashboard } from '../../../meeting-intelligence/components'; import { getOverlayToRender } from '../../../overlay/functions.web'; import ParticipantsPane from '../../../participants-pane/components/web/ParticipantsPane'; import Prejoin from '../../../prejoin/components/web/Prejoin'; @@ -323,6 +324,7 @@ class Conference extends AbstractConference { { _showVisitorsQueue && } + ); diff --git a/react/features/meeting-intelligence/actionTypes.ts b/react/features/meeting-intelligence/actionTypes.ts new file mode 100644 index 0000000..a020a9b --- /dev/null +++ b/react/features/meeting-intelligence/actionTypes.ts @@ -0,0 +1,78 @@ +/** + * Action types for Meeting Intelligence feature. + */ + +/** + * Toggle the meeting intelligence dashboard visibility. + */ +export const TOGGLE_MEETING_INTELLIGENCE = 'TOGGLE_MEETING_INTELLIGENCE'; + +/** + * Set the active tab in the dashboard. + */ +export const SET_ACTIVE_TAB = 'SET_MEETING_INTELLIGENCE_ACTIVE_TAB'; + +/** + * Select a meeting for detailed view. + */ +export const SELECT_MEETING = 'SELECT_MEETING'; + +/** + * Clear the selected meeting. + */ +export const CLEAR_SELECTED_MEETING = 'CLEAR_SELECTED_MEETING'; + +/** + * Fetch meetings list. + */ +export const FETCH_MEETINGS_REQUEST = 'FETCH_MEETINGS_REQUEST'; +export const FETCH_MEETINGS_SUCCESS = 'FETCH_MEETINGS_SUCCESS'; +export const FETCH_MEETINGS_FAILURE = 'FETCH_MEETINGS_FAILURE'; + +/** + * Fetch transcript for a meeting. + */ +export const FETCH_TRANSCRIPT_REQUEST = 'FETCH_TRANSCRIPT_REQUEST'; +export const FETCH_TRANSCRIPT_SUCCESS = 'FETCH_TRANSCRIPT_SUCCESS'; +export const FETCH_TRANSCRIPT_FAILURE = 'FETCH_TRANSCRIPT_FAILURE'; + +/** + * Fetch summary for a meeting. + */ +export const FETCH_SUMMARY_REQUEST = 'FETCH_SUMMARY_REQUEST'; +export const FETCH_SUMMARY_SUCCESS = 'FETCH_SUMMARY_SUCCESS'; +export const FETCH_SUMMARY_FAILURE = 'FETCH_SUMMARY_FAILURE'; + +/** + * Generate summary for a meeting. + */ +export const GENERATE_SUMMARY_REQUEST = 'GENERATE_SUMMARY_REQUEST'; +export const GENERATE_SUMMARY_SUCCESS = 'GENERATE_SUMMARY_SUCCESS'; +export const GENERATE_SUMMARY_FAILURE = 'GENERATE_SUMMARY_FAILURE'; + +/** + * Fetch speaker stats for a meeting. + */ +export const FETCH_SPEAKER_STATS_SUCCESS = 'FETCH_SPEAKER_STATS_SUCCESS'; + +/** + * Search transcripts. + */ +export const SEARCH_REQUEST = 'MEETING_INTELLIGENCE_SEARCH_REQUEST'; +export const SEARCH_SUCCESS = 'MEETING_INTELLIGENCE_SEARCH_SUCCESS'; +export const SEARCH_FAILURE = 'MEETING_INTELLIGENCE_SEARCH_FAILURE'; +export const SET_SEARCH_QUERY = 'SET_MEETING_INTELLIGENCE_SEARCH_QUERY'; +export const CLEAR_SEARCH = 'CLEAR_MEETING_INTELLIGENCE_SEARCH'; + +/** + * Export meeting. + */ +export const EXPORT_REQUEST = 'MEETING_INTELLIGENCE_EXPORT_REQUEST'; +export const EXPORT_SUCCESS = 'MEETING_INTELLIGENCE_EXPORT_SUCCESS'; +export const EXPORT_FAILURE = 'MEETING_INTELLIGENCE_EXPORT_FAILURE'; +export const SET_EXPORT_FORMAT = 'SET_MEETING_INTELLIGENCE_EXPORT_FORMAT'; + +/** + * Update meeting status (from polling). + */ +export const UPDATE_MEETING_STATUS = 'UPDATE_MEETING_STATUS'; diff --git a/react/features/meeting-intelligence/actions.ts b/react/features/meeting-intelligence/actions.ts new file mode 100644 index 0000000..c281055 --- /dev/null +++ b/react/features/meeting-intelligence/actions.ts @@ -0,0 +1,402 @@ +/** + * Action creators for Meeting Intelligence feature. + */ + +import { IStore } from '../app/types'; + +import { + CLEAR_SEARCH, + CLEAR_SELECTED_MEETING, + EXPORT_FAILURE, + EXPORT_REQUEST, + EXPORT_SUCCESS, + FETCH_MEETINGS_FAILURE, + FETCH_MEETINGS_REQUEST, + FETCH_MEETINGS_SUCCESS, + FETCH_SPEAKER_STATS_SUCCESS, + FETCH_SUMMARY_FAILURE, + FETCH_SUMMARY_REQUEST, + FETCH_SUMMARY_SUCCESS, + FETCH_TRANSCRIPT_FAILURE, + FETCH_TRANSCRIPT_REQUEST, + FETCH_TRANSCRIPT_SUCCESS, + GENERATE_SUMMARY_FAILURE, + GENERATE_SUMMARY_REQUEST, + GENERATE_SUMMARY_SUCCESS, + SEARCH_FAILURE, + SEARCH_REQUEST, + SEARCH_SUCCESS, + SELECT_MEETING, + SET_ACTIVE_TAB, + SET_EXPORT_FORMAT, + SET_SEARCH_QUERY, + TOGGLE_MEETING_INTELLIGENCE, + UPDATE_MEETING_STATUS +} from './actionTypes'; +import { API_BASE_URL } from './constants'; +import { + IMeeting, + IMeetingSummary, + ISearchResult, + ISpeakerStats, + ITranscriptSegment +} from './types'; + +/** + * Toggle the meeting intelligence dashboard. + * + * @returns {Object} The action object. + */ +export function toggleMeetingIntelligence() { + return { + type: TOGGLE_MEETING_INTELLIGENCE + }; +} + +/** + * Set the active tab. + * + * @param {string} tab - The tab to set active. + * @returns {Object} The action object. + */ +export function setActiveTab(tab: 'recordings' | 'transcript' | 'summary' | 'search') { + return { + type: SET_ACTIVE_TAB, + tab + }; +} + +/** + * Select a meeting for detailed view. + * + * @param {string} meetingId - The meeting ID to select. + * @returns {Function} Async thunk action. + */ +export function selectMeeting(meetingId: string) { + return async (dispatch: IStore['dispatch']) => { + dispatch({ type: SELECT_MEETING, meetingId }); + + // Fetch transcript and summary for the selected meeting + dispatch(fetchTranscript(meetingId)); + dispatch(fetchSummary(meetingId)); + dispatch(fetchSpeakerStats(meetingId)); + }; +} + +/** + * Clear the selected meeting. + * + * @returns {Object} The action object. + */ +export function clearSelectedMeeting() { + return { + type: CLEAR_SELECTED_MEETING + }; +} + +/** + * Fetch the list of meetings. + * + * @returns {Function} Async thunk action. + */ +export function fetchMeetings() { + return async (dispatch: IStore['dispatch']) => { + dispatch({ type: FETCH_MEETINGS_REQUEST }); + + try { + const response = await fetch(`${API_BASE_URL}/meetings`); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + + const data = await response.json(); + + dispatch({ + type: FETCH_MEETINGS_SUCCESS, + meetings: data.meetings as IMeeting[] + }); + } catch (error) { + dispatch({ + type: FETCH_MEETINGS_FAILURE, + error: error instanceof Error ? error.message : 'Failed to fetch meetings' + }); + } + }; +} + +/** + * Fetch transcript for a meeting. + * + * @param {string} meetingId - The meeting ID. + * @returns {Function} Async thunk action. + */ +export function fetchTranscript(meetingId: string) { + return async (dispatch: IStore['dispatch']) => { + dispatch({ type: FETCH_TRANSCRIPT_REQUEST }); + + try { + const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/transcript`); + + if (!response.ok) { + if (response.status === 404) { + dispatch({ + type: FETCH_TRANSCRIPT_SUCCESS, + transcript: [] + }); + + return; + } + throw new Error(`HTTP error: ${response.status}`); + } + + const data = await response.json(); + + dispatch({ + type: FETCH_TRANSCRIPT_SUCCESS, + transcript: data.segments as ITranscriptSegment[] + }); + } catch (error) { + dispatch({ + type: FETCH_TRANSCRIPT_FAILURE, + error: error instanceof Error ? error.message : 'Failed to fetch transcript' + }); + } + }; +} + +/** + * Fetch summary for a meeting. + * + * @param {string} meetingId - The meeting ID. + * @returns {Function} Async thunk action. + */ +export function fetchSummary(meetingId: string) { + return async (dispatch: IStore['dispatch']) => { + dispatch({ type: FETCH_SUMMARY_REQUEST }); + + try { + const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`); + + if (!response.ok) { + if (response.status === 404) { + dispatch({ + type: FETCH_SUMMARY_SUCCESS, + summary: null + }); + + return; + } + throw new Error(`HTTP error: ${response.status}`); + } + + const data = await response.json(); + + dispatch({ + type: FETCH_SUMMARY_SUCCESS, + summary: data as IMeetingSummary + }); + } catch (error) { + dispatch({ + type: FETCH_SUMMARY_FAILURE, + error: error instanceof Error ? error.message : 'Failed to fetch summary' + }); + } + }; +} + +/** + * Generate AI summary for a meeting. + * + * @param {string} meetingId - The meeting ID. + * @returns {Function} Async thunk action. + */ +export function generateSummary(meetingId: string) { + return async (dispatch: IStore['dispatch']) => { + dispatch({ type: GENERATE_SUMMARY_REQUEST }); + + try { + const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + + const data = await response.json(); + + dispatch({ + type: GENERATE_SUMMARY_SUCCESS, + summary: data as IMeetingSummary + }); + } catch (error) { + dispatch({ + type: GENERATE_SUMMARY_FAILURE, + error: error instanceof Error ? error.message : 'Failed to generate summary' + }); + } + }; +} + +/** + * Fetch speaker statistics for a meeting. + * + * @param {string} meetingId - The meeting ID. + * @returns {Function} Async thunk action. + */ +export function fetchSpeakerStats(meetingId: string) { + return async (dispatch: IStore['dispatch']) => { + try { + const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/speakers`); + + if (!response.ok) { + return; + } + + const data = await response.json(); + + dispatch({ + type: FETCH_SPEAKER_STATS_SUCCESS, + speakerStats: data.speakers as ISpeakerStats[] + }); + } catch { + // Silently ignore speaker stats errors + } + }; +} + +/** + * Search transcripts. + * + * @param {string} query - The search query. + * @returns {Function} Async thunk action. + */ +export function searchTranscripts(query: string) { + return async (dispatch: IStore['dispatch']) => { + dispatch({ type: SET_SEARCH_QUERY, query }); + + if (!query.trim()) { + dispatch({ type: CLEAR_SEARCH }); + + return; + } + + dispatch({ type: SEARCH_REQUEST }); + + try { + const response = await fetch(`${API_BASE_URL}/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, limit: 50 }) + }); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + + const data = await response.json(); + + dispatch({ + type: SEARCH_SUCCESS, + results: data.results as ISearchResult[] + }); + } catch (error) { + dispatch({ + type: SEARCH_FAILURE, + error: error instanceof Error ? error.message : 'Search failed' + }); + } + }; +} + +/** + * Clear search results. + * + * @returns {Object} The action object. + */ +export function clearSearch() { + return { type: CLEAR_SEARCH }; +} + +/** + * Set export format. + * + * @param {string} format - The export format. + * @returns {Object} The action object. + */ +export function setExportFormat(format: 'pdf' | 'markdown' | 'json') { + return { + type: SET_EXPORT_FORMAT, + format + }; +} + +/** + * Export a meeting. + * + * @param {string} meetingId - The meeting ID. + * @param {string} format - The export format. + * @returns {Function} Async thunk action. + */ +export function exportMeeting(meetingId: string, format: string) { + return async (dispatch: IStore['dispatch']) => { + dispatch({ type: EXPORT_REQUEST }); + + try { + const response = await fetch( + `${API_BASE_URL}/meetings/${meetingId}/export?format=${format}` + ); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + + // Get filename from content-disposition header + const contentDisposition = response.headers.get('content-disposition'); + let filename = `meeting-${meetingId}.${format === 'markdown' ? 'md' : format}`; + + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/); + + if (match) { + filename = match[1]; + } + } + + // Download the file + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + dispatch({ type: EXPORT_SUCCESS }); + } catch (error) { + dispatch({ + type: EXPORT_FAILURE, + error: error instanceof Error ? error.message : 'Export failed' + }); + } + }; +} + +/** + * Update meeting status (from polling). + * + * @param {string} meetingId - The meeting ID. + * @param {string} status - The new status. + * @returns {Object} The action object. + */ +export function updateMeetingStatus(meetingId: string, status: string) { + return { + type: UPDATE_MEETING_STATUS, + meetingId, + status + }; +} diff --git a/react/features/meeting-intelligence/components/index.ts b/react/features/meeting-intelligence/components/index.ts new file mode 100644 index 0000000..d8c3921 --- /dev/null +++ b/react/features/meeting-intelligence/components/index.ts @@ -0,0 +1 @@ +export * from './index.web'; diff --git a/react/features/meeting-intelligence/components/index.web.ts b/react/features/meeting-intelligence/components/index.web.ts new file mode 100644 index 0000000..0da4a6c --- /dev/null +++ b/react/features/meeting-intelligence/components/index.web.ts @@ -0,0 +1,6 @@ +export { default as MeetingIntelligenceButton } from './web/MeetingIntelligenceButton'; +export { default as MeetingIntelligenceDashboard } from './web/MeetingIntelligenceDashboard'; +export { default as RecordingsList } from './web/RecordingsList'; +export { default as TranscriptViewer } from './web/TranscriptViewer'; +export { default as SummaryPanel } from './web/SummaryPanel'; +export { default as SearchPanel } from './web/SearchPanel'; diff --git a/react/features/meeting-intelligence/components/web/MeetingIntelligenceButton.ts b/react/features/meeting-intelligence/components/web/MeetingIntelligenceButton.ts new file mode 100644 index 0000000..5e2f434 --- /dev/null +++ b/react/features/meeting-intelligence/components/web/MeetingIntelligenceButton.ts @@ -0,0 +1,66 @@ +import { connect } from 'react-redux'; + +import { IReduxState } from '../../../app/types'; +import { translate } from '../../../base/i18n/functions'; +import { IconMeter } from '../../../base/icons/svg'; +import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton'; +import { toggleMeetingIntelligence } from '../../actions'; +import { isDashboardOpen, isMeetingIntelligenceEnabled } from '../../functions'; + +interface IProps extends AbstractButtonProps { + + /** + * Whether or not the dashboard is open. + */ + _isDashboardOpen: boolean; +} + +/** + * Implements a button to open the Meeting Intelligence dashboard. + */ +class MeetingIntelligenceButton extends AbstractButton { + override accessibilityLabel = 'toolbar.accessibilityLabel.meetingIntelligence'; + override toggledAccessibilityLabel = 'toolbar.accessibilityLabel.closeMeetingIntelligence'; + override icon = IconMeter; + override label = 'toolbar.meetingIntelligence'; + override toggledLabel = 'toolbar.closeMeetingIntelligence'; + override tooltip = 'toolbar.meetingIntelligence'; + override toggledTooltip = 'toolbar.closeMeetingIntelligence'; + + /** + * Handles clicking / pressing the button. + * + * @private + * @returns {void} + */ + override _handleClick() { + this.props.dispatch(toggleMeetingIntelligence()); + } + + /** + * Indicates whether this button is in toggled state or not. + * + * @override + * @protected + * @returns {boolean} + */ + override _isToggled() { + return this.props._isDashboardOpen; + } +} + +/** + * Maps part of the Redux state to the props of this component. + * + * @param {Object} state - The Redux state. + * @private + * @returns {IProps} + */ +function _mapStateToProps(state: IReduxState) { + return { + _isDashboardOpen: isDashboardOpen(state), + visible: isMeetingIntelligenceEnabled(state) + }; +} + +export default translate(connect(_mapStateToProps)(MeetingIntelligenceButton)); diff --git a/react/features/meeting-intelligence/components/web/MeetingIntelligenceDashboard.tsx b/react/features/meeting-intelligence/components/web/MeetingIntelligenceDashboard.tsx new file mode 100644 index 0000000..0a31fd5 --- /dev/null +++ b/react/features/meeting-intelligence/components/web/MeetingIntelligenceDashboard.tsx @@ -0,0 +1,149 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { IReduxState } from '../../../app/types'; +import { + clearSelectedMeeting, + fetchMeetings, + setActiveTab, + toggleMeetingIntelligence +} from '../../actions'; +import { getMeetingIntelligenceState } from '../../functions'; + +import RecordingsList from './RecordingsList'; +import SearchPanel from './SearchPanel'; +import SummaryPanel from './SummaryPanel'; +import TranscriptViewer from './TranscriptViewer'; + +/** + * Meeting Intelligence Dashboard side panel. + * + * @returns {React.ReactElement|null} The dashboard or null if closed. + */ +const MeetingIntelligenceDashboard: React.FC = () => { + const dispatch = useDispatch(); + const { + isOpen, + activeTab, + selectedMeeting + } = useSelector((state: IReduxState) => getMeetingIntelligenceState(state)); + + // Fetch meetings on open + useEffect(() => { + if (isOpen) { + dispatch(fetchMeetings() as any); + } + }, [ dispatch, isOpen ]); + + const handleClose = useCallback(() => { + dispatch(toggleMeetingIntelligence()); + }, [ dispatch ]); + + const handleBack = useCallback(() => { + dispatch(clearSelectedMeeting()); + }, [ dispatch ]); + + const handleTranscriptTab = useCallback(() => { + dispatch(setActiveTab('transcript')); + }, [ dispatch ]); + + const handleSummaryTab = useCallback(() => { + dispatch(setActiveTab('summary')); + }, [ dispatch ]); + + const handleRecordingsTab = useCallback(() => { + dispatch(setActiveTab('recordings')); + }, [ dispatch ]); + + const handleSearchTab = useCallback(() => { + dispatch(setActiveTab('search')); + }, [ dispatch ]); + + if (!isOpen) { + return null; + } + + return ( +
+
+
+ {selectedMeeting && ( + + )} +

Meeting Intelligence

+
+ +
+ + {selectedMeeting ? ( + <> +
+ + +
+ +
+ {activeTab === 'transcript' && } + {activeTab === 'summary' && } +
+ + ) : ( + <> +
+ + +
+ +
+ {activeTab === 'recordings' && } + {activeTab === 'search' && } +
+ + )} +
+ ); +}; + +export default MeetingIntelligenceDashboard; diff --git a/react/features/meeting-intelligence/components/web/RecordingsList.tsx b/react/features/meeting-intelligence/components/web/RecordingsList.tsx new file mode 100644 index 0000000..10d653f --- /dev/null +++ b/react/features/meeting-intelligence/components/web/RecordingsList.tsx @@ -0,0 +1,110 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { IReduxState } from '../../../app/types'; +import { selectMeeting } from '../../actions'; +import { formatDate, formatDuration, getMeetingIntelligenceState, getStatusColor, getStatusLabel } from '../../functions'; + +/** + * List of meeting recordings. + * + * @returns {React.ReactElement} The recordings list component. + */ +const RecordingsList: React.FC = () => { + const dispatch = useDispatch(); + const { meetings, meetingsLoading, meetingsError } = useSelector( + (state: IReduxState) => getMeetingIntelligenceState(state) + ); + + const handleSelectMeeting = useCallback((e: React.MouseEvent | React.KeyboardEvent) => { + const meetingId = e.currentTarget.dataset.meetingId; + + if (meetingId) { + dispatch(selectMeeting(meetingId) as any); + } + }, [ dispatch ]); + + if (meetingsLoading) { + return ( +
+
+ Loading recordings... +
+ ); + } + + if (meetingsError) { + return ( +
+

Failed to load recordings: {meetingsError}

+
+ ); + } + + if (meetings.length === 0) { + return ( +
+ + + +

No recordings yet

+

Recordings will appear here after you record a meeting.

+
+ ); + } + + return ( +
+ {meetings.map(meeting => ( +
+
+ + + +
+
+
+ {meeting.title || meeting.conference_id} +
+
+ {meeting.started_at && ( + + {formatDate(meeting.started_at)} + + )} + {meeting.duration_seconds && ( + + {formatDuration(meeting.duration_seconds)} + + )} +
+
+
+ + {getStatusLabel(meeting.status)} + +
+
+ ))} +
+ ); +}; + +export default RecordingsList; diff --git a/react/features/meeting-intelligence/components/web/SearchPanel.tsx b/react/features/meeting-intelligence/components/web/SearchPanel.tsx new file mode 100644 index 0000000..f3e1415 --- /dev/null +++ b/react/features/meeting-intelligence/components/web/SearchPanel.tsx @@ -0,0 +1,150 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { IReduxState } from '../../../app/types'; +import { searchTranscripts, selectMeeting } from '../../actions'; +import { formatDate, formatTime, getMeetingIntelligenceState } from '../../functions'; + +/** + * Search panel for searching across all meeting transcripts. + * + * @returns {React.ReactElement} The search panel component. + */ +const SearchPanel: React.FC = () => { + const dispatch = useDispatch(); + const { searchQuery, searchResults, searchLoading, searchError } = useSelector( + (state: IReduxState) => getMeetingIntelligenceState(state) + ); + const [ inputValue, setInputValue ] = useState(searchQuery); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + setInputValue(e.target.value); + }, []); + + const handleSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + dispatch(searchTranscripts(inputValue) as any); + }, [ dispatch, inputValue ]); + + const handleResultClick = useCallback((e: React.MouseEvent | React.KeyboardEvent) => { + const meetingId = e.currentTarget.dataset.meetingId; + + if (meetingId) { + dispatch(selectMeeting(meetingId) as any); + } + }, [ dispatch ]); + + return ( +
+
+ + +
+ + {searchError && ( +
+

Search failed: {searchError}

+
+ )} + + {searchResults.length > 0 && ( +
+
+ {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} found +
+ {searchResults.map((result, index) => ( +
+
+ + {result.title || result.conference_id} + + {result.started_at && ( + + {formatDate(result.started_at)} + + )} +
+
+ {result.speaker_label && ( + + {result.speaker_label}: + + )} + + {result.segment_text} + +
+
+ + at {formatTime(result.start_time)} + + + Relevance: {Math.round(result.score * 100)}% + +
+
+ ))} +
+ )} + + {searchQuery && searchResults.length === 0 && !searchLoading && !searchError && ( +
+ + + +

No results found

+

Try different keywords or check the spelling.

+
+ )} + + {!searchQuery && ( +
+ + + +

Search across all meetings

+

Find specific topics, decisions, or action items mentioned in any meeting.

+
+ )} +
+ ); +}; + +export default SearchPanel; diff --git a/react/features/meeting-intelligence/components/web/SummaryPanel.tsx b/react/features/meeting-intelligence/components/web/SummaryPanel.tsx new file mode 100644 index 0000000..ecde6ec --- /dev/null +++ b/react/features/meeting-intelligence/components/web/SummaryPanel.tsx @@ -0,0 +1,195 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { IReduxState } from '../../../app/types'; +import { exportMeeting, generateSummary } from '../../actions'; +import { getMeetingIntelligenceState } from '../../functions'; + +/** + * AI-generated meeting summary panel. + * + * @returns {React.ReactElement} The summary panel component. + */ +const SummaryPanel: React.FC = () => { + const dispatch = useDispatch(); + const { + summary, + summaryLoading, + summaryError, + selectedMeetingId, + selectedMeeting, + exportLoading + } = useSelector((state: IReduxState) => getMeetingIntelligenceState(state)); + + const handleGenerateSummary = useCallback(() => { + if (selectedMeetingId) { + dispatch(generateSummary(selectedMeetingId) as any); + } + }, [ dispatch, selectedMeetingId ]); + + const handleExportMarkdown = useCallback(() => { + if (selectedMeetingId) { + dispatch(exportMeeting(selectedMeetingId, 'markdown') as any); + } + }, [ dispatch, selectedMeetingId ]); + + const handleExportPdf = useCallback(() => { + if (selectedMeetingId) { + dispatch(exportMeeting(selectedMeetingId, 'pdf') as any); + } + }, [ dispatch, selectedMeetingId ]); + + const handleExportJson = useCallback(() => { + if (selectedMeetingId) { + dispatch(exportMeeting(selectedMeetingId, 'json') as any); + } + }, [ dispatch, selectedMeetingId ]); + + if (summaryLoading) { + return ( +
+
+ Loading summary... +
+ ); + } + + if (summaryError) { + return ( +
+

Failed to load summary: {summaryError}

+ +
+ ); + } + + if (!summary) { + const canGenerate = selectedMeeting?.status === 'ready'; + + return ( +
+ + + + +

No summary yet

+

+ {canGenerate + ? 'Generate an AI summary to see key points, action items, and decisions.' + : 'Summary will be available after transcription is complete.'} +

+ {canGenerate && ( + + )} +
+ ); + } + + return ( +
+
+

Summary

+

{summary.summary_text}

+
+ + {summary.key_points && summary.key_points.length > 0 && ( +
+

Key Points

+
    + {summary.key_points.map((point, index) => ( +
  • {point}
  • + ))} +
+
+ )} + + {summary.action_items && summary.action_items.length > 0 && ( +
+

Action Items

+
    + {summary.action_items.map((item, index) => ( +
  • + + {item.task} + {item.assignee && ( + + ({item.assignee}) + + )} +
  • + ))} +
+
+ )} + + {summary.decisions && summary.decisions.length > 0 && ( +
+

Decisions

+
    + {summary.decisions.map((decision, index) => ( +
  • {decision}
  • + ))} +
+
+ )} + + {summary.topics && summary.topics.length > 0 && ( +
+

Topics Discussed

+
+ {summary.topics.map((topic, index) => ( + + {topic} + + ))} +
+
+ )} + +
+

Export

+
+ + + +
+
+
+ ); +}; + +export default SummaryPanel; diff --git a/react/features/meeting-intelligence/components/web/TranscriptViewer.tsx b/react/features/meeting-intelligence/components/web/TranscriptViewer.tsx new file mode 100644 index 0000000..37eb86e --- /dev/null +++ b/react/features/meeting-intelligence/components/web/TranscriptViewer.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { IReduxState } from '../../../app/types'; +import { + formatTime, + getMeetingIntelligenceState, + getSpeakerColor, + getUniqueSpeakers, + groupSegmentsBySpeaker +} from '../../functions'; + +/** + * Transcript viewer component with speaker labels. + * + * @returns {React.ReactElement} The transcript viewer component. + */ +const TranscriptViewer: React.FC = () => { + const { transcript, transcriptLoading, transcriptError, selectedMeeting } = useSelector( + (state: IReduxState) => getMeetingIntelligenceState(state) + ); + + if (transcriptLoading) { + return ( +
+
+ Loading transcript... +
+ ); + } + + if (transcriptError) { + return ( +
+

Failed to load transcript: {transcriptError}

+
+ ); + } + + if (!transcript || transcript.length === 0) { + const isProcessing = selectedMeeting?.status !== 'ready' && selectedMeeting?.status !== 'failed'; + + return ( +
+ {isProcessing ? ( + <> +
+

Transcript in progress

+

The transcript is being generated. Please check back soon.

+ + ) : ( + <> + + + +

No transcript available

+

This meeting does not have a transcript yet.

+ + )} +
+ ); + } + + const speakerLabels = getUniqueSpeakers(transcript); + const groupedSegments = groupSegmentsBySpeaker(transcript); + + return ( +
+
+ {speakerLabels.map(speaker => ( + + {speaker} + + ))} +
+ +
+ {groupedSegments.map((group, groupIndex) => ( +
+
+ {group.speaker} + + {formatTime(group.segments[0].start_time)} + +
+
+ {group.segments.map(segment => segment.text).join(' ')} +
+
+ ))} +
+
+ ); +}; + +export default TranscriptViewer; diff --git a/react/features/meeting-intelligence/constants.ts b/react/features/meeting-intelligence/constants.ts new file mode 100644 index 0000000..ae52551 --- /dev/null +++ b/react/features/meeting-intelligence/constants.ts @@ -0,0 +1,70 @@ +/** + * Constants for Meeting Intelligence feature. + */ + +/** + * API base URL for meeting intelligence endpoints. + * Uses the integrated path on the same domain. + */ +export const API_BASE_URL = '/api/intelligence'; + +/** + * Status to display text mapping. + */ +export const STATUS_LABELS: Record = { + recording: 'Recording', + extracting_audio: 'Extracting Audio', + transcribing: 'Transcribing', + diarizing: 'Identifying Speakers', + summarizing: 'Generating Summary', + ready: 'Ready', + failed: 'Failed' +}; + +/** + * Status to color mapping. + */ +export const STATUS_COLORS: Record = { + recording: '#FF5722', + extracting_audio: '#2196F3', + transcribing: '#9C27B0', + diarizing: '#00BCD4', + summarizing: '#4CAF50', + ready: '#8BC34A', + failed: '#F44336' +}; + +/** + * Speaker colors for transcript display. + */ +export const SPEAKER_COLORS = [ + '#4FC3F7', // Light Blue + '#81C784', // Light Green + '#FFB74D', // Orange + '#F06292', // Pink + '#BA68C8', // Purple + '#4DB6AC', // Teal + '#FFD54F', // Amber + '#7986CB', // Indigo + '#A1887F', // Brown + '#90A4AE' // Blue Grey +]; + +/** + * Export format options. + */ +export const EXPORT_FORMATS = [ + { value: 'markdown', label: 'Markdown (.md)' }, + { value: 'pdf', label: 'PDF (.pdf)' }, + { value: 'json', label: 'JSON (.json)' } +] as const; + +/** + * Polling interval for meeting status updates (ms). + */ +export const STATUS_POLL_INTERVAL = 5000; + +/** + * Maximum number of meetings to fetch per page. + */ +export const MEETINGS_PAGE_SIZE = 20; diff --git a/react/features/meeting-intelligence/functions.ts b/react/features/meeting-intelligence/functions.ts new file mode 100644 index 0000000..ec39318 --- /dev/null +++ b/react/features/meeting-intelligence/functions.ts @@ -0,0 +1,233 @@ +/** + * Utility functions and selectors for Meeting Intelligence feature. + */ + +import { IReduxState } from '../app/types'; + +import { SPEAKER_COLORS, STATUS_COLORS, STATUS_LABELS } from './constants'; +import { IMeetingIntelligenceState, ISpeakerStats, ITranscriptSegment } from './types'; + +/** + * Get the meeting intelligence state. + * + * @param {IReduxState} state - The Redux state. + * @returns {IMeetingIntelligenceState} The meeting intelligence state. + */ +export function getMeetingIntelligenceState(state: IReduxState): IMeetingIntelligenceState { + return state['features/meeting-intelligence']; +} + +/** + * Check if the dashboard is open. + * + * @param {IReduxState} state - The Redux state. + * @returns {boolean} True if the dashboard is open. + */ +export function isDashboardOpen(state: IReduxState): boolean { + return getMeetingIntelligenceState(state)?.isOpen ?? false; +} + +/** + * Get the active tab. + * + * @param {IReduxState} state - The Redux state. + * @returns {string} The active tab name. + */ +export function getActiveTab(state: IReduxState): string { + return getMeetingIntelligenceState(state)?.activeTab ?? 'recordings'; +} + +/** + * Get the selected meeting. + * + * @param {IReduxState} state - The Redux state. + * @returns {IMeeting|undefined} The selected meeting or undefined. + */ +export function getSelectedMeeting(state: IReduxState) { + return getMeetingIntelligenceState(state)?.selectedMeeting; +} + +/** + * Get the current transcript. + * + * @param {IReduxState} state - The Redux state. + * @returns {ITranscriptSegment[]} The transcript segments. + */ +export function getTranscript(state: IReduxState): ITranscriptSegment[] { + return getMeetingIntelligenceState(state)?.transcript ?? []; +} + +/** + * Get the current summary. + * + * @param {IReduxState} state - The Redux state. + * @returns {IMeetingSummary|undefined} The meeting summary or undefined. + */ +export function getSummary(state: IReduxState) { + return getMeetingIntelligenceState(state)?.summary; +} + +/** + * Get speaker statistics. + * + * @param {IReduxState} state - The Redux state. + * @returns {ISpeakerStats[]} The speaker statistics array. + */ +export function getSpeakerStats(state: IReduxState): ISpeakerStats[] { + return getMeetingIntelligenceState(state)?.speakerStats ?? []; +} + +/** + * Format time in seconds to MM:SS or HH:MM:SS. + * + * @param {number} seconds - Time in seconds. + * @returns {string} Formatted time string. + */ +export function formatTime(seconds: number): string { + const totalSeconds = Math.floor(seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + + return `${minutes}:${secs.toString().padStart(2, '0')}`; +} + +/** + * Format duration in seconds to human readable. + * + * @param {number} seconds - Duration in seconds. + * @returns {string} Human readable duration. + */ +export function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + + return `${minutes}m`; +} + +/** + * Format date to display string. + * + * @param {string} dateString - ISO date string. + * @returns {string} Formatted date string. + */ +export function formatDate(dateString: string): string { + const date = new Date(dateString); + + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +/** + * Get color for a speaker based on label. + * + * @param {string} speakerLabel - The speaker label. + * @param {string[]} speakerLabels - All speaker labels. + * @returns {string} The color for the speaker. + */ +export function getSpeakerColor(speakerLabel: string, speakerLabels: string[]): string { + const index = speakerLabels.indexOf(speakerLabel); + + return SPEAKER_COLORS[index % SPEAKER_COLORS.length]; +} + +/** + * Get unique speaker labels from transcript. + * + * @param {ITranscriptSegment[]} transcript - The transcript segments. + * @returns {string[]} Unique speaker labels. + */ +export function getUniqueSpeakers(transcript: ITranscriptSegment[]): string[] { + const speakers = new Set(); + + transcript.forEach(segment => { + if (segment.speaker_label) { + speakers.add(segment.speaker_label); + } + }); + + return Array.from(speakers); +} + +/** + * Get status label. + * + * @param {string} status - The status key. + * @returns {string} Human readable status label. + */ +export function getStatusLabel(status: string): string { + return STATUS_LABELS[status] ?? status; +} + +/** + * Get status color. + * + * @param {string} status - The status key. + * @returns {string} The color hex code. + */ +export function getStatusColor(status: string): string { + return STATUS_COLORS[status] ?? '#9E9E9E'; +} + +/** + * Group transcript segments by speaker. + * + * @param {ITranscriptSegment[]} transcript - The transcript segments. + * @returns {Array<{segments: ITranscriptSegment[], speaker: string}>} Grouped segments. + */ +export function groupSegmentsBySpeaker( + transcript: ITranscriptSegment[] +): Array<{ segments: ITranscriptSegment[]; speaker: string; }> { + const groups: Array<{ segments: ITranscriptSegment[]; speaker: string; }> = []; + let currentGroup: { segments: ITranscriptSegment[]; speaker: string; } | null = null; + + transcript.forEach(segment => { + const speaker = segment.speaker_label || 'Speaker'; + + if (!currentGroup || currentGroup.speaker !== speaker) { + currentGroup = { speaker, segments: [] }; + groups.push(currentGroup); + } + + currentGroup.segments.push(segment); + }); + + return groups; +} + +/** + * Check if meeting intelligence is enabled in config. + * + * @param {IReduxState} state - The Redux state. + * @returns {boolean} True if meeting intelligence is enabled. + */ +export function isMeetingIntelligenceEnabled(state: IReduxState): boolean { + const config = state['features/base/config']; + + return config?.meetingIntelligence?.enabled ?? true; +} + +/** + * Get the API URL from config or use default. + * + * @param {IReduxState} state - The Redux state. + * @returns {string} The API URL. + */ +export function getApiUrl(state: IReduxState): string { + const config = state['features/base/config']; + + return config?.meetingIntelligence?.apiUrl ?? '/api/intelligence'; +} diff --git a/react/features/meeting-intelligence/hooks.ts b/react/features/meeting-intelligence/hooks.ts new file mode 100644 index 0000000..1d64b1b --- /dev/null +++ b/react/features/meeting-intelligence/hooks.ts @@ -0,0 +1,23 @@ +import { useSelector } from 'react-redux'; + +import MeetingIntelligenceButton from './components/web/MeetingIntelligenceButton'; +import { isMeetingIntelligenceEnabled } from './functions'; + +const meetingIntelligence = { + key: 'meetingintelligence', + Content: MeetingIntelligenceButton, + group: 3 +}; + +/** + * A hook that returns the meeting intelligence button if it is enabled and undefined otherwise. + * + * @returns {Object | undefined} + */ +export function useMeetingIntelligenceButton() { + const meetingIntelligenceEnabled = useSelector(isMeetingIntelligenceEnabled); + + if (meetingIntelligenceEnabled) { + return meetingIntelligence; + } +} diff --git a/react/features/meeting-intelligence/logger.ts b/react/features/meeting-intelligence/logger.ts new file mode 100644 index 0000000..01acf7d --- /dev/null +++ b/react/features/meeting-intelligence/logger.ts @@ -0,0 +1,3 @@ +import { getLogger } from '../base/logging/functions'; + +export default getLogger('features/meeting-intelligence'); diff --git a/react/features/meeting-intelligence/reducer.ts b/react/features/meeting-intelligence/reducer.ts new file mode 100644 index 0000000..00e0972 --- /dev/null +++ b/react/features/meeting-intelligence/reducer.ts @@ -0,0 +1,252 @@ +/** + * Reducer for Meeting Intelligence feature. + */ + +import ReducerRegistry from '../base/redux/ReducerRegistry'; + +import { + CLEAR_SEARCH, + CLEAR_SELECTED_MEETING, + EXPORT_FAILURE, + EXPORT_REQUEST, + EXPORT_SUCCESS, + FETCH_MEETINGS_FAILURE, + FETCH_MEETINGS_REQUEST, + FETCH_MEETINGS_SUCCESS, + FETCH_SPEAKER_STATS_SUCCESS, + FETCH_SUMMARY_FAILURE, + FETCH_SUMMARY_REQUEST, + FETCH_SUMMARY_SUCCESS, + FETCH_TRANSCRIPT_FAILURE, + FETCH_TRANSCRIPT_REQUEST, + FETCH_TRANSCRIPT_SUCCESS, + GENERATE_SUMMARY_FAILURE, + GENERATE_SUMMARY_REQUEST, + GENERATE_SUMMARY_SUCCESS, + SEARCH_FAILURE, + SEARCH_REQUEST, + SEARCH_SUCCESS, + SELECT_MEETING, + SET_ACTIVE_TAB, + SET_EXPORT_FORMAT, + SET_SEARCH_QUERY, + TOGGLE_MEETING_INTELLIGENCE, + UPDATE_MEETING_STATUS +} from './actionTypes'; +import { IMeetingIntelligenceState } from './types'; + +/** + * Initial state. + */ +const INITIAL_STATE: IMeetingIntelligenceState = { + isOpen: false, + activeTab: 'recordings', + meetings: [], + meetingsLoading: false, + meetingsError: undefined, + selectedMeetingId: undefined, + selectedMeeting: undefined, + transcript: [], + transcriptLoading: false, + transcriptError: undefined, + summary: undefined, + summaryLoading: false, + summaryError: undefined, + speakerStats: [], + searchQuery: '', + searchResults: [], + searchLoading: false, + searchError: undefined, + exportFormat: 'markdown', + exportLoading: false +}; + +/** + * Reducer function. + */ +ReducerRegistry.register( + 'features/meeting-intelligence', + (state = INITIAL_STATE, action): IMeetingIntelligenceState => { + switch (action.type) { + case TOGGLE_MEETING_INTELLIGENCE: + return { + ...state, + isOpen: !state.isOpen + }; + + case SET_ACTIVE_TAB: + return { + ...state, + activeTab: action.tab + }; + + case SELECT_MEETING: + return { + ...state, + selectedMeetingId: action.meetingId, + selectedMeeting: state.meetings.find(m => m.id === action.meetingId), + transcript: [], + summary: undefined, + speakerStats: [], + activeTab: 'transcript' + }; + + case CLEAR_SELECTED_MEETING: + return { + ...state, + selectedMeetingId: undefined, + selectedMeeting: undefined, + transcript: [], + summary: undefined, + speakerStats: [], + activeTab: 'recordings' + }; + + case FETCH_MEETINGS_REQUEST: + return { + ...state, + meetingsLoading: true, + meetingsError: undefined + }; + + case FETCH_MEETINGS_SUCCESS: + return { + ...state, + meetings: action.meetings, + meetingsLoading: false + }; + + case FETCH_MEETINGS_FAILURE: + return { + ...state, + meetingsLoading: false, + meetingsError: action.error + }; + + case FETCH_TRANSCRIPT_REQUEST: + return { + ...state, + transcriptLoading: true, + transcriptError: undefined + }; + + case FETCH_TRANSCRIPT_SUCCESS: + return { + ...state, + transcript: action.transcript, + transcriptLoading: false + }; + + case FETCH_TRANSCRIPT_FAILURE: + return { + ...state, + transcriptLoading: false, + transcriptError: action.error + }; + + case FETCH_SUMMARY_REQUEST: + case GENERATE_SUMMARY_REQUEST: + return { + ...state, + summaryLoading: true, + summaryError: undefined + }; + + case FETCH_SUMMARY_SUCCESS: + case GENERATE_SUMMARY_SUCCESS: + return { + ...state, + summary: action.summary, + summaryLoading: false + }; + + case FETCH_SUMMARY_FAILURE: + case GENERATE_SUMMARY_FAILURE: + return { + ...state, + summaryLoading: false, + summaryError: action.error + }; + + case FETCH_SPEAKER_STATS_SUCCESS: + return { + ...state, + speakerStats: action.speakerStats + }; + + case SET_SEARCH_QUERY: + return { + ...state, + searchQuery: action.query + }; + + case SEARCH_REQUEST: + return { + ...state, + searchLoading: true, + searchError: undefined + }; + + case SEARCH_SUCCESS: + return { + ...state, + searchResults: action.results, + searchLoading: false + }; + + case SEARCH_FAILURE: + return { + ...state, + searchLoading: false, + searchError: action.error + }; + + case CLEAR_SEARCH: + return { + ...state, + searchQuery: '', + searchResults: [], + searchError: undefined + }; + + case SET_EXPORT_FORMAT: + return { + ...state, + exportFormat: action.format + }; + + case EXPORT_REQUEST: + return { + ...state, + exportLoading: true + }; + + case EXPORT_SUCCESS: + case EXPORT_FAILURE: + return { + ...state, + exportLoading: false + }; + + case UPDATE_MEETING_STATUS: { + const updatedMeetings = state.meetings.map(m => + m.id === action.meetingId + ? { ...m, status: action.status } + : m + ); + const updatedSelectedMeeting = state.selectedMeeting && state.selectedMeeting.id === action.meetingId + ? { ...state.selectedMeeting, status: action.status } + : state.selectedMeeting; + + return { + ...state, + meetings: updatedMeetings, + selectedMeeting: updatedSelectedMeeting + }; + } + + default: + return state; + } + } +); diff --git a/react/features/meeting-intelligence/types.ts b/react/features/meeting-intelligence/types.ts new file mode 100644 index 0000000..3f44403 --- /dev/null +++ b/react/features/meeting-intelligence/types.ts @@ -0,0 +1,130 @@ +/** + * Type definitions for Meeting Intelligence feature. + */ + +/** + * A single transcript segment with speaker and timing info. + */ +export interface ITranscriptSegment { + confidence?: number; + end_time: number; + id: string; + speaker_label?: string; + start_time: number; + text: string; +} + +/** + * An action item extracted from a meeting. + */ +export interface IActionItem { + assignee?: string; + due_date?: string; + task: string; +} + +/** + * AI-generated meeting summary. + */ +export interface IMeetingSummary { + action_items: IActionItem[]; + decisions: string[]; + generated_at: string; + key_points: string[]; + sentiment?: string; + summary_text: string; + topics: string[]; +} + +/** + * A meeting record. + */ +export interface IMeeting { + conference_id: string; + conference_name?: string; + created_at: string; + duration_seconds?: number; + ended_at?: string; + id: string; + recording_path?: string; + started_at?: string; + status: MeetingStatus; + title?: string; +} + +/** + * Meeting status values. + */ +export type MeetingStatus = + | 'recording' + | 'extracting_audio' + | 'transcribing' + | 'diarizing' + | 'summarizing' + | 'ready' + | 'failed'; + +/** + * Speaker statistics. + */ +export interface ISpeakerStats { + percentage: number; + segment_count: number; + speaker_label: string; + total_duration: number; +} + +/** + * Search result item. + */ +export interface ISearchResult { + conference_id: string; + meeting_id: string; + score: number; + segment_text: string; + speaker_label?: string; + start_time: number; + started_at?: string; + title?: string; +} + +/** + * Meeting Intelligence Redux state. + */ +export interface IMeetingIntelligenceState { + activeTab: 'recordings' | 'transcript' | 'summary' | 'search'; + // Export + exportFormat: 'pdf' | 'markdown' | 'json'; + + exportLoading: boolean; + // Dashboard state + isOpen: boolean; + // Meetings list + meetings: IMeeting[]; + + meetingsError?: string; + meetingsLoading: boolean; + + searchError?: string; + searchLoading: boolean; + // Search + searchQuery: string; + + searchResults: ISearchResult[]; + selectedMeeting?: IMeeting; + // Selected meeting + selectedMeetingId?: string; + + // Speaker stats + speakerStats: ISpeakerStats[]; + + // Summary + summary?: IMeetingSummary; + summaryError?: string; + summaryLoading: boolean; + // Transcript + transcript: ITranscriptSegment[]; + + transcriptError?: string; + transcriptLoading: boolean; +} diff --git a/react/features/toolbox/hooks.web.ts b/react/features/toolbox/hooks.web.ts index 0928a38..f35e0ca 100644 --- a/react/features/toolbox/hooks.web.ts +++ b/react/features/toolbox/hooks.web.ts @@ -24,6 +24,7 @@ import { isGifEnabled } from '../gifs/function.any'; import InviteButton from '../invite/components/add-people-dialog/web/InviteButton'; import { registerShortcut, unregisterShortcut } from '../keyboard-shortcuts/actions'; import { useKeyboardShortcutsButton } from '../keyboard-shortcuts/hooks'; +import { useMeetingIntelligenceButton } from '../meeting-intelligence/hooks'; import NoiseSuppressionButton from '../noise-suppression/components/NoiseSuppressionButton'; import { close as closeParticipantsPane, @@ -285,6 +286,7 @@ export function useToolboxButtons( const shareaudio = getShareAudioButton(); const shareVideo = useSharedVideoButton(); const shareMusic = useSharedMusicButton(); + const meetingIntelligence = useMeetingIntelligenceButton(); const whiteboard = useWhiteboardButton(); const etherpad = useEtherpadButton(); const virtualBackground = useVirtualBackgroundButton(); @@ -318,6 +320,7 @@ export function useToolboxButtons( linktosalesforce, sharedvideo: shareVideo, sharedmusic: shareMusic, + meetingintelligence: meetingIntelligence, shareaudio, noisesuppression: noiseSuppression, whiteboard, diff --git a/react/features/toolbox/types.ts b/react/features/toolbox/types.ts index c775e8a..cb42ca4 100644 --- a/react/features/toolbox/types.ts +++ b/react/features/toolbox/types.ts @@ -50,6 +50,7 @@ export type ToolbarButton = 'camera' | 'shareaudio' | 'sharedvideo' | 'sharedmusic' | + 'meetingintelligence' | 'shortcuts' | 'stats' | 'tileview' | diff --git a/tsconfig.native.json b/tsconfig.native.json index 0b1432f..4bd91cf 100644 --- a/tsconfig.native.json +++ b/tsconfig.native.json @@ -36,6 +36,7 @@ "react/features/virtual-background", "react/features/web-hid", "react/features/whiteboard", + "react/features/meeting-intelligence", "**/web/*", "**/*.web.ts", "**/*.web.tsx"