Add interactive Chat with LLM interface to Web UI
Features: - Real-time streaming chat interface (ChatGPT-like experience) - Model selection: Text model (fast) or Vision model (image analysis) - System prompt toggle: Chat with Miku's personality or raw LLM - Mood selector: Choose from 14 different emotional states - Full context integration: Uses complete miku_lore.txt, miku_prompt.txt, and miku_lyrics.txt - Conversation memory: Maintains chat history throughout session - Image upload support for vision model - Horizontal scrolling tabs for responsive design - Clear chat history functionality - SSE (Server-Sent Events) for streaming responses - Keyboard shortcuts (Ctrl+Enter to send) Technical changes: - Added POST /chat/stream endpoint in api.py with streaming support - Updated ChatMessage model with mood, conversation_history, and image_data - Integrated context_manager for proper Miku personality context - Added Chat with LLM tab to index.html - Implemented JavaScript streaming client with EventSource-like handling - Added CSS for chat messages, typing indicators, and animations - Made tab navigation horizontally scrollable for narrow viewports
This commit is contained in:
@@ -419,6 +419,28 @@
|
||||
display: flex;
|
||||
border-bottom: 2px solid #333;
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #555 #222;
|
||||
}
|
||||
|
||||
.tab-buttons::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.tab-buttons::-webkit-scrollbar-track {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.tab-buttons::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tab-buttons::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@@ -430,6 +452,8 @@
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-right: 0.5rem;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
@@ -450,6 +474,110 @@
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Chat Interface Styles */
|
||||
.chat-message {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.chat-message.user-message {
|
||||
background: #2a3a4a;
|
||||
border-left: 4px solid #4CAF50;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.chat-message.assistant-message {
|
||||
background: #3a2a3a;
|
||||
border-left: 4px solid #61dafb;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.chat-message.error-message {
|
||||
background: #4a2a2a;
|
||||
border-left: 4px solid #f44336;
|
||||
}
|
||||
|
||||
.chat-message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.chat-message-sender {
|
||||
font-weight: bold;
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
.chat-message.user-message .chat-message-sender {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.chat-message-time {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.chat-message-content {
|
||||
color: #ddd;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-typing-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #61dafb;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite;
|
||||
}
|
||||
|
||||
.chat-typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.chat-typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.7; }
|
||||
30% { transform: translateY(-10px); opacity: 1; }
|
||||
}
|
||||
|
||||
#chat-messages::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
#chat-messages::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
#chat-messages::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#chat-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -468,6 +596,7 @@
|
||||
<button class="tab-button" onclick="switchTab('tab3')">Status</button>
|
||||
<button class="tab-button" onclick="switchTab('tab4')">🎨 Image Generation</button>
|
||||
<button class="tab-button" onclick="switchTab('tab5')">📊 Autonomous Stats</button>
|
||||
<button class="tab-button" onclick="switchTab('tab6')">💬 Chat with LLM</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1 Content -->
|
||||
@@ -904,6 +1033,132 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat with LLM Tab Content -->
|
||||
<div id="tab6" class="tab-content">
|
||||
<div class="section">
|
||||
<h3>💬 Chat with LLM</h3>
|
||||
<p>Direct chat interface with the language models. Test responses, experiment with prompts, or just chat with Miku!</p>
|
||||
|
||||
<!-- Configuration Options -->
|
||||
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1.5rem;">
|
||||
<h4 style="margin-top: 0; color: #61dafb;">⚙️ Chat Configuration</h4>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.5rem; margin-bottom: 1rem;">
|
||||
<!-- Model Selection -->
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">🤖 Model Type:</label>
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<label style="display: flex; align-items: center; cursor: pointer;">
|
||||
<input type="radio" name="chat-model-type" value="text" checked onchange="toggleChatImageUpload()">
|
||||
<span style="margin-left: 0.5rem;">💬 Text Model (Fast)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer;">
|
||||
<input type="radio" name="chat-model-type" value="vision" onchange="toggleChatImageUpload()">
|
||||
<span style="margin-left: 0.5rem;">👁️ Vision Model (Images)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
|
||||
Text model for conversations, Vision model for image analysis
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Prompt Toggle -->
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">🎭 System Prompt:</label>
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<label style="display: flex; align-items: center; cursor: pointer;">
|
||||
<input type="radio" name="chat-system-prompt" value="true" checked>
|
||||
<span style="margin-left: 0.5rem;">✅ Use Miku Personality</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer;">
|
||||
<input type="radio" name="chat-system-prompt" value="false">
|
||||
<span style="margin-left: 0.5rem;">❌ Raw LLM (No Prompt)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
|
||||
With prompt: Chat as Miku. Without: Direct LLM responses
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mood Selection -->
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">😊 Miku's Mood:</label>
|
||||
<select id="chat-mood-select" style="width: 100%; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px;">
|
||||
<option value="neutral" selected>neutral</option>
|
||||
<option value="angry">💢 angry</option>
|
||||
<option value="asleep">💤 asleep</option>
|
||||
<option value="bubbly">🫧 bubbly</option>
|
||||
<option value="curious">👀 curious</option>
|
||||
<option value="excited">✨ excited</option>
|
||||
<option value="flirty">🫦 flirty</option>
|
||||
<option value="irritated">😒 irritated</option>
|
||||
<option value="melancholy">🍷 melancholy</option>
|
||||
<option value="romantic">💌 romantic</option>
|
||||
<option value="serious">👔 serious</option>
|
||||
<option value="shy">👉👈 shy</option>
|
||||
<option value="silly">🪿 silly</option>
|
||||
<option value="sleepy">🌙 sleepy</option>
|
||||
</select>
|
||||
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
|
||||
Choose Miku's emotional state for this conversation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Upload for Vision Model -->
|
||||
<div id="chat-image-upload-section" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #444;">
|
||||
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">🖼️ Upload Image:</label>
|
||||
<input type="file" id="chat-image-file" accept="image/*" style="margin-bottom: 0.5rem;">
|
||||
<div style="font-size: 0.85rem; color: #aaa;">
|
||||
Upload an image for the vision model to analyze
|
||||
</div>
|
||||
<div id="chat-image-preview" style="margin-top: 0.5rem; display: none;">
|
||||
<img id="chat-image-preview-img" style="max-width: 200px; max-height: 200px; border: 1px solid #555; border-radius: 4px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Chat Button -->
|
||||
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #444;">
|
||||
<button onclick="clearChatHistory()" style="background: #ff9800;">🗑️ Clear Chat History</button>
|
||||
<span style="margin-left: 1rem; font-size: 0.85rem; color: #aaa;">Remove all messages from this session</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Messages Container -->
|
||||
<div id="chat-messages" style="background: #1e1e1e; border: 1px solid #444; border-radius: 8px; padding: 1rem; min-height: 400px; max-height: 500px; overflow-y: auto; margin-bottom: 1rem;">
|
||||
<div style="text-align: center; color: #888; padding: 2rem;">
|
||||
💬 Start chatting with the LLM! Your conversation will appear here.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Input Area -->
|
||||
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
|
||||
<div style="flex: 1;">
|
||||
<textarea
|
||||
id="chat-input"
|
||||
placeholder="Type your message here..."
|
||||
rows="3"
|
||||
style="width: 100%; padding: 0.75rem; background: #2a2a2a; color: #fff; border: 1px solid #555; border-radius: 4px; font-family: inherit; resize: vertical;"
|
||||
onkeydown="handleChatKeyPress(event)"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
id="chat-send-btn"
|
||||
onclick="sendChatMessage()"
|
||||
style="padding: 1rem 1.5rem; height: 100%; background: #4CAF50; font-size: 1rem; font-weight: bold;"
|
||||
>
|
||||
📤 Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: #aaa;">
|
||||
💡 Tip: Press Ctrl+Enter to send your message quickly
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3244,6 +3499,320 @@ function populateAutonomousServerDropdown() {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Chat Interface Functions ==========
|
||||
|
||||
// Store conversation history for context
|
||||
let chatConversationHistory = [];
|
||||
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
|
||||
// Preview uploaded image
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const imageInput = document.getElementById('chat-image-file');
|
||||
if (imageInput) {
|
||||
imageInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
const preview = document.getElementById('chat-image-preview');
|
||||
const previewImg = document.getElementById('chat-image-preview-img');
|
||||
previewImg.src = event.target.result;
|
||||
preview.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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">${sender}</span>
|
||||
<span class="chat-message-time">${timestamp}</span>
|
||||
</div>
|
||||
<div class="chat-message-content">${content}</div>
|
||||
`;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user