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:
parent
9f9bb309bb
commit
68016c30f6
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,5 +77,6 @@ $flagsImagePath: "../images/";
|
||||||
@import 'reactions-menu';
|
@import 'reactions-menu';
|
||||||
@import 'plan-limit';
|
@import 'plan-limit';
|
||||||
@import 'shared_music';
|
@import 'shared_music';
|
||||||
|
@import 'meeting_intelligence';
|
||||||
|
|
||||||
/* Modules END */
|
/* Modules END */
|
||||||
|
|
|
||||||
|
|
@ -1385,6 +1385,8 @@
|
||||||
"sharedvideo": "Share video",
|
"sharedvideo": "Share video",
|
||||||
"sharedmusic": "Share music",
|
"sharedmusic": "Share music",
|
||||||
"stopSharedMusic": "Stop music",
|
"stopSharedMusic": "Stop music",
|
||||||
|
"meetingIntelligence": "Meeting Intelligence",
|
||||||
|
"closeMeetingIntelligence": "Close Meeting Intelligence",
|
||||||
"shortcuts": "Toggle shortcuts",
|
"shortcuts": "Toggle shortcuts",
|
||||||
"show": "Show on stage",
|
"show": "Show on stage",
|
||||||
"showWhiteboard": "Show whiteboard",
|
"showWhiteboard": "Show whiteboard",
|
||||||
|
|
@ -1501,6 +1503,8 @@
|
||||||
"sharedvideo": "Share video",
|
"sharedvideo": "Share video",
|
||||||
"sharedmusic": "Share music",
|
"sharedmusic": "Share music",
|
||||||
"stopSharedMusic": "Stop music",
|
"stopSharedMusic": "Stop music",
|
||||||
|
"meetingIntelligence": "Meeting Intelligence",
|
||||||
|
"closeMeetingIntelligence": "Close Meeting Intelligence",
|
||||||
"shortcuts": "View shortcuts",
|
"shortcuts": "View shortcuts",
|
||||||
"showWhiteboard": "Show whiteboard",
|
"showWhiteboard": "Show whiteboard",
|
||||||
"silence": "Silence",
|
"silence": "Silence",
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,6 @@ import '../talk-while-muted/reducer';
|
||||||
import '../virtual-background/reducer';
|
import '../virtual-background/reducer';
|
||||||
import '../web-hid/reducer';
|
import '../web-hid/reducer';
|
||||||
import '../file-sharing/reducer';
|
import '../file-sharing/reducer';
|
||||||
|
import '../meeting-intelligence/reducer';
|
||||||
|
|
||||||
import './reducers.any';
|
import './reducers.any';
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import { IJaaSState } from '../jaas/reducer';
|
||||||
import { IKeyboardShortcutsState } from '../keyboard-shortcuts/types';
|
import { IKeyboardShortcutsState } from '../keyboard-shortcuts/types';
|
||||||
import { ILargeVideoState } from '../large-video/reducer';
|
import { ILargeVideoState } from '../large-video/reducer';
|
||||||
import { ILobbyState } from '../lobby/reducer';
|
import { ILobbyState } from '../lobby/reducer';
|
||||||
|
import { IMeetingIntelligenceState } from '../meeting-intelligence/types';
|
||||||
import { IMobileAudioModeState } from '../mobile/audio-mode/reducer';
|
import { IMobileAudioModeState } from '../mobile/audio-mode/reducer';
|
||||||
import { IMobileBackgroundState } from '../mobile/background/reducer';
|
import { IMobileBackgroundState } from '../mobile/background/reducer';
|
||||||
import { ICallIntegrationState } from '../mobile/call-integration/reducer';
|
import { ICallIntegrationState } from '../mobile/call-integration/reducer';
|
||||||
|
|
@ -139,6 +140,7 @@ export interface IReduxState {
|
||||||
'features/keyboard-shortcuts': IKeyboardShortcutsState;
|
'features/keyboard-shortcuts': IKeyboardShortcutsState;
|
||||||
'features/large-video': ILargeVideoState;
|
'features/large-video': ILargeVideoState;
|
||||||
'features/lobby': ILobbyState;
|
'features/lobby': ILobbyState;
|
||||||
|
'features/meeting-intelligence': IMeetingIntelligenceState;
|
||||||
'features/mobile/audio-mode': IMobileAudioModeState;
|
'features/mobile/audio-mode': IMobileAudioModeState;
|
||||||
'features/mobile/background': IMobileBackgroundState;
|
'features/mobile/background': IMobileBackgroundState;
|
||||||
'features/mobile/external-api': IMobileExternalApiState;
|
'features/mobile/external-api': IMobileExternalApiState;
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,13 @@ export interface IConfig {
|
||||||
logging?: ILoggingConfig;
|
logging?: ILoggingConfig;
|
||||||
mainToolbarButtons?: Array<Array<string>>;
|
mainToolbarButtons?: Array<Array<string>>;
|
||||||
maxFullResolutionParticipants?: number;
|
maxFullResolutionParticipants?: number;
|
||||||
|
meetingIntelligence?: {
|
||||||
|
apiUrl?: string;
|
||||||
|
autoTranscribe?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
exportFormats?: Array<'pdf' | 'markdown' | 'json'>;
|
||||||
|
standalonePageEnabled?: boolean;
|
||||||
|
};
|
||||||
microsoftApiApplicationClientID?: string;
|
microsoftApiApplicationClientID?: string;
|
||||||
moderatedRoomServiceUrl?: string;
|
moderatedRoomServiceUrl?: string;
|
||||||
mouseMoveCallbackInterval?: number;
|
mouseMoveCallbackInterval?: number;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import CalleeInfoContainer from '../../../invite/components/callee-info/CalleeIn
|
||||||
import LargeVideo from '../../../large-video/components/LargeVideo.web';
|
import LargeVideo from '../../../large-video/components/LargeVideo.web';
|
||||||
import LobbyScreen from '../../../lobby/components/web/LobbyScreen';
|
import LobbyScreen from '../../../lobby/components/web/LobbyScreen';
|
||||||
import { getIsLobbyVisible } from '../../../lobby/functions';
|
import { getIsLobbyVisible } from '../../../lobby/functions';
|
||||||
|
import { MeetingIntelligenceDashboard } from '../../../meeting-intelligence/components';
|
||||||
import { getOverlayToRender } from '../../../overlay/functions.web';
|
import { getOverlayToRender } from '../../../overlay/functions.web';
|
||||||
import ParticipantsPane from '../../../participants-pane/components/web/ParticipantsPane';
|
import ParticipantsPane from '../../../participants-pane/components/web/ParticipantsPane';
|
||||||
import Prejoin from '../../../prejoin/components/web/Prejoin';
|
import Prejoin from '../../../prejoin/components/web/Prejoin';
|
||||||
|
|
@ -323,6 +324,7 @@ class Conference extends AbstractConference<IProps, any> {
|
||||||
{ _showVisitorsQueue && <VisitorsQueue />}
|
{ _showVisitorsQueue && <VisitorsQueue />}
|
||||||
</div>
|
</div>
|
||||||
<ParticipantsPane />
|
<ParticipantsPane />
|
||||||
|
<MeetingIntelligenceDashboard />
|
||||||
<ReactionAnimations />
|
<ReactionAnimations />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './index.web';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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));
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { getLogger } from '../base/logging/functions';
|
||||||
|
|
||||||
|
export default getLogger('features/meeting-intelligence');
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import { isGifEnabled } from '../gifs/function.any';
|
||||||
import InviteButton from '../invite/components/add-people-dialog/web/InviteButton';
|
import InviteButton from '../invite/components/add-people-dialog/web/InviteButton';
|
||||||
import { registerShortcut, unregisterShortcut } from '../keyboard-shortcuts/actions';
|
import { registerShortcut, unregisterShortcut } from '../keyboard-shortcuts/actions';
|
||||||
import { useKeyboardShortcutsButton } from '../keyboard-shortcuts/hooks';
|
import { useKeyboardShortcutsButton } from '../keyboard-shortcuts/hooks';
|
||||||
|
import { useMeetingIntelligenceButton } from '../meeting-intelligence/hooks';
|
||||||
import NoiseSuppressionButton from '../noise-suppression/components/NoiseSuppressionButton';
|
import NoiseSuppressionButton from '../noise-suppression/components/NoiseSuppressionButton';
|
||||||
import {
|
import {
|
||||||
close as closeParticipantsPane,
|
close as closeParticipantsPane,
|
||||||
|
|
@ -285,6 +286,7 @@ export function useToolboxButtons(
|
||||||
const shareaudio = getShareAudioButton();
|
const shareaudio = getShareAudioButton();
|
||||||
const shareVideo = useSharedVideoButton();
|
const shareVideo = useSharedVideoButton();
|
||||||
const shareMusic = useSharedMusicButton();
|
const shareMusic = useSharedMusicButton();
|
||||||
|
const meetingIntelligence = useMeetingIntelligenceButton();
|
||||||
const whiteboard = useWhiteboardButton();
|
const whiteboard = useWhiteboardButton();
|
||||||
const etherpad = useEtherpadButton();
|
const etherpad = useEtherpadButton();
|
||||||
const virtualBackground = useVirtualBackgroundButton();
|
const virtualBackground = useVirtualBackgroundButton();
|
||||||
|
|
@ -318,6 +320,7 @@ export function useToolboxButtons(
|
||||||
linktosalesforce,
|
linktosalesforce,
|
||||||
sharedvideo: shareVideo,
|
sharedvideo: shareVideo,
|
||||||
sharedmusic: shareMusic,
|
sharedmusic: shareMusic,
|
||||||
|
meetingintelligence: meetingIntelligence,
|
||||||
shareaudio,
|
shareaudio,
|
||||||
noisesuppression: noiseSuppression,
|
noisesuppression: noiseSuppression,
|
||||||
whiteboard,
|
whiteboard,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export type ToolbarButton = 'camera' |
|
||||||
'shareaudio' |
|
'shareaudio' |
|
||||||
'sharedvideo' |
|
'sharedvideo' |
|
||||||
'sharedmusic' |
|
'sharedmusic' |
|
||||||
|
'meetingintelligence' |
|
||||||
'shortcuts' |
|
'shortcuts' |
|
||||||
'stats' |
|
'stats' |
|
||||||
'tileview' |
|
'tileview' |
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"react/features/virtual-background",
|
"react/features/virtual-background",
|
||||||
"react/features/web-hid",
|
"react/features/web-hid",
|
||||||
"react/features/whiteboard",
|
"react/features/whiteboard",
|
||||||
|
"react/features/meeting-intelligence",
|
||||||
"**/web/*",
|
"**/web/*",
|
||||||
"**/*.web.ts",
|
"**/*.web.ts",
|
||||||
"**/*.web.tsx"
|
"**/*.web.tsx"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue