499 lines
15 KiB
JavaScript
499 lines
15 KiB
JavaScript
|
|
// ============================================================================
|
||
|
|
// 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 = `
|
||
|
|
<div style="text-align: center; color: #888; padding: 2rem;">
|
||
|
|
💬 Start chatting with the LLM! Your conversation will appear here.
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
// 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 = `
|
||
|
|
<div class="chat-message-header">
|
||
|
|
<span class="chat-message-sender">${escapeHtml(sender)}</span>
|
||
|
|
<span class="chat-message-time">${timestamp}</span>
|
||
|
|
</div>
|
||
|
|
<div class="chat-message-content"></div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
// 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 = `
|
||
|
|
<div class="chat-message-header">
|
||
|
|
<span class="chat-message-sender">Miku</span>
|
||
|
|
<span class="chat-message-time">typing...</span>
|
||
|
|
</div>
|
||
|
|
<div class="chat-typing-indicator">
|
||
|
|
<span></span>
|
||
|
|
<span></span>
|
||
|
|
<span></span>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
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!<br>User ID: ${data.user_id}<br>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 = '<div style="text-align: center; color: #888;">No calls yet. Start one above!</div>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let html = '';
|
||
|
|
voiceCallHistory.forEach((call, index) => {
|
||
|
|
html += `
|
||
|
|
<div style="background: #242424; padding: 0.75rem; margin-bottom: 0.5rem; border-radius: 4px; border-left: 3px solid #61dafb;">
|
||
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
|
|
<div>
|
||
|
|
<strong>${call.timestamp}</strong>
|
||
|
|
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
|
||
|
|
User: <code>${call.userId}</code> | Channel: <code>${call.channelId}</code>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<a href="${call.inviteUrl}" target="_blank" style="color: #61dafb; text-decoration: none; padding: 0.3rem 0.7rem; background: #333; border-radius: 4px; font-size: 0.85rem;">
|
||
|
|
View Link →
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
});
|
||
|
|
|
||
|
|
historyDiv.innerHTML = html;
|
||
|
|
}
|