Files

1252 lines
48 KiB
JavaScript

/**
* Speaker management composable
* Handles speaker identification, naming, and navigation
*/
export function useSpeakers(state, utils, processedTranscription) {
const { nextTick } = Vue;
const {
showSpeakerModal, speakerModalTab, showAddSpeakerModal, showEditSpeakersModal,
showEditTextModal, selectedRecording, recordings,
speakerMap, speakerColorMap, modalSpeakers, speakerDisplayMap, speakerSuggestions, loadingSuggestions,
activeSpeakerInput, regenerateSummaryAfterSpeakerUpdate,
editingSpeakersList, databaseSpeakers, editingSpeakerSuggestions,
editSpeakerDropdownPositions, newSpeakerName, newSpeakerIsMe,
newSpeakerSuggestions, loadingNewSpeakerSuggestions, showNewSpeakerSuggestions,
editingSegmentIndex, editingSpeakerIndex, editedText, editedTranscriptData, highlightedSpeaker,
isAutoIdentifying, availableSpeakers, editingSegments,
currentSpeakerGroupIndex, speakerGroups, currentUserName,
voiceSuggestions, loadingVoiceSuggestions
} = state;
const { showToast, setGlobalError, onChatComplete } = utils;
// i18n helper — falls back to the provided fallback string if i18n is not loaded
const t = (key, params, fallback) => window.i18n ? window.i18n.t(key, params) : (fallback || key);
const tc = (key, count, params) => window.i18n ? window.i18n.tc(key, count, params) : (params && params.count != null ? `${params.count}` : key);
// Current speaker highlight state
let currentSpeakerId = null;
// Number of speaker colors available in CSS (must match styles.css and app.modular.js)
const SPEAKER_COLOR_COUNT = 16;
// Get speaker color from the shared color map
// If speaker not in map, assign next available color
const getSpeakerColor = (speakerId) => {
if (speakerColorMap.value[speakerId]) {
return speakerColorMap.value[speakerId];
}
// Assign next color to new speaker
const colorIndex = Object.keys(speakerColorMap.value).length;
const color = `speaker-color-${(colorIndex % SPEAKER_COLOR_COUNT) + 1}`;
speakerColorMap.value[speakerId] = color;
return color;
};
// Helper to pause outer audio player when opening modals with their own player
const pauseOuterAudioPlayer = () => {
// Find the audio player in the right panel (not in a modal)
const outerAudio = document.querySelector('#rightMainColumn audio') || document.querySelector('#rightMainColumn video') ||
document.querySelector('.detail-view audio:not(.fixed audio)') || document.querySelector('.detail-view video:not(.fixed video)');
if (outerAudio && !outerAudio.paused) {
outerAudio.pause();
}
};
// =========================================
// Speaker Identification Modal
// =========================================
const openSpeakerModal = () => {
if (!selectedRecording.value) return;
// Pause outer audio player to avoid conflicts with modal's player
pauseOuterAudioPlayer();
// Clear any existing speaker map data first
speakerMap.value = {};
speakerDisplayMap.value = {};
// Get the same speaker order used in processedTranscription
const transcription = selectedRecording.value?.transcription;
let speakers = [];
if (transcription) {
try {
const transcriptionData = JSON.parse(transcription);
if (transcriptionData && Array.isArray(transcriptionData)) {
// Use the exact same logic as processedTranscription to get speakers
speakers = [...new Set(transcriptionData.map(segment => segment.speaker).filter(Boolean))];
}
} catch (e) {
// Fall back to getIdentifiedSpeakers if JSON parsing fails
speakers = getIdentifiedSpeakers();
}
}
// Initialize speaker map FIRST with colors from shared color map
// Clear existing map and rebuild it
speakerMap.value = {};
speakerDisplayMap.value = {};
speakers.forEach(speaker => {
speakerMap.value[speaker] = {
name: '',
isMe: false,
color: getSpeakerColor(speaker)
};
speakerDisplayMap.value[speaker] = speaker;
});
// Set modalSpeakers AFTER speakerMap is populated (triggers render)
modalSpeakers.value = speakers;
highlightedSpeaker.value = null;
speakerSuggestions.value = {};
loadingSuggestions.value = {};
activeSpeakerInput.value = null;
isAutoIdentifying.value = false;
regenerateSummaryAfterSpeakerUpdate.value = true;
voiceSuggestions.value = {};
speakerModalTab.value = 'speakers'; // Reset to speakers tab on mobile
showSpeakerModal.value = true;
// Reset virtual scroll state for fresh modal render
if (utils.resetSpeakerModalScroll) {
utils.resetSpeakerModalScroll();
}
// Load voice-based suggestions if embeddings are available
loadVoiceSuggestions();
};
const getIdentifiedSpeakers = () => {
// Ensure we have a valid recording and transcription
if (!selectedRecording.value?.transcription) {
return [];
}
const transcription = selectedRecording.value.transcription;
let transcriptionData;
try {
transcriptionData = JSON.parse(transcription);
} catch (e) {
transcriptionData = null;
}
// Handle new simplified JSON format (array of segments)
if (transcriptionData && Array.isArray(transcriptionData)) {
// JSON format - extract speakers in order of appearance
const speakersInOrder = [];
const seenSpeakers = new Set();
transcriptionData.forEach(segment => {
if (segment.speaker && !seenSpeakers.has(segment.speaker)) {
seenSpeakers.add(segment.speaker);
speakersInOrder.push(segment.speaker);
}
});
return speakersInOrder;
} else if (typeof transcription === 'string') {
// Plain text format - find speakers in order of appearance
const speakerRegex = /\[([^\]]+)\]:/g;
const speakersInOrder = [];
const seenSpeakers = new Set();
let match;
while ((match = speakerRegex.exec(transcription)) !== null) {
const speaker = match[1].trim();
if (speaker && !seenSpeakers.has(speaker)) {
seenSpeakers.add(speaker);
speakersInOrder.push(speaker);
}
}
return speakersInOrder;
}
return [];
};
const closeSpeakerModal = () => {
// Pause any playing modal audio before closing
const modalAudio = document.querySelector('.fixed.z-50 audio') || document.querySelector('.fixed.z-50 video');
if (modalAudio) {
modalAudio.pause();
}
// Reset modal audio state (keep main player independent)
if (utils.resetModalAudioState) {
utils.resetModalAudioState();
}
showSpeakerModal.value = false;
showAutoIdDropdown.value = false;
highlightedSpeaker.value = null;
// Clear the speaker map to prevent stale data from persisting
speakerMap.value = {};
speakerSuggestions.value = {};
loadingSuggestions.value = {};
clearSpeakerHighlight();
};
const saveTranscriptImmediately = async (transcriptData) => {
if (!selectedRecording.value) return;
try {
// Save transcript without closing modal
const filteredSpeakerMap = Object.entries(speakerMap.value).reduce((acc, [speakerId, speakerData]) => {
if (speakerData.name && speakerData.name.trim() !== '') {
acc[speakerId] = speakerData;
}
return acc;
}, {});
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcript`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
transcript_data: transcriptData,
speaker_map: filteredSpeakerMap,
regenerate_summary: false // Don't regenerate on immediate saves
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update transcript');
// Update recordings list and selected recording without closing modal
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index] = data.recording;
}
selectedRecording.value = data.recording;
editedTranscriptData.value = null;
showToast(t('help.saved'), 'fa-check-circle', 2000, 'success');
} catch (error) {
console.error('Save Transcript Error:', error);
showToast(`Error: ${error.message}`, 'fa-exclamation-circle', 3000, 'error');
}
};
const saveTranscriptEdits = async () => {
if (!selectedRecording.value || !editedTranscriptData.value) {
return saveSpeakerNames(); // Fall back to regular speaker name save
}
try {
// Save both speaker names and transcript edits
const filteredSpeakerMap = Object.entries(speakerMap.value).reduce((acc, [speakerId, speakerData]) => {
if (speakerData.name && speakerData.name.trim() !== '') {
acc[speakerId] = speakerData;
}
return acc;
}, {});
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${selectedRecording.value.id}/update_transcript`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
transcript_data: editedTranscriptData.value,
speaker_map: filteredSpeakerMap,
regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update transcript');
closeSpeakerModal();
// If summary regeneration was requested, update status immediately
if (regenerateSummaryAfterSpeakerUpdate.value && data.summary_queued) {
// Update recording status to SUMMARIZING immediately for UI feedback
const summarizingRecording = { ...data.recording, status: 'SUMMARIZING' };
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index] = summarizingRecording;
}
selectedRecording.value = summarizingRecording;
editedTranscriptData.value = null;
showToast(t('help.transcriptUpdated'), 'fa-check-circle');
showToast(t('help.summaryRegenerationStarted'), 'fa-sync-alt');
// Poll for summary completion
pollForSummaryCompletion(selectedRecording.value.id);
} else {
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index] = data.recording;
}
selectedRecording.value = data.recording;
editedTranscriptData.value = null;
showToast(t('help.transcriptUpdated'), 'fa-check-circle');
}
} catch (error) {
console.error('Save Transcript Error:', error);
showToast(`Error: ${error.message}`, 'fa-exclamation-circle', 3000, 'error');
}
};
const saveSpeakerNames = async () => {
if (!selectedRecording.value) return;
// If there are transcript edits, save those instead
if (editedTranscriptData.value) {
return saveTranscriptEdits();
}
// Create a filtered speaker map that excludes entries with blank names
const filteredSpeakerMap = Object.entries(speakerMap.value).reduce((acc, [speakerId, speakerData]) => {
if (speakerData.name && speakerData.name.trim() !== '') {
acc[speakerId] = speakerData;
}
return acc;
}, {});
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${selectedRecording.value.id}/update_speakers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
speaker_map: filteredSpeakerMap,
regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Failed to update speaker names');
closeSpeakerModal();
// If summary regeneration was requested, update status immediately
if (regenerateSummaryAfterSpeakerUpdate.value && data.summary_queued) {
// Update recording status to SUMMARIZING immediately for UI feedback
const summarizingRecording = { ...data.recording, status: 'SUMMARIZING' };
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index] = summarizingRecording;
}
selectedRecording.value = summarizingRecording;
showToast(t('help.speakerNamesUpdated'), 'fa-check-circle');
showToast(t('help.summaryRegenerationStarted'), 'fa-sync-alt');
// Poll for summary completion
pollForSummaryCompletion(selectedRecording.value.id);
} else {
// The backend returns the fully updated recording object
const index = recordings.value.findIndex(r => r.id === selectedRecording.value.id);
if (index !== -1) {
recordings.value[index] = data.recording;
}
selectedRecording.value = data.recording;
showToast(t('help.speakerNamesUpdated'), 'fa-check-circle');
}
} catch (error) {
setGlobalError(`Failed to save speaker names: ${error.message}`);
}
};
// Poll for summary completion after regeneration
const pollForSummaryCompletion = async (recordingId) => {
const maxAttempts = 40; // Poll for up to 2 minutes (40 * 3 seconds)
let attempts = 0;
const pollInterval = setInterval(async () => {
attempts++;
try {
// Use lightweight status-only endpoint for polling
const response = await fetch(`/recording/${recordingId}/status`);
if (!response.ok) {
clearInterval(pollInterval);
return;
}
const statusData = await response.json();
// Update status in recordings list
const index = recordings.value.findIndex(r => r.id === recordingId);
if (index !== -1) {
// Create new object to ensure Vue reactivity
recordings.value[index] = {
...recordings.value[index],
status: statusData.status
};
}
// Update selectedRecording with new object reference for reactivity
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
selectedRecording.value = {
...selectedRecording.value,
status: statusData.status
};
}
// Check if summarization is complete
if (statusData.status === 'COMPLETED') {
clearInterval(pollInterval);
// Now fetch the full recording with the new summary
const fullResponse = await fetch(`/api/recordings/${recordingId}`);
if (fullResponse.ok) {
const fullData = await fullResponse.json();
// Update in recordings list first
const currentIndex = recordings.value.findIndex(r => r.id === recordingId);
if (currentIndex !== -1) {
recordings.value[currentIndex] = fullData;
}
// Always update selectedRecording if it's the current recording
if (selectedRecording.value && selectedRecording.value.id === recordingId) {
selectedRecording.value = fullData;
// Force Vue to detect the change
await nextTick();
}
}
showToast(t('help.summaryUpdated'), 'fa-check-circle');
// Refresh token budget after LLM operation
if (onChatComplete) onChatComplete();
} else if (statusData.status === 'FAILED' || statusData.status === 'ERROR') {
// Stop polling if it failed
clearInterval(pollInterval);
showToast(t('help.summaryGenerationFailed'), 'fa-exclamation-circle', 3000, 'error');
} else if (attempts >= maxAttempts) {
// Stop polling after max attempts
clearInterval(pollInterval);
showToast(t('help.summaryGenerationTimedOut'), 'fa-clock', 3000, 'warning');
}
} catch (error) {
console.error('Error polling for summary:', error);
clearInterval(pollInterval);
}
}, 3000); // Poll every 3 seconds
};
// =========================================
// Speaker Suggestions
// =========================================
const loadVoiceSuggestions = async () => {
if (!selectedRecording.value?.id) return;
loadingVoiceSuggestions.value = true;
voiceSuggestions.value = {};
try {
const response = await fetch(`/speakers/suggestions/${selectedRecording.value.id}`);
if (!response.ok) throw new Error('Failed to load voice suggestions');
const data = await response.json();
if (data.success && data.suggestions) {
// Only keep suggestions that have matches
voiceSuggestions.value = Object.fromEntries(
Object.entries(data.suggestions).filter(([_, matches]) => matches && matches.length > 0)
);
}
} catch (error) {
console.error('Error loading voice suggestions:', error);
voiceSuggestions.value = {};
} finally {
loadingVoiceSuggestions.value = false;
}
};
const applyVoiceSuggestion = (speakerId, suggestion) => {
if (speakerMap.value[speakerId]) {
speakerMap.value[speakerId].name = suggestion.name;
// Don't delete the suggestion - let it reappear if user clears the field
}
};
// Handle "This is Me" checkbox changes
const handleIsMeChange = (speakerId) => {
if (!speakerMap.value[speakerId]) return;
if (speakerMap.value[speakerId].isMe) {
// Checkbox is now checked - set the name to current user's name
speakerMap.value[speakerId].name = currentUserName.value || 'Me';
} else {
// Checkbox is now unchecked - clear the name
speakerMap.value[speakerId].name = '';
}
};
// Determine if voice suggestion pill should be shown inside the input field
const shouldShowVoiceSuggestionPill = (speakerId) => {
// Don't show if no suggestions available
if (!voiceSuggestions.value[speakerId] || voiceSuggestions.value[speakerId].length === 0) {
return false;
}
// Don't show if "This is Me" is checked
if (speakerMap.value[speakerId]?.isMe) {
return false;
}
// Only show when the input field is empty
const typedName = speakerMap.value[speakerId]?.name?.trim();
if (typedName && typedName.length > 0) {
return false;
}
return true;
};
const searchSpeakers = async (query, speakerId) => {
if (!query || query.length < 2) {
speakerSuggestions.value[speakerId] = [];
return;
}
loadingSuggestions.value[speakerId] = true;
try {
const response = await fetch(`/speakers/search?q=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error('Failed to search speakers');
const speakers = await response.json();
speakerSuggestions.value[speakerId] = speakers;
} catch (error) {
console.error('Error searching speakers:', error);
speakerSuggestions.value[speakerId] = [];
} finally {
loadingSuggestions.value[speakerId] = false;
}
};
const selectSpeakerSuggestion = (speakerId, suggestion) => {
if (speakerMap.value[speakerId]) {
speakerMap.value[speakerId].name = suggestion.name;
speakerSuggestions.value[speakerId] = [];
activeSpeakerInput.value = null;
}
};
const closeSpeakerSuggestionsOnClick = (event) => {
// Check if the click was on an input field or dropdown
const clickedInput = event.target.closest('input[type="text"]');
const clickedDropdown = event.target.closest('.absolute.z-10');
// If not clicking on input or dropdown, close all suggestions
if (!clickedInput && !clickedDropdown) {
Object.keys(speakerSuggestions.value).forEach(speakerId => {
speakerSuggestions.value[speakerId] = [];
});
}
};
// =========================================
// Speaker Navigation (Index-Based for Virtual Scroll)
// =========================================
/**
* Find speaker groups by analyzing segment data (not DOM).
* Returns groups with startIndex instead of startElement for virtual scroll compatibility.
*/
const findSpeakerGroups = (speakerId) => {
if (!speakerId) return [];
// Get segments from processedTranscription
const segments = processedTranscription.value?.simpleSegments || [];
if (segments.length === 0) return [];
const groups = [];
let currentGroup = null;
let lastSpeakerId = null;
segments.forEach((segment, index) => {
const segmentSpeakerId = segment.speakerId;
if (segmentSpeakerId === speakerId) {
// If this is a new group (not consecutive with previous)
if (lastSpeakerId !== speakerId) {
currentGroup = {
startIndex: index,
indices: [index]
};
groups.push(currentGroup);
} else if (currentGroup) {
// Add to existing group
currentGroup.indices.push(index);
}
}
lastSpeakerId = segmentSpeakerId;
});
return groups;
};
const highlightSpeakerInTranscript = (speakerId) => {
highlightedSpeaker.value = speakerId;
if (speakerId) {
// Find all speaker groups for navigation (index-based, no DOM queries)
speakerGroups.value = findSpeakerGroups(speakerId);
if (speakerGroups.value.length > 0) {
// Get the current visible range from the virtual scroll
const visibleRange = utils.getSpeakerModalVisibleRange ? utils.getSpeakerModalVisibleRange() : null;
if (visibleRange) {
const { start: visibleStart, end: visibleEnd } = visibleRange;
const visibleCenter = Math.floor((visibleStart + visibleEnd) / 2);
// Check if any group is already visible
const visibleGroupIndex = speakerGroups.value.findIndex(group =>
group.startIndex >= visibleStart && group.startIndex < visibleEnd
);
if (visibleGroupIndex !== -1) {
// A group is already visible, just set it as current (no scroll needed)
currentSpeakerGroupIndex.value = visibleGroupIndex;
} else {
// No group visible - find the nearest group to the visible center
let nearestIndex = 0;
let nearestDistance = Infinity;
speakerGroups.value.forEach((group, index) => {
const distance = Math.abs(group.startIndex - visibleCenter);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
currentSpeakerGroupIndex.value = nearestIndex;
// Scroll to the nearest group
const nearestGroup = speakerGroups.value[nearestIndex];
if (nearestGroup && typeof nearestGroup.startIndex === 'number' && utils.scrollToSegmentIndex) {
utils.scrollToSegmentIndex(nearestGroup.startIndex);
}
}
} else {
// Fallback: no visible range available, scroll to first group
currentSpeakerGroupIndex.value = 0;
const firstGroup = speakerGroups.value[0];
if (firstGroup && typeof firstGroup.startIndex === 'number' && utils.scrollToSegmentIndex) {
utils.scrollToSegmentIndex(firstGroup.startIndex);
}
}
} else {
currentSpeakerGroupIndex.value = -1;
}
} else {
speakerGroups.value = [];
currentSpeakerGroupIndex.value = -1;
}
};
/**
* Select a speaker for navigation from the dropdown.
* Uses index-based navigation compatible with virtual scrolling.
*/
const selectSpeakerForNavigation = (speakerId) => {
if (!speakerId) {
highlightedSpeaker.value = null;
speakerGroups.value = [];
currentSpeakerGroupIndex.value = -1;
return;
}
highlightedSpeaker.value = speakerId;
// Find groups immediately (no DOM dependency)
speakerGroups.value = findSpeakerGroups(speakerId);
currentSpeakerGroupIndex.value = 0;
// Scroll to first occurrence
if (speakerGroups.value.length > 0) {
const firstGroup = speakerGroups.value[0];
if (firstGroup && typeof firstGroup.startIndex === 'number') {
if (utils.scrollToSegmentIndex) {
utils.scrollToSegmentIndex(firstGroup.startIndex);
}
}
}
};
const navigateToNextSpeakerGroup = () => {
if (speakerGroups.value.length === 0) return;
// Update the index
currentSpeakerGroupIndex.value = (currentSpeakerGroupIndex.value + 1) % speakerGroups.value.length;
const group = speakerGroups.value[currentSpeakerGroupIndex.value];
if (group && typeof group.startIndex === 'number') {
if (utils.scrollToSegmentIndex) {
utils.scrollToSegmentIndex(group.startIndex);
}
}
};
const navigateToPrevSpeakerGroup = () => {
if (speakerGroups.value.length === 0) return;
// Update the index
currentSpeakerGroupIndex.value = currentSpeakerGroupIndex.value <= 0
? speakerGroups.value.length - 1
: currentSpeakerGroupIndex.value - 1;
const group = speakerGroups.value[currentSpeakerGroupIndex.value];
if (group && typeof group.startIndex === 'number') {
if (utils.scrollToSegmentIndex) {
utils.scrollToSegmentIndex(group.startIndex);
}
}
};
const focusSpeaker = (speakerId) => {
// Set this as the active speaker input
activeSpeakerInput.value = speakerId;
// Only highlight if not already highlighted (to preserve navigation state)
if (highlightedSpeaker.value !== speakerId) {
highlightSpeakerInTranscript(speakerId);
}
};
const blurSpeaker = () => {
// Clear the active speaker input after a delay to allow clicking on suggestions
setTimeout(() => {
activeSpeakerInput.value = null;
speakerSuggestions.value = {};
}, 200);
clearSpeakerHighlight();
};
const clearSpeakerHighlight = () => {
highlightedSpeaker.value = null;
};
// =========================================
// Auto-Identify Speakers
// =========================================
// Split button dropdown visibility + click-outside handling
const showAutoIdDropdown = Vue.ref(false);
const autoIdSplitBtn = Vue.ref(null);
const onAutoIdClickOutside = (e) => {
if (autoIdSplitBtn.value && !autoIdSplitBtn.value.contains(e.target)) {
showAutoIdDropdown.value = false;
}
};
Vue.watch(showAutoIdDropdown, (open) => {
if (open) {
document.addEventListener('click', onAutoIdClickOutside, true);
} else {
document.removeEventListener('click', onAutoIdClickOutside, true);
}
});
/**
* Auto-identify speakers via LLM.
* @param {boolean} identifyAll - When false (default), only fill speakers with empty names.
* When true, overwrite all speaker names.
*/
const autoIdentifySpeakers = async (identifyAll = false) => {
showAutoIdDropdown.value = false;
if (!selectedRecording.value) {
showToast(t('help.noRecordingSelected'), 'fa-exclamation-circle');
return;
}
isAutoIdentifying.value = true;
showToast(t('help.startingAutoIdentification'), 'fa-magic');
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch(`/recording/${selectedRecording.value.id}/auto_identify_speakers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
current_speaker_map: speakerMap.value
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Unknown error occurred during auto-identification.');
}
// Check if there's a message (e.g., all speakers already identified)
if (data.message) {
showToast(data.message, 'fa-info-circle');
return;
}
// Update speakerMap with the identified names
let identifiedCount = 0;
for (const speakerId in data.speaker_map) {
const identifiedName = data.speaker_map[speakerId];
if (speakerMap.value[speakerId] && identifiedName && identifiedName.trim() !== '') {
// Skip speakers that already have a name unless identifyAll is true
if (!identifyAll && speakerMap.value[speakerId].name && speakerMap.value[speakerId].name.trim() !== '') {
continue;
}
speakerMap.value[speakerId].name = identifiedName;
identifiedCount++;
}
}
if (identifiedCount > 0) {
showToast(tc('help.speakersIdentified', identifiedCount, { count: identifiedCount }), 'fa-check-circle');
} else {
showToast(t('help.noSpeakersIdentified'), 'fa-info-circle');
}
// Refresh token budget after LLM operation
if (onChatComplete) onChatComplete();
} catch (error) {
console.error('Auto Identify Speakers Error:', error);
showToast(`Error: ${error.message}`, 'fa-exclamation-circle', 5000, 'error');
} finally {
isAutoIdentifying.value = false;
}
};
// =========================================
// Apply Suggested Names
// =========================================
/** True when any unnamed, non-isMe speaker has voice or autocomplete suggestions */
const hasAnySuggestions = Vue.computed(() => {
for (const speakerId of modalSpeakers.value) {
const data = speakerMap.value[speakerId];
if (!data || data.isMe) continue;
if (data.name && data.name.trim() !== '') continue;
// Check voice suggestions
if (voiceSuggestions.value[speakerId] && voiceSuggestions.value[speakerId].length > 0) {
return true;
}
// Check autocomplete suggestions
if (speakerSuggestions.value[speakerId] && speakerSuggestions.value[speakerId].length > 0) {
return true;
}
}
return false;
});
/** Bulk-apply voice suggestions (priority) then autocomplete suggestions to empty names only */
const applySuggestedNames = () => {
let appliedCount = 0;
for (const speakerId of modalSpeakers.value) {
const data = speakerMap.value[speakerId];
if (!data || data.isMe) continue;
if (data.name && data.name.trim() !== '') continue;
// Priority 1: voice suggestions
const voice = voiceSuggestions.value[speakerId];
if (voice && voice.length > 0) {
data.name = voice[0].name;
appliedCount++;
continue;
}
// Priority 2: autocomplete suggestions
const auto = speakerSuggestions.value[speakerId];
if (auto && auto.length > 0) {
data.name = auto[0].name;
appliedCount++;
}
}
if (appliedCount > 0) {
showToast(tc('help.appliedSuggestedNames', appliedCount, { count: appliedCount }), 'fa-check-circle');
} else {
showToast(t('help.noSuggestionsToApply'), 'fa-info-circle');
}
};
// =========================================
// Add Speaker Modal
// =========================================
const searchNewSpeaker = async () => {
const query = newSpeakerName.value;
if (!query || query.length < 2) {
newSpeakerSuggestions.value = [];
return;
}
loadingNewSpeakerSuggestions.value = true;
try {
const response = await fetch(`/speakers/search?q=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error('Failed to search speakers');
const speakers = await response.json();
newSpeakerSuggestions.value = speakers;
} catch (error) {
console.error('Error searching speakers:', error);
newSpeakerSuggestions.value = [];
} finally {
loadingNewSpeakerSuggestions.value = false;
}
};
const selectNewSpeakerSuggestion = (suggestion) => {
newSpeakerName.value = suggestion.name;
newSpeakerSuggestions.value = [];
showNewSpeakerSuggestions.value = false;
};
const hideNewSpeakerSuggestionsDelayed = () => {
setTimeout(() => {
showNewSpeakerSuggestions.value = false;
newSpeakerSuggestions.value = [];
}, 200);
};
const openAddSpeakerModal = () => {
newSpeakerName.value = '';
newSpeakerIsMe.value = false;
newSpeakerSuggestions.value = [];
loadingNewSpeakerSuggestions.value = false;
showNewSpeakerSuggestions.value = false;
showAddSpeakerModal.value = true;
};
const closeAddSpeakerModal = () => {
showAddSpeakerModal.value = false;
newSpeakerName.value = '';
newSpeakerIsMe.value = false;
newSpeakerSuggestions.value = [];
loadingNewSpeakerSuggestions.value = false;
showNewSpeakerSuggestions.value = false;
};
const addNewSpeaker = () => {
const name = newSpeakerIsMe.value ? (currentUserName.value || 'Me') : newSpeakerName.value.trim();
if (!newSpeakerIsMe.value && !name) {
showToast(t('help.pleaseEnterSpeakerName'), 'fa-exclamation-circle');
return;
}
// Generate new speaker ID
const existingSpeakerNumbers = modalSpeakers.value
.map(s => {
const match = s.match(/^SPEAKER_(\d+)$/);
return match ? parseInt(match[1]) : -1;
})
.filter(n => n >= 0);
const nextNumber = existingSpeakerNumbers.length > 0
? Math.max(...existingSpeakerNumbers) + 1
: modalSpeakers.value.length;
const newSpeakerId = `SPEAKER_${String(nextNumber).padStart(2, '0')}`;
// Add to speakerMap FIRST (before modalSpeakers) to avoid render race condition
speakerMap.value[newSpeakerId] = {
name: name,
isMe: newSpeakerIsMe.value,
color: getSpeakerColor(newSpeakerId)
};
// Add to speakerDisplayMap
speakerDisplayMap.value[newSpeakerId] = newSpeakerId;
// Add to modalSpeakers LAST (triggers re-render, but speakerMap is already populated)
modalSpeakers.value.push(newSpeakerId);
closeAddSpeakerModal();
showToast(t('help.speakerAdded'), 'fa-check-circle');
};
// =========================================
// Edit Speakers Modal
// =========================================
const openEditSpeakersModal = async () => {
// Close any open suggestions
editingSegments.value.forEach(seg => seg.showSuggestions = false);
// Copy current speakers to editing list with original and current properties
editingSpeakersList.value = availableSpeakers.value.map(s => ({
original: s,
current: s
}));
// Fetch speakers from database for autocomplete
try {
const response = await fetch('/speakers');
const speakers = await response.json();
// Keep full objects with id and name for autocomplete dropdown
databaseSpeakers.value = speakers;
} catch (e) {
console.error('Failed to fetch speakers:', e);
databaseSpeakers.value = [];
}
editingSpeakerSuggestions.value = {};
showEditSpeakersModal.value = true;
};
const closeEditSpeakersModal = () => {
showEditSpeakersModal.value = false;
editingSpeakersList.value = [];
};
const addEditingSpeaker = () => {
editingSpeakersList.value.push({ original: '', current: '' });
};
const removeEditingSpeaker = (index) => {
editingSpeakersList.value.splice(index, 1);
};
const filterEditingSpeakerSuggestions = (index) => {
const query = editingSpeakersList.value[index]?.current?.toLowerCase().trim() || '';
if (query === '') {
// Show all speakers when field is empty/focused
editingSpeakerSuggestions.value[index] = [...databaseSpeakers.value];
} else {
editingSpeakerSuggestions.value[index] = databaseSpeakers.value.filter(
s => s.name.toLowerCase().includes(query)
);
}
};
const selectEditingSpeakerSuggestion = (index, name) => {
editingSpeakersList.value[index].current = name;
editingSpeakerSuggestions.value[index] = [];
};
const closeEditingSpeakerSuggestions = (index) => {
editingSpeakerSuggestions.value[index] = [];
};
const onEditSpeakerBlur = (index) => {
// Delay closing to allow clicking on suggestions
setTimeout(() => {
closeEditingSpeakerSuggestions(index);
}, 200);
};
const getEditSpeakerDropdownPosition = (index) => {
// Find the input element for this index and calculate position
const inputs = document.querySelectorAll('[class*="edit-speakers-modal"] input[placeholder="New name..."], .max-w-md input[placeholder="New name..."]');
if (inputs[index]) {
const rect = inputs[index].getBoundingClientRect();
return {
top: rect.bottom + 2 + 'px',
left: rect.left + 'px',
width: rect.width + 'px'
};
}
return { top: '0px', left: '0px', width: '200px' };
};
const saveEditingSpeakers = async () => {
const map = {};
editingSpeakersList.value.forEach(item => {
if (item.original && item.current) {
map[item.original] = item.current;
}
});
// Update ASR editor state if it's open
if (editingSegments.value.length > 0) {
// Build new list of available speakers
const newSpeakers = new Set();
// Apply renames to all segments
editingSegments.value.forEach(segment => {
if (map[segment.speaker]) {
segment.speaker = map[segment.speaker];
}
newSpeakers.add(segment.speaker);
});
// Add any newly added speakers from the modal
editingSpeakersList.value.forEach(item => {
if (!item.original && item.current) {
// This is a new speaker (no original)
newSpeakers.add(item.current);
}
});
// Update available speakers list
availableSpeakers.value = [...newSpeakers].sort();
// Update filtered speakers for all segments
editingSegments.value.forEach(segment => {
segment.filteredSpeakers = [...availableSpeakers.value];
});
closeEditSpeakersModal();
showToast(t('help.speakersUpdatedSaveToApply'), 'fa-check-circle');
} else {
// Regular flow for non-ASR editor context
speakerMap.value = map;
closeEditSpeakersModal();
await saveSpeakerNames();
}
};
// =========================================
// Edit Text Modal
// =========================================
const openEditTextModal = (segmentIndex) => {
if (!selectedRecording.value?.transcription) return;
try {
const transcriptionData = JSON.parse(selectedRecording.value.transcription);
if (transcriptionData && Array.isArray(transcriptionData) && transcriptionData[segmentIndex]) {
editingSegmentIndex.value = segmentIndex;
editedText.value = transcriptionData[segmentIndex].sentence || '';
showEditTextModal.value = true;
}
} catch (e) {
console.error('Error opening text editor:', e);
showToast(t('help.errorOpeningTextEditor'), 'fa-exclamation-circle', 3000, 'error');
}
};
const closeEditTextModal = () => {
showEditTextModal.value = false;
editingSegmentIndex.value = null;
editedText.value = '';
};
const saveEditedText = async () => {
if (editingSegmentIndex.value === null || !selectedRecording.value?.transcription) return;
try {
const transcriptionData = JSON.parse(selectedRecording.value.transcription);
if (transcriptionData && Array.isArray(transcriptionData) && transcriptionData[editingSegmentIndex.value]) {
transcriptionData[editingSegmentIndex.value].sentence = editedText.value;
editedTranscriptData.value = transcriptionData;
// Update the recording's transcription temporarily for UI update
selectedRecording.value.transcription = JSON.stringify(transcriptionData);
closeEditTextModal();
// Immediately persist the change
showToast(t('help.savingProgress'), 'fa-spinner fa-spin');
await saveTranscriptImmediately(transcriptionData);
}
} catch (e) {
console.error('Error saving text:', e);
showToast(t('help.errorSavingText'), 'fa-exclamation-circle', 3000, 'error');
}
};
// =========================================
// Change Speaker in Segment
// =========================================
const openSpeakerChangeDropdown = (segmentIndex) => {
editingSpeakerIndex.value = editingSpeakerIndex.value === segmentIndex ? null : segmentIndex;
};
const changeSpeaker = async (segmentIndex, newSpeakerId) => {
if (!selectedRecording.value?.transcription) return;
try {
const transcriptionData = JSON.parse(selectedRecording.value.transcription);
if (transcriptionData && Array.isArray(transcriptionData) && transcriptionData[segmentIndex]) {
transcriptionData[segmentIndex].speaker = newSpeakerId;
editedTranscriptData.value = transcriptionData;
// Update the recording's transcription temporarily for UI update
selectedRecording.value.transcription = JSON.stringify(transcriptionData);
editingSpeakerIndex.value = null;
// Immediately persist the change
showToast(t('help.savingProgress'), 'fa-spinner fa-spin');
await saveTranscriptImmediately(transcriptionData);
}
} catch (e) {
console.error('Error changing speaker:', e);
showToast(t('help.errorChangingSpeaker'), 'fa-exclamation-circle', 3000, 'error');
}
};
return {
// Speaker modal
openSpeakerModal,
closeSpeakerModal,
saveSpeakerNames,
// Suggestions
loadVoiceSuggestions,
applyVoiceSuggestion,
handleIsMeChange,
shouldShowVoiceSuggestionPill,
searchSpeakers,
selectSpeakerSuggestion,
closeSpeakerSuggestionsOnClick,
// Navigation
findSpeakerGroups,
highlightSpeakerInTranscript,
selectSpeakerForNavigation,
navigateToNextSpeakerGroup,
navigateToPrevSpeakerGroup,
focusSpeaker,
blurSpeaker,
clearSpeakerHighlight,
// Auto-identify
autoIdentifySpeakers,
showAutoIdDropdown,
autoIdSplitBtn,
hasAnySuggestions,
applySuggestedNames,
// Add speaker
openAddSpeakerModal,
closeAddSpeakerModal,
addNewSpeaker,
searchNewSpeaker,
selectNewSpeakerSuggestion,
hideNewSpeakerSuggestionsDelayed,
// Edit speakers modal
openEditSpeakersModal,
closeEditSpeakersModal,
addEditingSpeaker,
removeEditingSpeaker,
filterEditingSpeakerSuggestions,
selectEditingSpeakerSuggestion,
closeEditingSpeakerSuggestions,
onEditSpeakerBlur,
getEditSpeakerDropdownPosition,
saveEditingSpeakers,
// Edit text
openEditTextModal,
closeEditTextModal,
saveEditedText,
// Change speaker
openSpeakerChangeDropdown,
changeSpeaker
};
}