feat(meeting-intelligence): add frontend dashboard for recordings and transcripts

Adds the Meeting Intelligence dashboard UI with:
- Toolbar button to open side panel during meetings
- Recordings list showing past meeting recordings
- Transcript viewer with speaker diarization colors
- AI summary panel with key points, action items, decisions
- Search panel for searching across all transcripts
- Export functionality (Markdown, PDF, JSON)

Frontend components:
- MeetingIntelligenceButton (toolbar integration)
- MeetingIntelligenceDashboard (side panel container)
- RecordingsList, TranscriptViewer, SummaryPanel, SearchPanel

Integrations:
- Redux state management via ReducerRegistry
- Config support via meetingIntelligence settings
- SCSS styles matching Jitsi design system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-06 02:24:50 +00:00
parent 9f9bb309bb
commit 68016c30f6
26 changed files with 2529 additions and 0 deletions

View File

@ -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%;
}
}

View File

@ -77,5 +77,6 @@ $flagsImagePath: "../images/";
@import 'reactions-menu';
@import 'plan-limit';
@import 'shared_music';
@import 'meeting_intelligence';
/* Modules END */

View File

@ -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",

View File

@ -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';

View File

@ -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;

View File

@ -478,6 +478,13 @@ export interface IConfig {
logging?: ILoggingConfig;
mainToolbarButtons?: Array<Array<string>>;
maxFullResolutionParticipants?: number;
meetingIntelligence?: {
apiUrl?: string;
autoTranscribe?: boolean;
enabled?: boolean;
exportFormats?: Array<'pdf' | 'markdown' | 'json'>;
standalonePageEnabled?: boolean;
};
microsoftApiApplicationClientID?: string;
moderatedRoomServiceUrl?: string;
mouseMoveCallbackInterval?: number;

View File

@ -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<IProps, any> {
{ _showVisitorsQueue && <VisitorsQueue />}
</div>
<ParticipantsPane />
<MeetingIntelligenceDashboard />
<ReactionAnimations />
</div>
);

View File

@ -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';

View File

@ -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
};
}

View File

@ -0,0 +1 @@
export * from './index.web';

View File

@ -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';

View File

@ -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<IProps> {
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));

View File

@ -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 (
<div className = 'meeting-intelligence-dashboard'>
<div className = 'meeting-intelligence-header'>
<div className = 'meeting-intelligence-header-left'>
{selectedMeeting && (
<button
className = 'meeting-intelligence-back-btn'
onClick = { handleBack }
type = 'button'>
<svg
fill = 'currentColor'
height = '20'
viewBox = '0 0 24 24'
width = '20'>
<path d = 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z' />
</svg>
</button>
)}
<h2>Meeting Intelligence</h2>
</div>
<button
className = 'meeting-intelligence-close-btn'
onClick = { handleClose }
type = 'button'>
<svg
fill = 'currentColor'
height = '24'
viewBox = '0 0 24 24'
width = '24'>
<path d = 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z' />
</svg>
</button>
</div>
{selectedMeeting ? (
<>
<div className = 'meeting-intelligence-tabs'>
<button
className = { `tab-btn ${activeTab === 'transcript' ? 'active' : ''}` }
onClick = { handleTranscriptTab }
type = 'button'>
Transcript
</button>
<button
className = { `tab-btn ${activeTab === 'summary' ? 'active' : ''}` }
onClick = { handleSummaryTab }
type = 'button'>
Summary
</button>
</div>
<div className = 'meeting-intelligence-content'>
{activeTab === 'transcript' && <TranscriptViewer />}
{activeTab === 'summary' && <SummaryPanel />}
</div>
</>
) : (
<>
<div className = 'meeting-intelligence-tabs'>
<button
className = { `tab-btn ${activeTab === 'recordings' ? 'active' : ''}` }
onClick = { handleRecordingsTab }
type = 'button'>
Recordings
</button>
<button
className = { `tab-btn ${activeTab === 'search' ? 'active' : ''}` }
onClick = { handleSearchTab }
type = 'button'>
Search
</button>
</div>
<div className = 'meeting-intelligence-content'>
{activeTab === 'recordings' && <RecordingsList />}
{activeTab === 'search' && <SearchPanel />}
</div>
</>
)}
</div>
);
};
export default MeetingIntelligenceDashboard;

View File

@ -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<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => {
const meetingId = e.currentTarget.dataset.meetingId;
if (meetingId) {
dispatch(selectMeeting(meetingId) as any);
}
}, [ dispatch ]);
if (meetingsLoading) {
return (
<div className = 'recordings-loading'>
<div className = 'spinner' />
<span>Loading recordings...</span>
</div>
);
}
if (meetingsError) {
return (
<div className = 'recordings-error'>
<p>Failed to load recordings: {meetingsError}</p>
</div>
);
}
if (meetings.length === 0) {
return (
<div className = 'recordings-empty'>
<svg
fill = 'currentColor'
height = '48'
viewBox = '0 0 24 24'
width = '48'>
<path d = 'M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z' />
</svg>
<h3>No recordings yet</h3>
<p>Recordings will appear here after you record a meeting.</p>
</div>
);
}
return (
<div className = 'recordings-list'>
{meetings.map(meeting => (
<div
className = 'recording-item'
data-meeting-id = { meeting.id }
key = { meeting.id }
onClick = { handleSelectMeeting }
onKeyDown = { handleSelectMeeting }
role = 'button'
tabIndex = { 0 }>
<div className = 'recording-item-icon'>
<svg
fill = 'currentColor'
height = '32'
viewBox = '0 0 24 24'
width = '32'>
<path d = 'M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z' />
</svg>
</div>
<div className = 'recording-item-info'>
<div className = 'recording-item-title'>
{meeting.title || meeting.conference_id}
</div>
<div className = 'recording-item-meta'>
{meeting.started_at && (
<span className = 'recording-date'>
{formatDate(meeting.started_at)}
</span>
)}
{meeting.duration_seconds && (
<span className = 'recording-duration'>
{formatDuration(meeting.duration_seconds)}
</span>
)}
</div>
</div>
<div className = 'recording-item-status'>
<span
className = 'status-badge'
style = {{ backgroundColor: getStatusColor(meeting.status) }}>
{getStatusLabel(meeting.status)}
</span>
</div>
</div>
))}
</div>
);
};
export default RecordingsList;

View File

@ -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<HTMLInputElement>) => {
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<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => {
const meetingId = e.currentTarget.dataset.meetingId;
if (meetingId) {
dispatch(selectMeeting(meetingId) as any);
}
}, [ dispatch ]);
return (
<div className = 'search-panel'>
<form
className = 'search-form'
onSubmit = { handleSubmit }>
<input
className = 'search-input'
onChange = { handleInputChange }
placeholder = 'Search transcripts...'
type = 'text'
value = { inputValue } />
<button
className = 'search-button'
disabled = { searchLoading }
type = 'submit'>
{searchLoading ? (
<div className = 'spinner-small' />
) : (
<svg
fill = 'currentColor'
height = '20'
viewBox = '0 0 24 24'
width = '20'>
<path d = 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z' />
</svg>
)}
</button>
</form>
{searchError && (
<div className = 'search-error'>
<p>Search failed: {searchError}</p>
</div>
)}
{searchResults.length > 0 && (
<div className = 'search-results'>
<div className = 'search-results-header'>
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} found
</div>
{searchResults.map((result, index) => (
<div
className = 'search-result-item'
data-meeting-id = { result.meeting_id }
key = { index }
onClick = { handleResultClick }
onKeyDown = { handleResultClick }
role = 'button'
tabIndex = { 0 }>
<div className = 'search-result-header'>
<span className = 'search-result-title'>
{result.title || result.conference_id}
</span>
{result.started_at && (
<span className = 'search-result-date'>
{formatDate(result.started_at)}
</span>
)}
</div>
<div className = 'search-result-snippet'>
{result.speaker_label && (
<span className = 'search-result-speaker'>
{result.speaker_label}:
</span>
)}
<span className = 'search-result-text'>
{result.segment_text}
</span>
</div>
<div className = 'search-result-meta'>
<span className = 'search-result-time'>
at {formatTime(result.start_time)}
</span>
<span className = 'search-result-score'>
Relevance: {Math.round(result.score * 100)}%
</span>
</div>
</div>
))}
</div>
)}
{searchQuery && searchResults.length === 0 && !searchLoading && !searchError && (
<div className = 'search-no-results'>
<svg
fill = 'currentColor'
height = '48'
viewBox = '0 0 24 24'
width = '48'>
<path d = 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z' />
</svg>
<h3>No results found</h3>
<p>Try different keywords or check the spelling.</p>
</div>
)}
{!searchQuery && (
<div className = 'search-hint'>
<svg
fill = 'currentColor'
height = '48'
viewBox = '0 0 24 24'
width = '48'>
<path d = 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z' />
</svg>
<h3>Search across all meetings</h3>
<p>Find specific topics, decisions, or action items mentioned in any meeting.</p>
</div>
)}
</div>
);
};
export default SearchPanel;

View File

@ -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 (
<div className = 'summary-loading'>
<div className = 'spinner' />
<span>Loading summary...</span>
</div>
);
}
if (summaryError) {
return (
<div className = 'summary-error'>
<p>Failed to load summary: {summaryError}</p>
<button
className = 'btn-primary'
onClick = { handleGenerateSummary }
type = 'button'>
Try Again
</button>
</div>
);
}
if (!summary) {
const canGenerate = selectedMeeting?.status === 'ready';
return (
<div className = 'summary-empty'>
<svg
fill = 'currentColor'
height = '48'
viewBox = '0 0 24 24'
width = '48'>
<path d = 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z' />
<path d = 'M18 9l-1.41-1.42L10 14.17l-2.59-2.58L6 13l4 4z' />
</svg>
<h3>No summary yet</h3>
<p>
{canGenerate
? 'Generate an AI summary to see key points, action items, and decisions.'
: 'Summary will be available after transcription is complete.'}
</p>
{canGenerate && (
<button
className = 'btn-primary'
onClick = { handleGenerateSummary }
type = 'button'>
Generate Summary
</button>
)}
</div>
);
}
return (
<div className = 'summary-panel'>
<div className = 'summary-section'>
<h3>Summary</h3>
<p className = 'summary-text'>{summary.summary_text}</p>
</div>
{summary.key_points && summary.key_points.length > 0 && (
<div className = 'summary-section'>
<h3>Key Points</h3>
<ul className = 'key-points-list'>
{summary.key_points.map((point, index) => (
<li key = { index }>{point}</li>
))}
</ul>
</div>
)}
{summary.action_items && summary.action_items.length > 0 && (
<div className = 'summary-section'>
<h3>Action Items</h3>
<ul className = 'action-items-list'>
{summary.action_items.map((item, index) => (
<li key = { index }>
<span className = 'action-checkbox'></span>
<span className = 'action-task'>{item.task}</span>
{item.assignee && (
<span className = 'action-assignee'>
({item.assignee})
</span>
)}
</li>
))}
</ul>
</div>
)}
{summary.decisions && summary.decisions.length > 0 && (
<div className = 'summary-section'>
<h3>Decisions</h3>
<ul className = 'decisions-list'>
{summary.decisions.map((decision, index) => (
<li key = { index }>{decision}</li>
))}
</ul>
</div>
)}
{summary.topics && summary.topics.length > 0 && (
<div className = 'summary-section'>
<h3>Topics Discussed</h3>
<div className = 'topics-tags'>
{summary.topics.map((topic, index) => (
<span
className = 'topic-tag'
key = { index }>
{topic}
</span>
))}
</div>
</div>
)}
<div className = 'summary-export'>
<h3>Export</h3>
<div className = 'export-buttons'>
<button
className = 'btn-secondary'
disabled = { exportLoading }
onClick = { handleExportMarkdown }
type = 'button'>
Markdown
</button>
<button
className = 'btn-secondary'
disabled = { exportLoading }
onClick = { handleExportPdf }
type = 'button'>
PDF
</button>
<button
className = 'btn-secondary'
disabled = { exportLoading }
onClick = { handleExportJson }
type = 'button'>
JSON
</button>
</div>
</div>
</div>
);
};
export default SummaryPanel;

View File

@ -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 (
<div className = 'transcript-loading'>
<div className = 'spinner' />
<span>Loading transcript...</span>
</div>
);
}
if (transcriptError) {
return (
<div className = 'transcript-error'>
<p>Failed to load transcript: {transcriptError}</p>
</div>
);
}
if (!transcript || transcript.length === 0) {
const isProcessing = selectedMeeting?.status !== 'ready' && selectedMeeting?.status !== 'failed';
return (
<div className = 'transcript-empty'>
{isProcessing ? (
<>
<div className = 'spinner' />
<h3>Transcript in progress</h3>
<p>The transcript is being generated. Please check back soon.</p>
</>
) : (
<>
<svg
fill = 'currentColor'
height = '48'
viewBox = '0 0 24 24'
width = '48'>
<path d = 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z' />
</svg>
<h3>No transcript available</h3>
<p>This meeting does not have a transcript yet.</p>
</>
)}
</div>
);
}
const speakerLabels = getUniqueSpeakers(transcript);
const groupedSegments = groupSegmentsBySpeaker(transcript);
return (
<div className = 'transcript-viewer'>
<div className = 'transcript-speakers'>
{speakerLabels.map(speaker => (
<span
className = 'speaker-tag'
key = { speaker }
style = {{ backgroundColor: getSpeakerColor(speaker, speakerLabels) }}>
{speaker}
</span>
))}
</div>
<div className = 'transcript-segments'>
{groupedSegments.map((group, groupIndex) => (
<div
className = 'transcript-group'
key = { groupIndex }>
<div
className = 'transcript-speaker'
style = {{ color: getSpeakerColor(group.speaker, speakerLabels) }}>
{group.speaker}
<span className = 'transcript-time'>
{formatTime(group.segments[0].start_time)}
</span>
</div>
<div className = 'transcript-text'>
{group.segments.map(segment => segment.text).join(' ')}
</div>
</div>
))}
</div>
</div>
);
};
export default TranscriptViewer;

View File

@ -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<string, string> = {
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<string, string> = {
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;

View File

@ -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<string>();
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';
}

View File

@ -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;
}
}

View File

@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/meeting-intelligence');

View File

@ -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<IMeetingIntelligenceState>(
'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;
}
}
);

View File

@ -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;
}

View File

@ -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,

View File

@ -50,6 +50,7 @@ export type ToolbarButton = 'camera' |
'shareaudio' |
'sharedvideo' |
'sharedmusic' |
'meetingintelligence' |
'shortcuts' |
'stats' |
'tileview' |

View File

@ -36,6 +36,7 @@
"react/features/virtual-background",
"react/features/web-hid",
"react/features/whiteboard",
"react/features/meeting-intelligence",
"**/web/*",
"**/*.web.ts",
"**/*.web.tsx"