// ============================================================================ // Miku Control Panel — Chat Interface + Voice Call Module // ============================================================================ // Toggle image upload section based on model type function toggleChatImageUpload() { const modelType = document.querySelector('input[name="chat-model-type"]:checked').value; const imageUploadSection = document.getElementById('chat-image-upload-section'); if (modelType === 'vision') { imageUploadSection.style.display = 'block'; } else { imageUploadSection.style.display = 'none'; } } // Load voice debug mode setting from server async function loadVoiceDebugMode() { try { const data = await apiCall('/voice/debug-mode'); const checkbox = document.getElementById('voice-debug-mode'); if (checkbox && data.debug_mode !== undefined) { checkbox.checked = data.debug_mode; } } catch (error) { console.error('Failed to load voice debug mode:', error); } } // Handle Enter key in chat input function handleChatKeyPress(event) { if (event.ctrlKey && event.key === 'Enter') { event.preventDefault(); sendChatMessage(); } } // Clear chat history function clearChatHistory() { if (confirm('Are you sure you want to clear all chat messages?')) { const chatMessages = document.getElementById('chat-messages'); chatMessages.innerHTML = `
💬 Start chatting with the LLM! Your conversation will appear here.
`; // Clear conversation history array chatConversationHistory = []; showNotification('Chat history cleared'); } } // Add a message to the chat display function addChatMessage(sender, content, isError = false) { const chatMessages = document.getElementById('chat-messages'); // Remove welcome message if it exists const welcomeMsg = chatMessages.querySelector('div[style*="text-align: center"]'); if (welcomeMsg) { welcomeMsg.remove(); } const messageDiv = document.createElement('div'); const messageClass = isError ? 'error-message' : (sender === 'You' ? 'user-message' : 'assistant-message'); messageDiv.className = `chat-message ${messageClass}`; const timestamp = new Date().toLocaleTimeString(); messageDiv.innerHTML = `
${escapeHtml(sender)} ${timestamp}
`; // Set content via textContent to prevent XSS messageDiv.querySelector('.chat-message-content').textContent = content; chatMessages.appendChild(messageDiv); // Scroll to bottom chatMessages.scrollTop = chatMessages.scrollHeight; return messageDiv; } // Add typing indicator function showTypingIndicator() { const chatMessages = document.getElementById('chat-messages'); const typingDiv = document.createElement('div'); typingDiv.id = 'chat-typing-indicator'; typingDiv.className = 'chat-message assistant-message'; typingDiv.innerHTML = `
Miku typing...
`; chatMessages.appendChild(typingDiv); chatMessages.scrollTop = chatMessages.scrollHeight; } // Remove typing indicator function hideTypingIndicator() { const typingIndicator = document.getElementById('chat-typing-indicator'); if (typingIndicator) { typingIndicator.remove(); } } // Send chat message with streaming support async function sendChatMessage() { const input = document.getElementById('chat-input'); const message = input.value.trim(); if (!message) { showNotification('Please enter a message', 'error'); return; } // Get configuration const modelType = document.querySelector('input[name="chat-model-type"]:checked').value; const useSystemPrompt = document.querySelector('input[name="chat-system-prompt"]:checked').value === 'true'; const selectedMood = document.getElementById('chat-mood-select').value; // Get image data if vision model let imageData = null; if (modelType === 'vision') { const imageFile = document.getElementById('chat-image-file').files[0]; if (imageFile) { try { imageData = await readFileAsBase64(imageFile); // Remove data URL prefix if present if (imageData.includes(',')) { imageData = imageData.split(',')[1]; } } catch (error) { showNotification('Failed to read image file', 'error'); return; } } } // Disable send button const sendBtn = document.getElementById('chat-send-btn'); const originalBtnText = sendBtn.innerHTML; sendBtn.disabled = true; sendBtn.innerHTML = '⏳ Sending...'; // Add user message to display addChatMessage('You', message); // Clear input input.value = ''; // Show typing indicator showTypingIndicator(); try { // Build user message for history let userMessageContent; if (modelType === 'vision' && imageData) { // Vision model with image - store as multimodal content userMessageContent = [ { "type": "text", "text": message }, { "type": "image_url", "image_url": { "url": `data:image/jpeg;base64,${imageData}` } } ]; } else { // Text-only message userMessageContent = message; } // Prepare request payload with conversation history const payload = { message: message, model_type: modelType, use_system_prompt: useSystemPrompt, image_data: imageData, conversation_history: chatConversationHistory, mood: selectedMood }; // Make streaming request const response = await fetch('/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } // Hide typing indicator hideTypingIndicator(); // Create message element for streaming response const assistantName = useSystemPrompt ? 'Miku' : 'LLM'; const responseDiv = addChatMessage(assistantName, ''); const contentDiv = responseDiv.querySelector('.chat-message-content'); // Read stream const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let fullResponse = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Process complete SSE messages const lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { if (line.startsWith('data: ')) { const dataStr = line.slice(6); try { const data = JSON.parse(dataStr); if (data.error) { contentDiv.textContent = `❌ Error: ${data.error}`; responseDiv.classList.add('error-message'); break; } if (data.content) { fullResponse += data.content; contentDiv.textContent = fullResponse; // Auto-scroll const chatMessages = document.getElementById('chat-messages'); chatMessages.scrollTop = chatMessages.scrollHeight; } if (data.done) { break; } } catch (e) { console.error('Failed to parse SSE data:', e); } } } } // If no response was received, show error if (!fullResponse) { contentDiv.textContent = '❌ No response received from LLM'; responseDiv.classList.add('error-message'); } else { // Add user message to conversation history chatConversationHistory.push({ role: "user", content: userMessageContent }); // Add assistant response to conversation history chatConversationHistory.push({ role: "assistant", content: fullResponse }); console.log('💬 Conversation history updated:', chatConversationHistory.length, 'messages'); } } catch (error) { console.error('Chat error:', error); hideTypingIndicator(); addChatMessage('Error', `Failed to send message: ${error.message}`, true); showNotification('Failed to send message', 'error'); } finally { // Re-enable send button sendBtn.disabled = false; sendBtn.innerHTML = originalBtnText; } } // Helper function to read file as base64 function readFileAsBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); } // ============================================================================ // Voice Call Management Functions // ============================================================================ async function initiateVoiceCall() { const userId = document.getElementById('voice-user-id').value.trim(); const channelId = document.getElementById('voice-channel-id').value.trim(); const debugMode = document.getElementById('voice-debug-mode').checked; // Validation if (!userId) { showNotification('Please enter a user ID', 'error'); return; } if (!channelId) { showNotification('Please enter a voice channel ID', 'error'); return; } // Check if user IDs are valid (numeric) if (isNaN(userId) || isNaN(channelId)) { showNotification('User ID and Channel ID must be numeric', 'error'); return; } // Set debug mode try { const debugFormData = new FormData(); debugFormData.append('enabled', debugMode); await fetch('/voice/debug-mode', { method: 'POST', body: debugFormData }); } catch (error) { console.error('Failed to set debug mode:', error); } // Disable button and show status const callBtn = document.getElementById('voice-call-btn'); const cancelBtn = document.getElementById('voice-call-cancel-btn'); const statusDiv = document.getElementById('voice-call-status'); const statusText = document.getElementById('voice-call-status-text'); callBtn.disabled = true; statusDiv.style.display = 'block'; cancelBtn.style.display = 'inline-block'; voiceCallActive = true; try { statusText.innerHTML = '⏳ Starting STT and TTS containers...'; const formData = new FormData(); formData.append('user_id', userId); formData.append('voice_channel_id', channelId); const response = await fetch('/voice/call', { method: 'POST', body: formData }); const data = await response.json(); // Check for HTTP error status (422 validation error, etc.) if (!response.ok) { let errorMsg = data.error || data.detail || 'Unknown error'; // Handle FastAPI validation errors if (data.detail && Array.isArray(data.detail)) { errorMsg = data.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', '); } statusText.innerHTML = `❌ Error: ${errorMsg}`; showNotification(`Voice call failed: ${errorMsg}`, 'error'); callBtn.disabled = false; cancelBtn.style.display = 'none'; voiceCallActive = false; return; } if (!data.success) { statusText.innerHTML = `❌ Error: ${data.error}`; showNotification(`Voice call failed: ${data.error}`, 'error'); callBtn.disabled = false; cancelBtn.style.display = 'none'; voiceCallActive = false; return; } // Success! statusText.innerHTML = `✅ Voice call initiated!
User ID: ${data.user_id}
Channel: ${data.channel_id}`; // Show invite link const inviteDiv = document.getElementById('voice-call-invite-link'); const inviteUrl = document.getElementById('voice-call-invite-url'); inviteUrl.href = data.invite_url; inviteUrl.textContent = data.invite_url; inviteDiv.style.display = 'block'; // Add to call history addVoiceCallToHistory(userId, channelId, data.invite_url); showNotification('Voice call initiated successfully!', 'success'); // Auto-reset after 5 minutes (call should be done by then or timed out) setTimeout(() => { if (voiceCallActive) { resetVoiceCall(); } }, 300000); // 5 minutes } catch (error) { console.error('Voice call error:', error); statusText.innerHTML = `❌ Error: ${error.message}`; showNotification(`Voice call error: ${error.message}`, 'error'); callBtn.disabled = false; cancelBtn.style.display = 'none'; voiceCallActive = false; } } function cancelVoiceCall() { resetVoiceCall(); showNotification('Voice call cancelled', 'info'); } function resetVoiceCall() { const callBtn = document.getElementById('voice-call-btn'); const cancelBtn = document.getElementById('voice-call-cancel-btn'); const statusDiv = document.getElementById('voice-call-status'); callBtn.disabled = false; cancelBtn.style.display = 'none'; statusDiv.style.display = 'none'; voiceCallActive = false; // Clear inputs document.getElementById('voice-user-id').value = ''; document.getElementById('voice-channel-id').value = ''; } function addVoiceCallToHistory(userId, channelId, inviteUrl) { const now = new Date(); const timestamp = now.toLocaleTimeString(); const callEntry = { userId: userId, channelId: channelId, inviteUrl: inviteUrl, timestamp: timestamp }; voiceCallHistory.unshift(callEntry); // Add to front // Keep only last 10 calls if (voiceCallHistory.length > 10) { voiceCallHistory.pop(); } updateVoiceCallHistoryDisplay(); } function updateVoiceCallHistoryDisplay() { const historyDiv = document.getElementById('voice-call-history'); if (voiceCallHistory.length === 0) { historyDiv.innerHTML = '
No calls yet. Start one above!
'; return; } let html = ''; voiceCallHistory.forEach((call, index) => { html += `
${call.timestamp}
User: ${call.userId} | Channel: ${call.channelId}
View Link →
`; }); historyDiv.innerHTML = html; }