class MessengerClient {
constructor() {
this.baseUrl = window.location.origin;
// Conversation elements
this.conversationMessages = document.getElementById('conversationMessages');
this.conversationContainer = document.getElementById('conversationContainer');
// Text input elements
this.messageInput = document.getElementById('messageInput');
this.micBtn = document.getElementById('micBtn');
// Send mode controls
this.sendModeRadios = document.querySelectorAll('input[name="sendMode"]');
this.triggerWordInputContainer = document.getElementById('triggerWordInputContainer');
this.triggerWordInput = document.getElementById('triggerWordInput');
// Settings
this.settingsToggleHeader = document.getElementById('settingsToggleHeader');
this.settingsContent = document.getElementById('settingsContent');
this.voiceResponsesToggle = document.getElementById('voiceResponsesToggle');
this.voiceOptions = document.getElementById('voiceOptions');
this.languageSelect = document.getElementById('languageSelect');
this.voiceSelect = document.getElementById('voiceSelect');
this.localVoicesGroup = document.getElementById('localVoicesGroup');
this.cloudVoicesGroup = document.getElementById('cloudVoicesGroup');
this.speechRateSlider = document.getElementById('speechRate');
this.speechRateInput = document.getElementById('speechRateInput');
this.testTTSBtn = document.getElementById('testTTSBtn');
this.rateWarning = document.getElementById('rateWarning');
this.systemVoiceInfo = document.getElementById('systemVoiceInfo');
// State
this.sendMode = 'automatic'; // 'automatic' or 'trigger'
this.triggerWord = 'send';
this.isListening = false;
this.isInterimText = false;
this.accumulatedText = ''; // For trigger word mode
this.debug = localStorage.getItem('voiceHooksDebug') === 'true';
// TTS state
this.voices = [];
this.selectedVoice = 'system';
this.speechRate = 1.0;
this.speechPitch = 1.0;
// Initialize
this.initializeSpeechRecognition();
this.initializeSpeechSynthesis();
this.initializeTTSEvents();
this.setupEventListeners();
this.loadPreferences();
this.loadData();
// Auto-refresh every 2 seconds
setInterval(() => this.loadData(), 2000);
}
debugLog(...args) {
if (this.debug) {
console.log(...args);
}
}
initializeSpeechSynthesis() {
// Check for browser support
if (!window.speechSynthesis) {
console.warn('Speech synthesis not supported in this browser');
return;
}
// Get available voices
this.voices = [];
// Enhanced voice loading with deduplication
const loadVoices = () => {
const voices = window.speechSynthesis.getVoices();
// Deduplicate voices - keep the first occurrence of each unique voice
const deduplicatedVoices = [];
const seen = new Set();
voices.forEach(voice => {
// Create a unique key based on name, language, and URI
const key = `${voice.name}-${voice.lang}-${voice.voiceURI}`;
if (!seen.has(key)) {
seen.add(key);
deduplicatedVoices.push(voice);
}
});
this.voices = deduplicatedVoices;
this.populateVoiceList();
};
// Load voices initially and with a delayed retry for reliability
loadVoices();
setTimeout(loadVoices, 100);
// Set up voice change listener
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = loadVoices;
}
}
initializeTTSEvents() {
// Connect to SSE for TTS events
this.eventSource = new EventSource(`${this.baseUrl}/api/tts-events`);
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'speak' && data.text) {
this.speakText(data.text);
} else if (data.type === 'waitStatus') {
this.handleWaitStatus(data.isWaiting);
}
} catch (error) {
console.error('Failed to parse TTS event:', error);
}
};
this.eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
};
}
handleWaitStatus(isWaiting) {
const waitingIndicator = document.getElementById('waitingIndicator');
if (waitingIndicator) {
waitingIndicator.style.display = isWaiting ? 'block' : 'none';
if (isWaiting) {
this.scrollToBottom();
}
}
}
async speakText(text) {
// Check if we should use system voice
if (this.selectedVoice === 'system') {
// Use Mac system voice via server
try {
const response = await fetch(`${this.baseUrl}/api/speak-system`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: text,
rate: Math.round(this.speechRate * 150) // Convert rate to words per minute
}),
});
if (!response.ok) {
const error = await response.json();
console.error('Failed to speak via system voice:', error);
}
} catch (error) {
console.error('Failed to call speak-system API:', error);
}
} else {
// Use browser voice
if (!window.speechSynthesis) {
console.error('Speech synthesis not available');
return;
}
// Cancel any ongoing speech
window.speechSynthesis.cancel();
// Create utterance
const utterance = new SpeechSynthesisUtterance(text);
// Set voice if using browser voice
if (this.selectedVoice && this.selectedVoice.startsWith('browser:')) {
const voiceIndex = parseInt(this.selectedVoice.substring(8));
if (this.voices[voiceIndex]) {
utterance.voice = this.voices[voiceIndex];
}
}
// Set speech properties
utterance.rate = this.speechRate;
utterance.pitch = this.speechPitch;
// Event handlers
utterance.onstart = () => {
this.debugLog('Started speaking:', text);
};
utterance.onend = () => {
this.debugLog('Finished speaking');
};
utterance.onerror = (event) => {
console.error('Speech synthesis error:', event);
};
// Speak the text
window.speechSynthesis.speak(utterance);
}
}
loadPreferences() {
// Load voice responses preference from localStorage
const savedVoiceResponses = localStorage.getItem('voiceResponsesEnabled');
if (savedVoiceResponses !== null) {
const enabled = savedVoiceResponses === 'true';
this.voiceResponsesToggle.checked = enabled;
this.voiceOptions.style.display = enabled ? 'block' : 'none';
this.updateVoiceResponses(enabled);
}
// Load voice selection
const savedVoice = localStorage.getItem('selectedVoice');
if (savedVoice) {
this.selectedVoice = savedVoice;
}
// Load speech rate
const savedRate = localStorage.getItem('speechRate');
if (savedRate) {
this.speechRate = parseFloat(savedRate);
if (this.speechRateSlider) this.speechRateSlider.value = this.speechRate.toString();
if (this.speechRateInput) this.speechRateInput.value = this.speechRate.toFixed(1);
}
}
populateLanguageFilter() {
if (!this.languageSelect || !this.voices) return;
const currentSelection = this.languageSelect.value || 'en-US';
this.languageSelect.innerHTML = '';
const allOption = document.createElement('option');
allOption.value = 'all';
allOption.textContent = 'All Languages';
this.languageSelect.appendChild(allOption);
const languageCodes = new Set();
this.voices.forEach(voice => {
languageCodes.add(voice.lang);
});
Array.from(languageCodes).sort().forEach(lang => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
this.languageSelect.appendChild(option);
});
this.languageSelect.value = currentSelection;
if (this.languageSelect.value !== currentSelection) {
this.languageSelect.value = 'en-US';
}
}
populateVoiceList() {
if (!this.voiceSelect || !this.localVoicesGroup || !this.cloudVoicesGroup) return;
this.populateLanguageFilter();
this.localVoicesGroup.innerHTML = '';
this.cloudVoicesGroup.innerHTML = '';
const excludedVoices = [
'Eddy', 'Flo', 'Grandma', 'Grandpa', 'Reed', 'Rocko', 'Sandy', 'Shelley',
'Albert', 'Bad News', 'Bahh', 'Bells', 'Boing', 'Bubbles', 'Cellos',
'Good News', 'Jester', 'Organ', 'Superstar', 'Trinoids', 'Whisper',
'Wobble', 'Zarvox', 'Fred', 'Junior', 'Kathy', 'Ralph'
];
const selectedLanguage = this.languageSelect ? this.languageSelect.value : 'en-US';
this.voices.forEach((voice, index) => {
const voiceLang = voice.lang;
let shouldInclude = selectedLanguage === 'all' || voiceLang === selectedLanguage;
if (shouldInclude) {
const voiceName = voice.name;
const isExcluded = excludedVoices.some(excluded =>
voiceName.toLowerCase().startsWith(excluded.toLowerCase())
);
if (!isExcluded) {
const option = document.createElement('option');
option.value = `browser:${index}`;
option.textContent = `${voice.name} (${voice.lang})`;
if (voice.localService) {
this.localVoicesGroup.appendChild(option);
} else {
this.cloudVoicesGroup.appendChild(option);
}
}
}
});
if (this.localVoicesGroup.children.length === 0) {
this.localVoicesGroup.style.display = 'none';
} else {
this.localVoicesGroup.style.display = '';
}
if (this.cloudVoicesGroup.children.length === 0) {
this.cloudVoicesGroup.style.display = 'none';
} else {
this.cloudVoicesGroup.style.display = '';
}
// Restore selection or find default
if (this.selectedVoice) {
this.voiceSelect.value = this.selectedVoice;
}
this.updateVoiceWarnings();
}
updateVoiceWarnings() {
if (this.selectedVoice === 'system') {
this.systemVoiceInfo.style.display = 'flex';
this.rateWarning.style.display = 'none';
} else if (this.selectedVoice && this.selectedVoice.startsWith('browser:')) {
const voiceIndex = parseInt(this.selectedVoice.substring(8));
const voice = this.voices[voiceIndex];
if (voice) {
const isGoogleVoice = voice.name.toLowerCase().includes('google');
this.rateWarning.style.display = isGoogleVoice ? 'flex' : 'none';
this.systemVoiceInfo.style.display = voice.localService ? 'flex' : 'none';
} else {
this.rateWarning.style.display = 'none';
this.systemVoiceInfo.style.display = 'none';
}
} else {
this.rateWarning.style.display = 'none';
this.systemVoiceInfo.style.display = 'none';
}
}
setupEventListeners() {
// Text input events
this.messageInput.addEventListener('keydown', (e) => this.handleTextInputKeydown(e));
this.messageInput.addEventListener('input', () => this.autoGrowTextarea());
// Microphone button
this.micBtn.addEventListener('click', () => this.toggleVoiceDictation());
// Send mode radio buttons
this.sendModeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
this.sendMode = e.target.value;
this.triggerWordInputContainer.style.display =
this.sendMode === 'trigger' ? 'flex' : 'none';
});
});
// Trigger word input
this.triggerWordInput.addEventListener('input', (e) => {
this.triggerWord = e.target.value.trim().toLowerCase();
});
// Settings toggle
this.settingsToggleHeader.addEventListener('click', () => {
const arrow = this.settingsToggleHeader.querySelector('.toggle-arrow');
if (this.settingsContent.classList.contains('open')) {
this.settingsContent.classList.remove('open');
arrow.classList.remove('open');
} else {
this.settingsContent.classList.add('open');
arrow.classList.add('open');
}
});
// Voice responses toggle
this.voiceResponsesToggle.addEventListener('change', async (e) => {
const enabled = e.target.checked;
await this.updateVoiceResponses(enabled);
// Show/hide voice options based on toggle
this.voiceOptions.style.display = enabled ? 'block' : 'none';
});
// Voice selection
this.voiceSelect.addEventListener('change', (e) => {
this.selectedVoice = e.target.value;
localStorage.setItem('selectedVoice', this.selectedVoice);
this.updateVoiceWarnings();
});
// Language filter
if (this.languageSelect) {
this.languageSelect.addEventListener('change', () => {
this.populateVoiceList();
});
}
// Speech rate slider
if (this.speechRateSlider) {
this.speechRateSlider.addEventListener('input', (e) => {
this.speechRate = parseFloat(e.target.value);
this.speechRateInput.value = this.speechRate.toFixed(1);
localStorage.setItem('speechRate', this.speechRate.toString());
});
}
// Speech rate text input
if (this.speechRateInput) {
this.speechRateInput.addEventListener('input', (e) => {
let value = parseFloat(e.target.value);
if (!isNaN(value)) {
value = Math.max(0.5, Math.min(5, value));
this.speechRate = value;
this.speechRateSlider.value = value.toString();
this.speechRateInput.value = value.toFixed(1);
localStorage.setItem('speechRate', this.speechRate.toString());
}
});
}
// Test TTS button
if (this.testTTSBtn) {
this.testTTSBtn.addEventListener('click', () => {
this.speakText('This is Voice Mode for Claude Code. How can I help you today?');
});
}
}
async loadData() {
try {
// Load full conversation
const conversationResponse = await fetch(`${this.baseUrl}/api/conversation?limit=50`);
if (conversationResponse.ok) {
const data = await conversationResponse.json();
this.updateConversation(data.messages);
}
} catch (error) {
console.error('Failed to load data:', error);
}
}
updateConversation(messages) {
const container = this.conversationMessages;
const emptyState = container.querySelector('.empty-state');
if (messages.length === 0) {
emptyState.style.display = 'flex';
container.querySelectorAll('.message-bubble').forEach(el => el.remove());
return;
}
emptyState.style.display = 'none';
// Get existing message IDs to avoid duplicates
const existingBubbles = container.querySelectorAll('.message-bubble');
const existingIds = new Set();
existingBubbles.forEach(bubble => {
if (bubble.dataset.messageId) {
existingIds.add(bubble.dataset.messageId);
}
});
// Get waiting indicator to insert messages before it
const waitingIndicator = container.querySelector('.waiting-indicator');
// Only render new messages and update status for existing ones
messages.forEach(message => {
if (!existingIds.has(message.id)) {
// New message - create bubble and insert before waiting indicator
const bubble = this.createMessageBubble(message);
if (waitingIndicator) {
container.insertBefore(bubble, waitingIndicator);
} else {
container.appendChild(bubble);
}
} else {
// Existing message - update status if it's a user message
if (message.role === 'user' && message.status) {
const bubble = container.querySelector(`[data-message-id="${message.id}"]`);
if (bubble) {
const statusEl = bubble.querySelector('.message-status');
if (statusEl) {
// Check if status changed from pending to something else
const wasPending = statusEl.classList.contains('pending');
const isPending = message.status === 'pending';
if (wasPending && !isPending) {
// Status changed from pending - remove delete button
const deleteBtn = statusEl.querySelector('.delete-message-btn');
if (deleteBtn) {
deleteBtn.remove();
}
}
// Update status class and text
statusEl.className = `message-status ${message.status}`;
const statusText = statusEl.querySelector('span:last-child');
if (statusText) {
statusText.textContent = message.status.toUpperCase();
}
}
}
}
}
});
this.scrollToBottom();
}
createMessageBubble(message) {
const bubble = document.createElement('div');
bubble.className = `message-bubble ${message.role}`;
bubble.dataset.messageId = message.id;
const messageText = document.createElement('div');
messageText.className = 'message-text';
messageText.textContent = message.text;
const messageMeta = document.createElement('div');
messageMeta.className = 'message-meta';
const timestamp = document.createElement('span');
timestamp.className = 'message-timestamp';
timestamp.textContent = this.formatTimestamp(message.timestamp);
messageMeta.appendChild(timestamp);
// Only show status for user messages
if (message.role === 'user' && message.status) {
const statusContainer = document.createElement('div');
statusContainer.className = `message-status ${message.status}`;
// Add delete button for pending messages (shows on hover)
if (message.status === 'pending') {
const deleteBtn = document.createElement('span');
deleteBtn.className = 'delete-message-btn';
deleteBtn.innerHTML = `
<svg class="delete-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
`;
deleteBtn.onclick = (e) => {
e.stopPropagation();
this.deleteMessage(message.id);
};
statusContainer.appendChild(deleteBtn);
}
const statusText = document.createElement('span');
statusText.textContent = message.status.toUpperCase();
statusContainer.appendChild(statusText);
messageMeta.appendChild(statusContainer);
}
bubble.appendChild(messageText);
bubble.appendChild(messageMeta);
return bubble;
}
scrollToBottom() {
this.conversationContainer.scrollTo({
top: this.conversationContainer.scrollHeight,
behavior: 'smooth'
});
}
formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString();
}
// Text input handling
handleTextInputKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendTypedMessage();
}
// Shift+Enter allows new line
}
autoGrowTextarea() {
const textarea = this.messageInput;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
}
async sendTypedMessage() {
const text = this.messageInput.value.trim();
if (!text || this.isInterimText) return;
this.messageInput.value = '';
this.messageInput.style.height = 'auto';
await this.sendMessage(text);
}
async sendMessage(text) {
try {
const response = await fetch(`${this.baseUrl}/api/potential-utterances`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, timestamp: new Date().toISOString() })
});
if (response.ok) {
this.loadData();
}
} catch (error) {
console.error('Failed to send message:', error);
}
}
// Voice dictation
toggleVoiceDictation() {
if (this.isListening) {
this.stopVoiceDictation();
} else {
this.startVoiceDictation();
}
}
async startVoiceDictation() {
if (!this.recognition) {
alert('Speech recognition not supported in this browser');
return;
}
try {
if (this.isInterimText) {
this.messageInput.value = '';
this.isInterimText = false;
}
this.recognition.start();
this.isListening = true;
this.micBtn.classList.add('listening');
// Activate voice input when mic is on
await this.updateVoiceInputState(true);
} catch (e) {
console.error('Failed to start recognition:', e);
alert('Failed to start speech recognition');
}
}
async stopVoiceDictation() {
if (this.recognition) {
this.isListening = false;
this.recognition.stop();
this.micBtn.classList.remove('listening');
// Send any accumulated text in the input
const text = this.messageInput.value.trim();
if (text) {
// In trigger mode, check for trigger word
if (this.sendMode === 'trigger') {
if (this.containsTriggerWord(text)) {
const textToSend = this.removeTriggerWord(text);
await this.sendMessage(textToSend);
this.messageInput.value = '';
}
// If no trigger word, keep text in input for user to continue
} else {
// In automatic mode, send the text
await this.sendMessage(text);
this.messageInput.value = '';
}
}
this.isInterimText = false;
this.messageInput.style.height = 'auto';
// Deactivate voice input when mic is turned off
await this.updateVoiceInputState(false);
}
}
initializeSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.error('Speech recognition not supported');
this.micBtn.disabled = true;
return;
}
this.recognition = new SpeechRecognition();
this.recognition.continuous = true;
this.recognition.interimResults = true;
this.recognition.lang = 'en-US';
this.recognition.onresult = (event) => {
let interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
// User paused
this.isInterimText = false;
if (this.sendMode === 'automatic') {
// Send immediately
const finalText = this.messageInput.value.trim();
this.sendMessage(finalText);
this.messageInput.value = '';
this.accumulatedText = '';
} else {
// Trigger word mode: accumulate until trigger word
// Use the previously saved accumulated text (before interim was shown)
const previouslyAccumulated = this.accumulatedText || '';
const newUtterance = transcript.trim();
// Check if this new utterance contains the trigger word
if (this.containsTriggerWord(newUtterance)) {
// Send everything accumulated plus this utterance (minus trigger word)
const combined = previouslyAccumulated
? previouslyAccumulated + ' ' + newUtterance
: newUtterance;
const textToSend = this.removeTriggerWord(combined).trim();
if (textToSend) {
this.sendMessage(textToSend);
}
this.messageInput.value = '';
this.accumulatedText = '';
} else {
// No trigger word - append with space (no newlines)
const newAccumulated = previouslyAccumulated
? previouslyAccumulated + ' ' + newUtterance
: newUtterance;
this.messageInput.value = newAccumulated;
this.accumulatedText = newAccumulated;
this.autoGrowTextarea();
}
}
} else {
// Still speaking
interimTranscript += transcript;
}
}
if (interimTranscript) {
// In trigger mode, preserve accumulated text and append interim
if (this.sendMode === 'trigger' && this.accumulatedText) {
// Show accumulated + interim with single space
this.messageInput.value = this.accumulatedText + ' ' + interimTranscript.trim();
} else {
// Show just interim
this.messageInput.value = interimTranscript;
}
this.isInterimText = true;
this.autoGrowTextarea();
}
};
this.recognition.onerror = (event) => {
if (event.error !== 'no-speech') {
console.error('Speech error:', event.error);
this.stopVoiceDictation();
}
};
this.recognition.onend = () => {
if (this.isListening) {
try {
this.recognition.start();
} catch (e) {
console.error('Failed to restart recognition:', e);
this.stopVoiceDictation();
}
}
};
}
containsTriggerWord(text) {
if (!this.triggerWord) return false;
const words = text.toLowerCase().split(/\s+/);
return words.includes(this.triggerWord.toLowerCase());
}
removeTriggerWord(text) {
if (!this.triggerWord) return text;
const words = text.split(/\s+/);
const filtered = words.filter(w => w.toLowerCase() !== this.triggerWord.toLowerCase());
return filtered.join(' ');
}
async deleteMessage(messageId) {
try {
const response = await fetch(`${this.baseUrl}/api/utterances/${messageId}`, {
method: 'DELETE'
});
if (response.ok) {
// Remove the message bubble from DOM immediately
const bubble = this.conversationMessages.querySelector(`[data-message-id="${messageId}"]`);
if (bubble) {
bubble.remove();
}
// Refresh to sync with server
this.loadData();
} else {
const error = await response.json();
console.error('Failed to delete message:', error);
alert(`Failed to delete: ${error.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Failed to delete message:', error);
}
}
async updateVoiceInputState(active) {
try {
await fetch(`${this.baseUrl}/api/voice-input-state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active })
});
} catch (error) {
console.error('Failed to update voice input state:', error);
}
}
async updateVoiceResponses(enabled) {
try {
// Save to localStorage
localStorage.setItem('voiceResponsesEnabled', enabled.toString());
// Update server
await fetch(`${this.baseUrl}/api/voice-preferences`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voiceResponsesEnabled: enabled })
});
} catch (error) {
console.error('Failed to update voice responses:', error);
}
}
}
// Initialize when page loads
document.addEventListener('DOMContentLoaded', () => {
new MessengerClient();
});