refactor: Modularize monolithic HTML control panel into organized components
This commit completes a major refactoring of the Miku control panel from a single 7,191-line monolithic HTML file to a modern modular architecture: CHANGES: - Extracted 872 lines of CSS into css/style.css - Created 10 specialized JavaScript modules (4,964 lines total): * core.js: Global state, utilities, initialization, polling system * servers.js: Server management and mood handling * modes.js: Evil mode, GPU selection, bipolar mode, scoreboard * actions.js: Autonomous/manual actions, custom prompts, reactions * image-gen.js: Image generation system * status.js: Status display and statistics * dm.js: DM user management and conversation analysis * chat.js: LLM chat interface with streaming and voice calls * memories.js: Cheshire Cat memory integration (episodic/declarative/procedural) * profile.js: Profile picture, album gallery, activities editor - Cleaned index.html to 1,351 lines (structure only, zero inline JS/CSS) - Removed 12 duplicate variable declarations - Maintained strict script load order for dependency resolution - Added backup comment to index.html.bak for historical reference VERIFICATION COMPLETED: ✓ All 191 functions/variables from original accounted for ✓ Cross-referenced with backup to ensure nothing lost ✓ All onclick handlers and modal systems validated ✓ No circular dependencies or broken references ✓ HTML structure integrity verified (11 tabs, all buttons/modals intact) ✓ CropperJS CDN links preserved The refactored code is production-ready with improved maintainability and clear separation of concerns.
This commit is contained in:
498
bot/static/js/chat.js
Normal file
498
bot/static/js/chat.js
Normal file
@@ -0,0 +1,498 @@
|
||||
// ============================================================================
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user