Remove obsolete VALID_VOICES validation that was rejecting the new "model:voice" format from the voices endpoint integration. The old validation expected simple voice names like "onyx" but the new format uses "tts-1:onyx", causing validation to fail and produce malformed API requests that returned HTTP 400 errors. Changes: - Remove VALID_VOICES constant (no longer needed) - Update syncInputsAndQueryString() to accept any voice value from dropdown - Default to "tts-1:onyx" format if no value present Fixes the TTS errors seen in production where voice selection was failing with HTTP 400 status.
1691 lines
66 KiB
HTML
1691 lines
66 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Chatroom{% endblock %}
|
|
|
|
{% block content %}
|
|
|
|
<div id="chat-container">
|
|
<!-- Chat area where messages will be displayed -->
|
|
<div id="chat"></div>
|
|
<!-- Form for sending messages -->
|
|
<form id="message-form">
|
|
<textarea id="message" placeholder="Type your message..."></textarea>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="utility-belt">
|
|
<div class="download-links">
|
|
History
|
|
<a href="/download_chat_history?room_name={{ room_name }}" download="{{ room_name }}.json">JSON</a> or
|
|
<a href="/download_chat_history_md?room_name={{ room_name }}" download="{{ room_name }}.md">Markdown</a>
|
|
</div>
|
|
<br>
|
|
<div>
|
|
<label for="username-input">Username</label>
|
|
<input type="text" id="username-input" placeholder="guest" maxlength="50">
|
|
</div>
|
|
<div>
|
|
<label for="model-select">Model</label>
|
|
<select id="model-select">
|
|
<option value="None">None</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="voice-select">Voice</label>
|
|
<select id="voice-select">
|
|
<option value="onyx">Onyx</option>
|
|
<option value="alloy">Alloy</option>
|
|
<option value="echo">Echo</option>
|
|
<option value="fable">Fable</option>
|
|
<option value="nova">Nova</option>
|
|
<option value="shimmer">Shimmer</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<button id="theme-toggle-btn" onclick="toggleTheme()">
|
|
🌙 Dark Mode
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<button id="auto-play-tts-btn" onclick="toggleAutoPlayTTS()" style="width: 100%; margin-top: 10px; background-color: #f44336; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer;">
|
|
Auto-Play TTS: OFF
|
|
</button>
|
|
</div>
|
|
|
|
<div id="activity-controls">
|
|
<h3>Activities</h3>
|
|
<div id="current-activity-info" style="display: none;">
|
|
<p>Current Activity: <span id="current-activity-name"></span></p>
|
|
<button id="cancel-activity-btn" onclick="cancelActivity()">Cancel Activity</button>
|
|
</div>
|
|
<div id="activity-list-section">
|
|
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 5px; margin-bottom: 5px;">
|
|
<select id="activity-select">
|
|
<option value="">-- Select an Activity --</option>
|
|
</select>
|
|
<button id="refresh-activities-btn" onclick="refreshActivityList()">🔄</button>
|
|
</div>
|
|
<button id="load-activity-btn" onclick="loadSelectedActivity()" style="margin-top: 5px;">Load Activity</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="user-lists">
|
|
<div id="active-users-list">
|
|
<h3>Active Users</h3>
|
|
<ul id="active-users">
|
|
<!-- Active users will be dynamically populated here -->
|
|
</ul>
|
|
</div>
|
|
<div id="inactive-users-list">
|
|
<h3>Inactive Users</h3>
|
|
<ul id="inactive-users">
|
|
<!-- Inactive users will be dynamically populated here -->
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// Constants
|
|
const API_KEY = "dummy-api-key";
|
|
const TTS_API_URL = "https://speech.ai.unturf.com/v1/audio/speech";
|
|
const VOICES_API_URL = "https://speech.ai.unturf.com/v1/voices";
|
|
const CODE_EXEC_URL = "https://code.ai.unturf.com"; // Code execution service URL (served via Caddy)
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
let username = urlParams.get("username") || "guest"; // Default to "guest" if no username in URL
|
|
|
|
// If no username was in the URL, add it now
|
|
if (!urlParams.get("username")) {
|
|
const newUrl = new URL(window.location.href);
|
|
newUrl.searchParams.set("username", username);
|
|
window.history.replaceState({}, '', newUrl);
|
|
}
|
|
const room_name = "{{ room_name }}";
|
|
|
|
// Configuration for DOMPurify to specify which tags and attributes are allowed
|
|
const dompurify_config = {
|
|
ADD_TAGS: ["iframe", "img", "video"],
|
|
FORBID_TAGS: ["form"],
|
|
ALLOWED_ATTR: [
|
|
"src", "width", "height", "frameborder", "allowfullscreen",
|
|
"alt", "class", "title", "style", "controls",
|
|
]
|
|
};
|
|
|
|
// keeping track of scrolling to prevent autoscrolling.
|
|
let userHasScrolledUp = false;
|
|
let currentAudio = null; // To keep track of the currently playing audio
|
|
let currentQueuedAudio = null; // To keep track of currently playing queued TTS audio
|
|
let audioCache = {}; // Cache to store audio blobs
|
|
|
|
// Flag to prevent mutual updates on desktop/mobile
|
|
let isSyncingDropdowns = false;
|
|
|
|
// Auto-play TTS state
|
|
let autoPlayTTS = localStorage.getItem('autoPlayTTS') === 'true' || false;
|
|
let ttsQueue = [];
|
|
let isPlayingTTS = false;
|
|
|
|
// Function to sanitize the username
|
|
function sanitizeUsername(username) {
|
|
// Split the username on commas and take the first part.
|
|
// The backend denormalizes the user list in the room table via csv.
|
|
return username.split(',')[0].trim();
|
|
}
|
|
|
|
// Function to copy message content to clipboard
|
|
function copyMessageContent(content) {
|
|
navigator.clipboard.writeText(content).then(() => {
|
|
// Optional: show a temporary success message
|
|
const button = event.currentTarget;
|
|
const originalText = button.textContent;
|
|
button.textContent = 'Copied!';
|
|
setTimeout(() => {
|
|
button.textContent = originalText;
|
|
}, 2000);
|
|
}).catch(err => {
|
|
console.error('Error copying text: ', err);
|
|
});
|
|
}
|
|
|
|
// Function to sync all inputs and update the query string
|
|
function syncInputsAndQueryString() {
|
|
const usernameInputDesktop = document.getElementById("username-input");
|
|
const usernameInputMobile = document.getElementById("username-input-mobile");
|
|
const modelSelectDesktop = document.getElementById("model-select");
|
|
const voiceSelectDesktop = document.getElementById("voice-select");
|
|
const modelSelectMobile = document.getElementById("model-select-mobile");
|
|
const voiceSelectMobile = document.getElementById("voice-select-mobile");
|
|
|
|
// Get current values
|
|
const currentUsername = usernameInputDesktop?.value || username || "guest";
|
|
const currentModel = modelSelectDesktop.value;
|
|
const currentVoice = voiceSelectDesktop.value || 'tts-1:onyx';
|
|
|
|
// Update global username variable
|
|
username = currentUsername;
|
|
const sanitizedUsername = sanitizeUsername(username);
|
|
|
|
// Sync username inputs
|
|
if (usernameInputDesktop) usernameInputDesktop.value = sanitizedUsername;
|
|
if (usernameInputMobile) usernameInputMobile.value = sanitizedUsername;
|
|
|
|
// Sync dropdowns
|
|
modelSelectDesktop.value = currentModel;
|
|
voiceSelectDesktop.value = currentVoice;
|
|
if (modelSelectMobile) modelSelectMobile.value = currentModel;
|
|
if (voiceSelectMobile) voiceSelectMobile.value = currentVoice;
|
|
|
|
// Save to localStorage for persistence
|
|
localStorage.setItem('selectedModel', currentModel);
|
|
localStorage.setItem('selectedVoice', currentVoice);
|
|
|
|
// Update URL
|
|
const newUrl = new URL(window.location.href);
|
|
newUrl.searchParams.set("username", sanitizedUsername);
|
|
newUrl.searchParams.set("model", currentModel);
|
|
newUrl.searchParams.set("voice", currentVoice);
|
|
window.history.replaceState({}, '', newUrl);
|
|
|
|
// Update room links with new parameters
|
|
if (typeof updateRoomLinksWithCurrentParams === 'function') {
|
|
updateRoomLinksWithCurrentParams();
|
|
}
|
|
}
|
|
|
|
// Backward compatibility
|
|
function syncDropdownsAndQueryString() {
|
|
syncInputsAndQueryString();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', (event) => {
|
|
const chatContainer = document.getElementById("chat");
|
|
const modelSelectDesktop = document.getElementById("model-select");
|
|
const voiceSelectDesktop = document.getElementById("voice-select");
|
|
const modelSelectMobile = document.getElementById("model-select-mobile");
|
|
const voiceSelectMobile = document.getElementById("voice-select-mobile");
|
|
|
|
// Initialize auto-play TTS button state from localStorage
|
|
updateAutoPlayTTSDisplay();
|
|
|
|
// Function to populate the model dropdown
|
|
function populateModelDropdown(models) {
|
|
// Clear options starting from index 1 (preserve "None" at index 0)
|
|
while (modelSelectDesktop.options.length > 1) {
|
|
modelSelectDesktop.remove(1);
|
|
}
|
|
// Append new model options
|
|
models.forEach(modelId => {
|
|
const option = document.createElement('option');
|
|
option.value = modelId;
|
|
option.textContent = modelId;
|
|
modelSelectDesktop.appendChild(option);
|
|
});
|
|
// Set initial value from URL
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const initialModel = urlParams.get("model") || "None";
|
|
modelSelectDesktop.value = initialModel;
|
|
}
|
|
|
|
// Function to populate the voice dropdown
|
|
function populateVoiceDropdown(voicesData) {
|
|
// Clear existing options
|
|
voiceSelectDesktop.innerHTML = '';
|
|
if (voiceSelectMobile) voiceSelectMobile.innerHTML = '';
|
|
|
|
// Group voices by model
|
|
const voicesByModel = {};
|
|
voicesData.data.forEach(modelData => {
|
|
const modelId = modelData.id;
|
|
voicesByModel[modelId] = modelData.voices || [];
|
|
});
|
|
|
|
// Create optgroups for each model
|
|
Object.entries(voicesByModel).forEach(([modelId, voices]) => {
|
|
if (voices.length > 0) {
|
|
const optgroup = document.createElement('optgroup');
|
|
optgroup.label = modelId;
|
|
|
|
voices.forEach(voice => {
|
|
const option = document.createElement('option');
|
|
option.value = `${modelId}:${voice}`;
|
|
option.textContent = `${modelId} - ${voice}`;
|
|
optgroup.appendChild(option);
|
|
});
|
|
|
|
voiceSelectDesktop.appendChild(optgroup);
|
|
if (voiceSelectMobile) {
|
|
voiceSelectMobile.appendChild(optgroup.cloneNode(true));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Set initial value from URL or default to first option
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const initialVoice = urlParams.get("voice") || localStorage.getItem('selectedVoice') || voiceSelectDesktop.options[0]?.value || "tts-1:onyx";
|
|
voiceSelectDesktop.value = initialVoice;
|
|
if (voiceSelectMobile) voiceSelectMobile.value = initialVoice;
|
|
}
|
|
|
|
// Memoization with localStorage (1-minute cache)
|
|
const cacheKey = 'modelList';
|
|
const cacheExpirationKey = 'modelListExpiration';
|
|
const cacheDuration = 60 * 1000; // 1 minute in milliseconds
|
|
|
|
const cachedData = localStorage.getItem(cacheKey);
|
|
const cachedExpiration = localStorage.getItem(cacheExpirationKey);
|
|
|
|
if (cachedData && cachedExpiration && Date.now() < parseInt(cachedExpiration)) {
|
|
// Use cached data if it exists and hasn't expired
|
|
const models = JSON.parse(cachedData);
|
|
populateModelDropdown(models);
|
|
} else {
|
|
// Fetch from backend and update cache
|
|
fetch('/models')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const models = data.models;
|
|
populateModelDropdown(models);
|
|
// Store in localStorage with expiration
|
|
localStorage.setItem(cacheKey, JSON.stringify(models));
|
|
localStorage.setItem(cacheExpirationKey, Date.now() + cacheDuration);
|
|
})
|
|
.catch(error => console.error("Error fetching models:", error));
|
|
}
|
|
|
|
// Fetch and populate voices with caching
|
|
const voicesCacheKey = 'voicesList';
|
|
const voicesCacheExpirationKey = 'voicesListExpiration';
|
|
|
|
const cachedVoices = localStorage.getItem(voicesCacheKey);
|
|
const cachedVoicesExpiration = localStorage.getItem(voicesCacheExpirationKey);
|
|
|
|
if (cachedVoices && cachedVoicesExpiration && Date.now() < parseInt(cachedVoicesExpiration)) {
|
|
// Use cached voices data
|
|
const voicesData = JSON.parse(cachedVoices);
|
|
populateVoiceDropdown(voicesData);
|
|
} else {
|
|
// Fetch voices from API
|
|
fetch(VOICES_API_URL, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${API_KEY}`
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(voicesData => {
|
|
populateVoiceDropdown(voicesData);
|
|
// Store in localStorage with expiration
|
|
localStorage.setItem(voicesCacheKey, JSON.stringify(voicesData));
|
|
localStorage.setItem(voicesCacheExpirationKey, Date.now() + cacheDuration);
|
|
})
|
|
.catch(error => {
|
|
console.error("Error fetching voices:", error);
|
|
// Fallback to default voice if fetch fails
|
|
voiceSelectDesktop.innerHTML = '<option value="tts-1:onyx">tts-1 - onyx</option>';
|
|
if (voiceSelectMobile) voiceSelectMobile.innerHTML = '<option value="tts-1:onyx">tts-1 - onyx</option>';
|
|
});
|
|
}
|
|
|
|
chatContainer.addEventListener('scroll', () => {
|
|
const distanceFromBottom = chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight;
|
|
userHasScrolledUp = distanceFromBottom > 5;
|
|
});
|
|
|
|
// Load model and voice from localStorage if not in URL
|
|
const storedModel = localStorage.getItem('selectedModel');
|
|
const storedVoice = localStorage.getItem('selectedVoice');
|
|
|
|
// Set initial model, voice, and username from URL, localStorage, or defaults
|
|
const initialModel = urlParams.get("model") || storedModel || "None";
|
|
const initialVoice = urlParams.get("voice") || storedVoice || "tts-1:onyx";
|
|
const initialUsername = username; // Already set to URL param or "guest"
|
|
|
|
modelSelectDesktop.value = initialModel;
|
|
// Voice is set by populateVoiceDropdown after voices are fetched
|
|
modelSelectMobile.value = initialModel;
|
|
|
|
// Set initial username values
|
|
const usernameInputDesktop = document.getElementById("username-input");
|
|
const usernameInputMobile = document.getElementById("username-input-mobile");
|
|
if (usernameInputDesktop) usernameInputDesktop.value = initialUsername;
|
|
if (usernameInputMobile) usernameInputMobile.value = initialUsername;
|
|
|
|
// Initial sync to ensure localStorage and URL are updated with current values
|
|
syncInputsAndQueryString();
|
|
|
|
// Add event listeners for desktop dropdowns
|
|
modelSelectDesktop.addEventListener("change", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
modelSelectMobile.value = modelSelectDesktop.value;
|
|
syncDropdownsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
|
|
voiceSelectDesktop.addEventListener("change", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
voiceSelectMobile.value = voiceSelectDesktop.value;
|
|
syncDropdownsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
|
|
// Add event listeners for mobile dropdowns
|
|
modelSelectMobile.addEventListener("change", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
modelSelectDesktop.value = modelSelectMobile.value;
|
|
syncDropdownsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
|
|
voiceSelectMobile.addEventListener("change", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
voiceSelectDesktop.value = voiceSelectMobile.value;
|
|
syncDropdownsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
|
|
// Add event listeners for username inputs
|
|
if (usernameInputDesktop) {
|
|
usernameInputDesktop.addEventListener("input", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
if (usernameInputMobile) {
|
|
usernameInputMobile.value = usernameInputDesktop.value;
|
|
}
|
|
syncInputsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
|
|
usernameInputDesktop.addEventListener("blur", () => {
|
|
syncInputsAndQueryString();
|
|
});
|
|
}
|
|
|
|
if (usernameInputMobile) {
|
|
usernameInputMobile.addEventListener("input", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
if (usernameInputDesktop) {
|
|
usernameInputDesktop.value = usernameInputMobile.value;
|
|
}
|
|
syncInputsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
|
|
usernameInputMobile.addEventListener("blur", () => {
|
|
syncInputsAndQueryString();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Socket event when the user connects
|
|
socket.on("connect", () => {
|
|
// Sanitize the username before joining
|
|
const sanitizedUsername = sanitizeUsername(username);
|
|
socket.emit("join", {"username": sanitizedUsername, "room_name": room_name});
|
|
// Sync inputs and update the query string
|
|
syncInputsAndQueryString();
|
|
});
|
|
|
|
// Function to update the active and inactive user lists in the DOM
|
|
function updateUserLists(activeUsers, inactiveUsers) {
|
|
const activeUserListElement = document.getElementById("active-users");
|
|
const inactiveUserListElement = document.getElementById("inactive-users");
|
|
const activeUserListElementMobile = document.getElementById("active-users-mobile");
|
|
const inactiveUserListElementMobile = document.getElementById("inactive-users-mobile");
|
|
|
|
activeUserListElement.innerHTML = ''; // Clear the current list
|
|
inactiveUserListElement.innerHTML = ''; // Clear the current list
|
|
activeUserListElementMobile.innerHTML = ''; // Clear the current list (mobile)
|
|
inactiveUserListElementMobile.innerHTML = ''; // Clear the current list (mobile)
|
|
|
|
// Populate the list with active users (desktop)
|
|
activeUsers.forEach(username => {
|
|
const userItem = document.createElement("li");
|
|
userItem.textContent = username;
|
|
activeUserListElement.appendChild(userItem);
|
|
});
|
|
|
|
// Populate the list with inactive users (desktop)
|
|
inactiveUsers.forEach(username => {
|
|
const userItem = document.createElement("li");
|
|
userItem.textContent = username;
|
|
inactiveUserListElement.appendChild(userItem);
|
|
});
|
|
|
|
// Populate the list with active users (mobile)
|
|
activeUsers.forEach(username => {
|
|
const userItemMobile = document.createElement("li");
|
|
userItemMobile.textContent = username;
|
|
activeUserListElementMobile.appendChild(userItemMobile);
|
|
});
|
|
|
|
// Populate the list with inactive users (mobile)
|
|
inactiveUsers.forEach(username => {
|
|
const userItemMobile = document.createElement("li");
|
|
userItemMobile.textContent = username;
|
|
inactiveUserListElementMobile.appendChild(userItemMobile);
|
|
});
|
|
}
|
|
|
|
// Update user lists whenever the event is received
|
|
socket.on("active_users", (data) => {
|
|
updateUserLists(data.active_users, data.inactive_users);
|
|
});
|
|
|
|
// Function to handle sending the message
|
|
function sendMessage() {
|
|
const messageTextarea = document.getElementById("message");
|
|
const message = messageTextarea.value;
|
|
const model = document.getElementById("model-select").value;
|
|
let messageToSend = message.trim();
|
|
|
|
if (messageToSend !== "") { // Ensure we're not sending empty messages
|
|
socket.emit("chat_message", {
|
|
"username": username,
|
|
"message": messageToSend,
|
|
"model": model, // Pass model as a separate attribute
|
|
"room_name": room_name
|
|
});
|
|
messageTextarea.value = "";
|
|
// Reset textarea height after sending
|
|
messageTextarea.style.height = 'auto';
|
|
}
|
|
}
|
|
|
|
// Function to handle deleting a message
|
|
function deleteMessage(messageId, room_name) {
|
|
socket.emit("delete_message", {"message_id": messageId, "room_name": room_name});
|
|
}
|
|
|
|
// Event listener for form submission to send a message
|
|
document.getElementById("message-form").addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
});
|
|
|
|
// Event listener for the Enter key press in the textarea to send a message
|
|
document.getElementById("message").addEventListener("keydown", function(e) {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
});
|
|
|
|
// Auto-grow textarea as user types
|
|
document.getElementById("message").addEventListener("input", function() {
|
|
// Reset height to auto to get the correct scrollHeight
|
|
this.style.height = 'auto';
|
|
// Set height to scrollHeight to fit content
|
|
this.style.height = this.scrollHeight + 'px';
|
|
});
|
|
|
|
// Socket event for updating the room title
|
|
socket.on("update_room_title", (data) => {
|
|
document.title = data.title; // Update the window's title
|
|
});
|
|
|
|
// Socket event to update the room title in the sidebar.
|
|
socket.on('update_room_list', function(updatedRoom) {
|
|
// Find the room list item by its data-room-id attribute
|
|
const roomListItem = document.querySelector(`#rooms-list li[data-room-id="${updatedRoom.id}"]`);
|
|
|
|
if (roomListItem) {
|
|
// Update the room list item's content with the new title
|
|
roomListItem.innerHTML = `<b>${updatedRoom.name}</b> ${updatedRoom.title ? '<br />' + updatedRoom.title : ''}`;
|
|
}
|
|
});
|
|
|
|
// Function to read text using TTS (for manual button clicks)
|
|
async function speakText(text, playButton, messageId) {
|
|
console.log("speakText called with:", {text: text, messageId, autoPlay: autoPlayTTS});
|
|
const voiceSelectValue = document.getElementById("voice-select").value;
|
|
|
|
// Parse model and voice from the dropdown value (format: "model:voice")
|
|
const [model, voice] = voiceSelectValue.includes(':') ? voiceSelectValue.split(':') : ['tts-1', voiceSelectValue];
|
|
const cacheKey = `${messageId}-${voiceSelectValue}`; // Unique cache key for each message and voice
|
|
|
|
// Clean the text to include only alphanumeric characters, spaces, and key punctuation
|
|
const cleanText = text.replace(/[^a-zA-Z0-9\s.,!?]/g, '');
|
|
|
|
try {
|
|
// Check if the audio is already cached
|
|
if (audioCache[cacheKey]) {
|
|
const audio = audioCache[cacheKey];
|
|
toggleAudioPlayback(audio, playButton);
|
|
return;
|
|
}
|
|
|
|
// Set button to processing state
|
|
playButton.textContent = "Processing...";
|
|
playButton.disabled = true;
|
|
|
|
const response = await fetch(TTS_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${API_KEY}`
|
|
},
|
|
body: JSON.stringify({
|
|
model: model,
|
|
voice: voice,
|
|
input: cleanText // Use the cleaned text
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const audioBlob = await response.blob();
|
|
const audioUrl = URL.createObjectURL(audioBlob);
|
|
const audio = new Audio(audioUrl);
|
|
audio.playbackRate = 0.9;
|
|
|
|
// Cache the audio only after it is successfully created
|
|
audioCache[cacheKey] = audio;
|
|
|
|
// Enable button and change text to "Pause"
|
|
playButton.disabled = false;
|
|
toggleAudioPlayback(audio, playButton);
|
|
} catch (error) {
|
|
console.error('Error in TTS:', error);
|
|
playButton.textContent = "Play"; // Reset button text on error
|
|
playButton.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Function to read text using TTS (for queued auto-play)
|
|
async function speakTextQueued(text, playButton, messageId) {
|
|
return new Promise((resolve, reject) => {
|
|
const voiceSelectValue = document.getElementById("voice-select").value;
|
|
|
|
// Parse model and voice from the dropdown value (format: "model:voice")
|
|
const [model, voice] = voiceSelectValue.includes(':') ? voiceSelectValue.split(':') : ['tts-1', voiceSelectValue];
|
|
const cacheKey = `${messageId}-${voiceSelectValue}`;
|
|
const cleanText = text.replace(/[^a-zA-Z0-9\s.,!?]/g, '');
|
|
|
|
const playAudio = (audio) => {
|
|
currentQueuedAudio = audio; // Track the currently playing queued audio
|
|
audio.onended = () => {
|
|
console.log("TTS finished for:", messageId);
|
|
currentQueuedAudio = null; // Clear when finished
|
|
resolve();
|
|
};
|
|
audio.onerror = () => {
|
|
console.error("TTS audio error for:", messageId);
|
|
currentQueuedAudio = null; // Clear on error
|
|
reject(new Error("Audio playback failed"));
|
|
};
|
|
audio.play().catch(reject);
|
|
};
|
|
|
|
// Check if audio is cached
|
|
if (audioCache[cacheKey]) {
|
|
playAudio(audioCache[cacheKey]);
|
|
return;
|
|
}
|
|
|
|
// Fetch new audio
|
|
fetch(TTS_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${API_KEY}`
|
|
},
|
|
body: JSON.stringify({
|
|
model: model,
|
|
voice: voice,
|
|
input: cleanText
|
|
})
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
return response.blob();
|
|
})
|
|
.then(audioBlob => {
|
|
const audioUrl = URL.createObjectURL(audioBlob);
|
|
const audio = new Audio(audioUrl);
|
|
audio.playbackRate = 0.9;
|
|
audioCache[cacheKey] = audio;
|
|
playAudio(audio);
|
|
})
|
|
.catch(reject);
|
|
});
|
|
}
|
|
|
|
// Function to add TTS to queue
|
|
function queueTTS(text, playButton, messageId) {
|
|
ttsQueue.push({ text, playButton, messageId });
|
|
console.log("Added to TTS queue:", messageId, "Queue length:", ttsQueue.length);
|
|
processNextTTS();
|
|
}
|
|
|
|
// Function to process the next TTS in queue
|
|
function processNextTTS() {
|
|
if (isPlayingTTS || ttsQueue.length === 0) {
|
|
return;
|
|
}
|
|
|
|
isPlayingTTS = true;
|
|
const { text, playButton, messageId } = ttsQueue.shift();
|
|
console.log("Processing TTS from queue:", messageId);
|
|
|
|
// Use non-blocking async processing
|
|
speakTextQueued(text, playButton, messageId)
|
|
.then(() => {
|
|
console.log("TTS completed successfully for:", messageId);
|
|
})
|
|
.catch((error) => {
|
|
console.error("TTS error:", error);
|
|
})
|
|
.finally(() => {
|
|
isPlayingTTS = false;
|
|
// Schedule next item with minimal delay to prevent blocking
|
|
setTimeout(processNextTTS, 10);
|
|
});
|
|
}
|
|
|
|
// Function to update auto-play TTS button display
|
|
function updateAutoPlayTTSDisplay() {
|
|
const autoPlayBtn = document.getElementById("auto-play-tts-btn");
|
|
const autoPlayBtnMobile = document.getElementById("auto-play-tts-btn-mobile");
|
|
|
|
if (autoPlayTTS) {
|
|
autoPlayBtn.textContent = "Auto-Play TTS: ON";
|
|
autoPlayBtn.style.backgroundColor = "#4CAF50";
|
|
autoPlayBtnMobile.textContent = "Auto-Play TTS: ON";
|
|
autoPlayBtnMobile.style.backgroundColor = "#4CAF50";
|
|
} else {
|
|
autoPlayBtn.textContent = "Auto-Play TTS: OFF";
|
|
autoPlayBtn.style.backgroundColor = "#f44336";
|
|
autoPlayBtnMobile.textContent = "Auto-Play TTS: OFF";
|
|
autoPlayBtnMobile.style.backgroundColor = "#f44336";
|
|
// Clear queue when turning off
|
|
ttsQueue = [];
|
|
isPlayingTTS = false;
|
|
}
|
|
}
|
|
|
|
// Function to toggle auto-play TTS
|
|
function toggleAutoPlayTTS() {
|
|
autoPlayTTS = !autoPlayTTS;
|
|
console.log("Auto-play TTS toggled to:", autoPlayTTS);
|
|
|
|
// Save to localStorage
|
|
localStorage.setItem('autoPlayTTS', autoPlayTTS.toString());
|
|
|
|
// If turning off, clear the queue and stop current audio
|
|
if (!autoPlayTTS) {
|
|
console.log("Clearing TTS queue, had", ttsQueue.length, "items");
|
|
ttsQueue = [];
|
|
isPlayingTTS = false;
|
|
|
|
// Stop any currently playing audio
|
|
if (currentAudio) {
|
|
currentAudio.pause();
|
|
currentAudio.currentTime = 0;
|
|
if (currentAudio.playButton) {
|
|
currentAudio.playButton.textContent = "Play";
|
|
}
|
|
currentAudio = null;
|
|
}
|
|
|
|
// Stop any currently playing queued TTS audio
|
|
if (currentQueuedAudio) {
|
|
currentQueuedAudio.pause();
|
|
currentQueuedAudio.currentTime = 0;
|
|
currentQueuedAudio = null;
|
|
}
|
|
}
|
|
|
|
updateAutoPlayTTSDisplay();
|
|
}
|
|
|
|
// Function to toggle audio playback
|
|
function toggleAudioPlayback(audio, playButton) {
|
|
if (currentAudio && currentAudio !== audio) {
|
|
currentAudio.pause();
|
|
currentAudio.currentTime = 0;
|
|
currentAudio.playButton.textContent = "Play";
|
|
}
|
|
|
|
if (audio.paused) {
|
|
audio.play();
|
|
playButton.textContent = "Pause";
|
|
} else {
|
|
audio.pause();
|
|
playButton.textContent = "Play";
|
|
}
|
|
|
|
currentAudio = audio;
|
|
currentAudio.playButton = playButton;
|
|
|
|
audio.onended = () => {
|
|
playButton.textContent = "Play";
|
|
};
|
|
}
|
|
|
|
// Socket event for receiving a new message
|
|
socket.on("chat_message", (data) => {
|
|
const messageWrapper = document.createElement("div");
|
|
messageWrapper.className = "message-wrapper";
|
|
messageWrapper.id = "message-" + data.id;
|
|
|
|
const newMessage = document.createElement("div");
|
|
newMessage.className = "message-content";
|
|
|
|
// Check if the username is present and prepend it to the message content
|
|
const messageContent = data.username ? `**${data.username}:**\n\n${data.content}` : data.content;
|
|
|
|
// Check if the message starts with a base64 image tag
|
|
if (data.content.startsWith('<img src="data:image/jpeg;base64')) {
|
|
// Directly assign the message as innerHTML if it starts with a base64 image
|
|
newMessage.innerHTML = data.content;
|
|
} else {
|
|
// Otherwise, sanitize and process the message with marked
|
|
newMessage.innerHTML = DOMPurify.sanitize(marked.marked(messageContent), dompurify_config);
|
|
}
|
|
|
|
// Check if the message has an id which means we can delete it.
|
|
if (data.id) {
|
|
newMessage.dataset.rawMarkdown = data.content;
|
|
|
|
// Create a container for the buttons
|
|
const buttonContainer = document.createElement("div");
|
|
buttonContainer.className = "button-container";
|
|
|
|
// Create the delete button
|
|
const deleteButton = document.createElement("button");
|
|
deleteButton.innerHTML = "x";
|
|
deleteButton.onclick = () => deleteMessage(data.id, room_name);
|
|
buttonContainer.appendChild(deleteButton);
|
|
|
|
// Create the edit button
|
|
const editButton = document.createElement("button");
|
|
editButton.textContent = "Edit";
|
|
editButton.className = "edit-button";
|
|
editButton.onclick = () => editMessage(data.id, newMessage, data.content);
|
|
buttonContainer.appendChild(editButton);
|
|
|
|
// Create the copy button
|
|
const copyButton = document.createElement("button");
|
|
copyButton.textContent = "Copy";
|
|
copyButton.className = "copy-button";
|
|
copyButton.onclick = () => copyMessageContent(data.content);
|
|
buttonContainer.appendChild(copyButton);
|
|
|
|
// Create the play button for TTS
|
|
const playButton = document.createElement("button");
|
|
playButton.textContent = "Play";
|
|
playButton.onclick = () => speakText(data.content, playButton, data.id);
|
|
buttonContainer.appendChild(playButton);
|
|
|
|
messageWrapper.appendChild(buttonContainer);
|
|
}
|
|
|
|
messageWrapper.appendChild(newMessage);
|
|
|
|
document.getElementById("chat").appendChild(messageWrapper);
|
|
|
|
// Apply syntax highlighting to code blocks within the message
|
|
newMessage.querySelectorAll("pre code").forEach((block) => {
|
|
addCopyButtonToCodeBlock(block);
|
|
truncateCodeBlock(block);
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
});
|
|
|
|
// Scroll to the bottom of the chat container to show the new message.
|
|
if (data.id) {
|
|
document.getElementById("chat").scrollTop = document.getElementById("chat").scrollHeight;
|
|
|
|
// Auto-play TTS if enabled and message has content - AFTER buttons are created
|
|
if (autoPlayTTS && data.content && data.content.trim() !== "") {
|
|
setTimeout(() => {
|
|
// Find the play button after buttons have been created
|
|
const buttons = messageWrapper.querySelectorAll("button");
|
|
const playButton = Array.from(buttons).find(btn => btn.textContent === "Play");
|
|
console.log("Auto-play TTS: enabled=", autoPlayTTS, "content=", data.content, "playButton=", playButton);
|
|
if (playButton) {
|
|
console.log("Queueing TTS for message:", data.id);
|
|
queueTTS(data.content, playButton, data.id);
|
|
}
|
|
}, 10); // Very short delay to let buttons be created
|
|
}
|
|
}
|
|
});
|
|
|
|
// Socket event for receiving previous messages
|
|
socket.on("previous_messages", (data) => {
|
|
|
|
if (document.getElementById("message-" + data.id)) {
|
|
// If it exists, skip appending it
|
|
return;
|
|
}
|
|
|
|
const messageWrapper = document.createElement("div");
|
|
messageWrapper.className = "message-wrapper";
|
|
messageWrapper.id = "message-" + data.id;
|
|
|
|
const newMessage = document.createElement("div");
|
|
newMessage.className = "message-content";
|
|
|
|
// Check if the message contains a base64 image
|
|
if (data.content.startsWith('<img src="data:image/jpeg;base64')) {
|
|
// Directly assign the message as innerHTML if it's a base64 image
|
|
newMessage.innerHTML = data.content;
|
|
} else {
|
|
// Otherwise, sanitize and process the message with marked
|
|
newMessage.innerHTML = DOMPurify.sanitize(marked.marked(`**${data.username}:**\n\n${data.content}`), dompurify_config);
|
|
}
|
|
|
|
newMessage.dataset.rawMarkdown = data.content;
|
|
|
|
// Create a container for the buttons
|
|
const buttonContainer = document.createElement("div");
|
|
buttonContainer.className = "button-container";
|
|
|
|
// Create the delete button
|
|
const deleteButton = document.createElement("button");
|
|
deleteButton.innerHTML = "x";
|
|
deleteButton.onclick = () => deleteMessage(data.id, room_name);
|
|
buttonContainer.appendChild(deleteButton);
|
|
|
|
// Create the edit button
|
|
const editButton = document.createElement("button");
|
|
editButton.textContent = "Edit";
|
|
editButton.className = "edit-button";
|
|
editButton.onclick = () => editMessage(data.id, newMessage);
|
|
buttonContainer.appendChild(editButton);
|
|
|
|
// Create the copy button
|
|
const copyButton = document.createElement("button");
|
|
copyButton.textContent = "Copy";
|
|
copyButton.className = "copy-button";
|
|
copyButton.onclick = () => copyMessageContent(data.content);
|
|
buttonContainer.appendChild(copyButton);
|
|
|
|
// Create the play button for TTS
|
|
const playButton = document.createElement("button");
|
|
playButton.textContent = "Play";
|
|
playButton.onclick = () => speakText(data.content, playButton, data.id);
|
|
buttonContainer.appendChild(playButton);
|
|
|
|
messageWrapper.appendChild(buttonContainer);
|
|
messageWrapper.appendChild(newMessage);
|
|
|
|
document.getElementById("chat").appendChild(messageWrapper);
|
|
|
|
// Apply syntax highlighting to code blocks within the message
|
|
newMessage.querySelectorAll("pre code").forEach((block) => {
|
|
addCopyButtonToCodeBlock(block);
|
|
truncateCodeBlock(block);
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
});
|
|
|
|
// Scroll to the bottom of the chat container
|
|
document.getElementById("chat").scrollTop = document.getElementById("chat").scrollHeight;
|
|
|
|
});
|
|
|
|
// Socket event for deleting a processing message
|
|
socket.on("delete_processing_message", (msg_id) => {
|
|
const tempMessages = document.querySelectorAll("#message-null");
|
|
tempMessages.forEach((tempMessage) => {
|
|
tempMessage.remove();
|
|
});
|
|
// Clear the message buffer and header for the corresponding message ID
|
|
delete messageBuffers[msg_id];
|
|
delete messageHeaders[msg_id];
|
|
});
|
|
|
|
// A dictionary to hold buffers for each message ID
|
|
const messageBuffers = {};
|
|
// A dictionary to track message headers (username/model) for each message ID
|
|
const messageHeaders = {};
|
|
|
|
// Socket event for receiving chunks of a message
|
|
socket.on("message_chunk", (data) => {
|
|
const wrapperId = "message-" + data.id;
|
|
let messageWrapper = document.getElementById(wrapperId);
|
|
let targetMessageElement;
|
|
|
|
// If the message wrapper doesn't exist, create it
|
|
if (!messageWrapper) {
|
|
messageWrapper = document.createElement("div");
|
|
messageWrapper.className = "message-wrapper";
|
|
messageWrapper.id = wrapperId;
|
|
document.getElementById("chat").appendChild(messageWrapper);
|
|
}
|
|
|
|
// If the message-content div doesn't exist, create it
|
|
if (!messageWrapper.querySelector(".message-content")) {
|
|
// Create a message body wrapper to contain both header and content
|
|
const messageBodyWrapper = document.createElement("div");
|
|
messageBodyWrapper.className = "message-body";
|
|
messageWrapper.appendChild(messageBodyWrapper);
|
|
|
|
// Create header element for username/model
|
|
const headerElement = document.createElement("div");
|
|
headerElement.className = "message-header";
|
|
messageBodyWrapper.appendChild(headerElement);
|
|
|
|
// Create content element for actual message content
|
|
targetMessageElement = document.createElement("div");
|
|
targetMessageElement.className = "message-content";
|
|
messageBodyWrapper.appendChild(targetMessageElement);
|
|
} else {
|
|
targetMessageElement = messageWrapper.querySelector(".message-content");
|
|
}
|
|
|
|
// If the message buffer for this ID doesn't exist, create it
|
|
if (!messageBuffers[data.id]) {
|
|
messageBuffers[data.id] = "";
|
|
}
|
|
|
|
// Store header info on first chunk and update header element
|
|
if (data.is_first_chunk && data.username && data.model_name) {
|
|
messageHeaders[data.id] = {
|
|
username: data.username,
|
|
model_name: data.model_name
|
|
};
|
|
|
|
// Update header element
|
|
const headerElement = messageWrapper.querySelector(".message-header");
|
|
if (headerElement) {
|
|
const headerContent = `**${data.username} (${data.model_name}):**`;
|
|
headerElement.innerHTML = DOMPurify.sanitize(marked.marked(headerContent), dompurify_config);
|
|
}
|
|
}
|
|
|
|
// Append the chunk to the buffer
|
|
messageBuffers[data.id] += data.content;
|
|
|
|
// Process just the content and set it in the content element
|
|
const sanitizedContent = DOMPurify.sanitize(marked.marked(messageBuffers[data.id]), dompurify_config);
|
|
targetMessageElement.innerHTML = sanitizedContent;
|
|
|
|
// Store the raw markdown in a data attribute for later use in editing (without header for clean editing)
|
|
targetMessageElement.dataset.rawMarkdown = messageBuffers[data.id];
|
|
|
|
// Apply syntax highlighting to code blocks within the content
|
|
targetMessageElement.querySelectorAll("pre code").forEach((block) => {
|
|
addCopyButtonToCodeBlock(block);
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
});
|
|
|
|
// Scroll to the bottom of the chat container, but skip it if the user has scrolled up.
|
|
if (!userHasScrolledUp) {
|
|
document.getElementById("chat").scrollTop = document.getElementById("chat").scrollHeight;
|
|
}
|
|
|
|
// Check if the message is complete and add buttons if they haven't been added
|
|
if (data.is_complete && !messageWrapper.querySelector(".button-container")) {
|
|
// Create a container for the buttons
|
|
const buttonContainer = document.createElement("div");
|
|
buttonContainer.className = "button-container";
|
|
|
|
// Create the delete button
|
|
const deleteButton = document.createElement("button");
|
|
deleteButton.innerHTML = "x";
|
|
deleteButton.onclick = () => deleteMessage(data.id, room_name);
|
|
buttonContainer.appendChild(deleteButton);
|
|
|
|
// Create the edit button
|
|
const editButton = document.createElement("button");
|
|
editButton.textContent = "Edit";
|
|
editButton.className = "edit-button";
|
|
editButton.onclick = () => editMessage(data.id, targetMessageElement);
|
|
buttonContainer.appendChild(editButton);
|
|
|
|
// Create the copy button
|
|
const copyButton = document.createElement("button");
|
|
copyButton.textContent = "Copy";
|
|
copyButton.className = "copy-button";
|
|
copyButton.onclick = () => copyMessageContent(messageBuffers[data.id]);
|
|
buttonContainer.appendChild(copyButton);
|
|
|
|
// Create the play button for TTS
|
|
const playButton = document.createElement("button");
|
|
playButton.textContent = "Play";
|
|
playButton.onclick = () => {
|
|
// Use content from the content element (clean text without header)
|
|
const cleanText = targetMessageElement.textContent || targetMessageElement.innerText || "";
|
|
speakText(cleanText, playButton, data.id);
|
|
};
|
|
buttonContainer.appendChild(playButton);
|
|
|
|
// Insert the button container at the beginning of the message wrapper (before header and content)
|
|
messageWrapper.insertBefore(buttonContainer, messageWrapper.firstChild);
|
|
|
|
// Auto-play TTS if enabled and message is complete (only when streaming finishes)
|
|
console.log("DEBUG: Streaming complete check:", {
|
|
autoPlayTTS: autoPlayTTS,
|
|
is_complete: data.is_complete,
|
|
hasBuffer: !!messageBuffers[data.id],
|
|
bufferContent: messageBuffers[data.id] ? messageBuffers[data.id].substring(0, 50) + "..." : "none",
|
|
messageId: data.id
|
|
});
|
|
if (autoPlayTTS && data.is_complete && messageBuffers[data.id] && messageBuffers[data.id].trim() !== "") {
|
|
const playButton = Array.from(buttonContainer.querySelectorAll("button")).find(btn => btn.textContent === "Play");
|
|
console.log("DEBUG: Found play button for streaming TTS:", playButton ? "YES" : "NO", playButton?.textContent);
|
|
if (playButton) {
|
|
setTimeout(() => {
|
|
// Use content from the content element (clean text without header)
|
|
const cleanText = targetMessageElement.textContent || targetMessageElement.innerText || "";
|
|
console.log("DEBUG: Queueing streaming TTS:", data.id, cleanText.substring(0, 50) + "...");
|
|
queueTTS(cleanText, playButton, data.id);
|
|
}, 50); // Small delay to let the message render
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
// Socket event for when a message is deleted
|
|
socket.on("message_deleted", (data) => {
|
|
const messageElement = document.getElementById("message-" + data.message_id);
|
|
if (messageElement) {
|
|
messageElement.remove();
|
|
}
|
|
});
|
|
|
|
// Socket event for when a message is updated
|
|
socket.on("message_updated", (data) => {
|
|
// Find the existing message wrapper by ID
|
|
const messageWrapper = document.getElementById("message-" + data.message_id);
|
|
|
|
if (messageWrapper) {
|
|
// Find the specific element that contains the message content
|
|
const messageContentContainer = messageWrapper.querySelector(".message-content");
|
|
|
|
// Update the message content
|
|
if (data.content.startsWith('<img src="data:image/jpeg;base64')) {
|
|
// If it's a base64 image, set it directly
|
|
messageContentContainer.innerHTML = data.content;
|
|
} else {
|
|
// If it's not an image, sanitize and process the message with marked
|
|
messageContentContainer.innerHTML = DOMPurify.sanitize(marked.marked(data.content), dompurify_config);
|
|
}
|
|
|
|
// Update the raw markdown stored in the data attribute
|
|
messageContentContainer.dataset.rawMarkdown = data.content;
|
|
|
|
// Apply syntax highlighting and other functionalities to code blocks within the message
|
|
messageContentContainer.querySelectorAll("pre code").forEach((block) => {
|
|
addCopyButtonToCodeBlock(block);
|
|
truncateCodeBlock(block);
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// Function to enter edit mode
|
|
function editMessage(messageId, messageContentContainer) {
|
|
// Store the current HTML in a data attribute
|
|
messageContentContainer.dataset.originalHtml = messageContentContainer.innerHTML;
|
|
const rawMarkdown = messageContentContainer.dataset.rawMarkdown;
|
|
|
|
// Create a textarea for editing
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = rawMarkdown;
|
|
textarea.rows = 16;
|
|
textarea.className = "message-edit";
|
|
|
|
// Replace the message content with the textarea
|
|
messageContentContainer.innerHTML = '';
|
|
messageContentContainer.appendChild(textarea);
|
|
|
|
// Find the message wrapper to access the edit and save buttons
|
|
const messageWrapper = messageContentContainer.closest('.message-wrapper');
|
|
|
|
// Create a save button with the 'save-button' class
|
|
const saveButton = document.createElement("button");
|
|
saveButton.textContent = "Save";
|
|
saveButton.className = "save-button"; // Add the class here
|
|
saveButton.onclick = () => saveEditedMessage(messageId, textarea, messageContentContainer);
|
|
|
|
// Change the edit button to a cancel button
|
|
const editButton = messageWrapper.querySelector(".edit-button");
|
|
editButton.textContent = "Cancel";
|
|
editButton.onclick = () => cancelEdit(messageId, messageContentContainer);
|
|
|
|
// Append the save button next to the cancel button
|
|
editButton.after(saveButton);
|
|
}
|
|
|
|
// Function to save the edited message
|
|
function saveEditedMessage(messageId, textarea, messageContentContainer) {
|
|
// Get the updated markdown from the textarea
|
|
const updatedMarkdown = textarea.value;
|
|
|
|
// Emit the update_message event to the server
|
|
socket.emit("update_message", {
|
|
"message_id": messageId,
|
|
"content": updatedMarkdown,
|
|
"room_name": room_name
|
|
});
|
|
|
|
// Clear the cached audio for this message to recompute TTS
|
|
if (audioCache[messageId]) {
|
|
delete audioCache[messageId];
|
|
}
|
|
|
|
// Reset the edit button to its original state
|
|
const messageWrapper = messageContentContainer.closest('.message-wrapper');
|
|
const editButton = messageWrapper.querySelector(".edit-button");
|
|
editButton.textContent = 'Edit';
|
|
editButton.onclick = () => editMessage(messageId, messageContentContainer, updatedMarkdown);
|
|
|
|
// Reset the play button to its initial state
|
|
const playButton = Array.from(messageWrapper.querySelectorAll("button")).find(btn => btn.textContent === 'Pause' || btn.textContent === 'Play');
|
|
if (playButton) {
|
|
playButton.textContent = 'Play';
|
|
playButton.onclick = () => speakText(updatedMarkdown, playButton, messageId); // Ensure it uses the updated content
|
|
}
|
|
|
|
// Remove the save button using the 'save-button' class
|
|
const saveButton = messageWrapper.querySelector(".save-button");
|
|
if (saveButton) {
|
|
saveButton.remove();
|
|
}
|
|
}
|
|
|
|
// Function to cancel the edit and revert changes
|
|
function cancelEdit(messageId, messageContentContainer) {
|
|
// Restore the original HTML of the message content from the data attribute
|
|
messageContentContainer.innerHTML = messageContentContainer.dataset.originalHtml;
|
|
|
|
// Reset the edit button to its original state
|
|
const messageWrapper = messageContentContainer.closest('.message-wrapper');
|
|
const editButton = messageWrapper.querySelector(".edit-button");
|
|
editButton.textContent = 'Edit';
|
|
editButton.onclick = () => editMessage(messageId, messageContentContainer, messageContentContainer.dataset.rawMarkdown);
|
|
|
|
// Remove the save button using the 'save-button' class
|
|
const saveButton = messageWrapper.querySelector(".save-button");
|
|
if (saveButton) {
|
|
saveButton.remove();
|
|
}
|
|
}
|
|
|
|
|
|
function truncateCodeBlock(block, maxLines = 100) {
|
|
// Split the content by new lines and check if it exceeds the maxLines
|
|
const lines = block.textContent.split('\n');
|
|
if (lines.length > maxLines) {
|
|
// Store the full content in a data attribute
|
|
block.dataset.fullContent = block.textContent;
|
|
|
|
// Truncate the displayed content
|
|
const truncatedText = lines.slice(0, maxLines).join('\n') + '\n...';
|
|
block.textContent = truncatedText;
|
|
|
|
// Create a container for bottom buttons
|
|
const bottomButtonContainer = document.createElement('div');
|
|
bottomButtonContainer.classList.add('code-block-bottom-buttons');
|
|
bottomButtonContainer.style.display = 'flex';
|
|
bottomButtonContainer.style.gap = '8px';
|
|
bottomButtonContainer.style.marginTop = '8px';
|
|
|
|
// Create the expand button
|
|
const expandButton = document.createElement('button');
|
|
expandButton.textContent = 'Show More';
|
|
expandButton.classList.add('show-more-button');
|
|
|
|
// Create bottom copy button
|
|
const bottomCopyButton = document.createElement('button');
|
|
bottomCopyButton.textContent = 'Copy';
|
|
bottomCopyButton.classList.add('copy-button');
|
|
bottomCopyButton.onclick = function() {
|
|
const contentToCopy = block.dataset.fullContent || block.textContent;
|
|
navigator.clipboard.writeText(contentToCopy).then(() => {
|
|
bottomCopyButton.textContent = 'Copied!';
|
|
setTimeout(() => {
|
|
bottomCopyButton.textContent = 'Copy';
|
|
}, 2000);
|
|
}).catch(err => {
|
|
console.error('Error copying text: ', err);
|
|
});
|
|
};
|
|
|
|
// Create bottom run button
|
|
const bottomPlayButton = document.createElement('button');
|
|
bottomPlayButton.textContent = '▶ Run';
|
|
bottomPlayButton.classList.add('play-button');
|
|
bottomPlayButton.onclick = function() {
|
|
const contentToRun = block.dataset.fullContent || block.textContent;
|
|
executeCodeBlock(contentToRun, block, bottomPlayButton);
|
|
};
|
|
|
|
expandButton.onclick = function() {
|
|
// Restore the full content from the data attribute
|
|
block.textContent = block.dataset.fullContent;
|
|
// Reapply syntax highlighting
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
// Change the button text to "Show Less"
|
|
expandButton.textContent = 'Show Less';
|
|
// Change the onclick function to truncate the block again
|
|
expandButton.onclick = function() {
|
|
block.textContent = truncatedText;
|
|
// Reapply syntax highlighting
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
// Change the button text back to "Show More"
|
|
expandButton.textContent = 'Show More';
|
|
// Set the onclick function back to the original expand function
|
|
expandButton.onclick = originalExpandFunction;
|
|
};
|
|
};
|
|
|
|
// Keep a reference to the original expand function
|
|
const originalExpandFunction = expandButton.onclick;
|
|
|
|
// Add all buttons to container
|
|
bottomButtonContainer.appendChild(expandButton);
|
|
bottomButtonContainer.appendChild(bottomCopyButton);
|
|
bottomButtonContainer.appendChild(bottomPlayButton);
|
|
|
|
// Insert the button container after the code block
|
|
block.parentNode.insertBefore(bottomButtonContainer, block.nextSibling);
|
|
}
|
|
}
|
|
|
|
|
|
// Modify the addCopyButtonToCodeBlock function to use the full content
|
|
function addCopyButtonToCodeBlock(block) {
|
|
// Check if the full content is stored in a data attribute, otherwise use textContent
|
|
const contentToCopy = block.dataset.fullContent || block.textContent;
|
|
|
|
// Create a container for the buttons
|
|
const buttonContainer = document.createElement('div');
|
|
buttonContainer.classList.add('code-block-button-container');
|
|
buttonContainer.style.display = 'flex';
|
|
buttonContainer.style.gap = '8px';
|
|
buttonContainer.style.marginBottom = '8px';
|
|
|
|
// Create a button to copy the code block's content
|
|
const copyButton = document.createElement('button');
|
|
copyButton.textContent = 'Copy';
|
|
copyButton.classList.add('copy-button'); // Add a class for styling if needed
|
|
copyButton.onclick = function() {
|
|
// Copy the content to the clipboard
|
|
navigator.clipboard.writeText(contentToCopy).then(() => {
|
|
// Optionally, indicate that the text was copied
|
|
copyButton.textContent = 'Copied!';
|
|
setTimeout(() => {
|
|
copyButton.textContent = 'Copy';
|
|
}, 2000); // Reset button text after 2 seconds
|
|
}).catch(err => {
|
|
console.error('Error copying text: ', err);
|
|
});
|
|
};
|
|
|
|
// Create a button to execute the code block's content
|
|
const playButton = document.createElement('button');
|
|
playButton.textContent = '▶ Run';
|
|
playButton.classList.add('play-button');
|
|
playButton.onclick = function() {
|
|
executeCodeBlock(contentToCopy, block, playButton);
|
|
};
|
|
|
|
// Add buttons to container
|
|
buttonContainer.appendChild(copyButton);
|
|
buttonContainer.appendChild(playButton);
|
|
|
|
// Insert the button container before the code block
|
|
block.parentNode.insertBefore(buttonContainer, block);
|
|
}
|
|
|
|
// Function to execute code block content
|
|
async function executeCodeBlock(code, blockElement, playButton) {
|
|
// Update button state
|
|
playButton.textContent = 'Running...';
|
|
playButton.disabled = true;
|
|
|
|
// Check if we already have a results container
|
|
let resultsContainer = blockElement.parentNode.querySelector('.code-execution-results');
|
|
if (!resultsContainer) {
|
|
// Create results container
|
|
resultsContainer = document.createElement('div');
|
|
resultsContainer.classList.add('code-execution-results');
|
|
resultsContainer.style.marginTop = '10px';
|
|
resultsContainer.style.padding = '10px';
|
|
resultsContainer.style.backgroundColor = 'var(--bg-code)';
|
|
resultsContainer.style.borderRadius = '5px';
|
|
resultsContainer.style.fontFamily = 'monospace';
|
|
resultsContainer.style.fontSize = '14px';
|
|
resultsContainer.style.whiteSpace = 'pre-wrap';
|
|
resultsContainer.style.wordWrap = 'break-word';
|
|
|
|
// Insert after the code block
|
|
blockElement.parentNode.insertBefore(resultsContainer, blockElement.nextSibling);
|
|
}
|
|
|
|
// Clear previous results
|
|
resultsContainer.innerHTML = '<div style="color: var(--text-muted);">Executing code...</div>';
|
|
|
|
try {
|
|
// Try to detect language from the code block's class
|
|
let language = null;
|
|
const classes = blockElement.className.split(' ');
|
|
for (const cls of classes) {
|
|
if (cls.startsWith('language-')) {
|
|
language = cls.replace('language-', '');
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no language specified (no class on code block), default to Python
|
|
if (!language) {
|
|
language = 'python';
|
|
}
|
|
|
|
// Use /execute/async endpoint with polling
|
|
const asyncResponse = await fetch(`${CODE_EXEC_URL}/execute/async`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
language: language,
|
|
code: code
|
|
})
|
|
});
|
|
|
|
if (!asyncResponse.ok) {
|
|
throw new Error(`HTTP error! status: ${asyncResponse.status}`);
|
|
}
|
|
|
|
const { job_id } = await asyncResponse.json();
|
|
|
|
// Poll for results: 300ms, 750ms, 1450ms, 2350ms, 3000ms, 4600ms, 6600ms+
|
|
const delays = [300, 450, 700, 900, 650, 1600, 2000];
|
|
let pollCount = 0;
|
|
let cancelButtonShown = false;
|
|
|
|
// Create cancel button (hidden initially)
|
|
let cancelButton = resultsContainer.querySelector('.cancel-execution-btn');
|
|
if (!cancelButton) {
|
|
cancelButton = document.createElement('button');
|
|
cancelButton.textContent = 'Cancel';
|
|
cancelButton.classList.add('cancel-execution-btn');
|
|
cancelButton.style.display = 'none';
|
|
cancelButton.style.marginTop = '8px';
|
|
cancelButton.style.padding = '4px 8px';
|
|
cancelButton.style.backgroundColor = 'var(--button-danger)';
|
|
cancelButton.style.color = 'white';
|
|
cancelButton.style.border = 'none';
|
|
cancelButton.style.borderRadius = '3px';
|
|
cancelButton.style.cursor = 'pointer';
|
|
cancelButton.onclick = async () => {
|
|
try {
|
|
await fetch(`${CODE_EXEC_URL}/jobs/${job_id}`, { method: 'DELETE' });
|
|
cancelButton.disabled = true;
|
|
cancelButton.textContent = 'Cancelling...';
|
|
} catch (error) {
|
|
console.error('Error cancelling job:', error);
|
|
}
|
|
};
|
|
resultsContainer.appendChild(cancelButton);
|
|
}
|
|
|
|
while (true) {
|
|
await sleep(delays[Math.min(pollCount, delays.length - 1)]);
|
|
pollCount++;
|
|
|
|
const jobResponse = await fetch(`${CODE_EXEC_URL}/jobs/${job_id}`);
|
|
if (!jobResponse.ok) {
|
|
throw new Error(`Failed to fetch job status: ${jobResponse.status}`);
|
|
}
|
|
|
|
const job = await jobResponse.json();
|
|
|
|
if (job.status !== 'pending' && job.status !== 'running') {
|
|
// Job finished - hide cancel button
|
|
if (cancelButton) {
|
|
cancelButton.style.display = 'none';
|
|
}
|
|
|
|
if (job.status === 'completed') {
|
|
const result = job.result;
|
|
displayExecutionResults(result, resultsContainer, language);
|
|
break;
|
|
}
|
|
|
|
// timeout or cancelled
|
|
const errorMsg = job.result?.error || 'Execution failed';
|
|
const partialOutput = job.result?.partial_output;
|
|
|
|
let outputHtml = `<div style="color: var(--text-error); font-weight: bold;">${escapeHtml(errorMsg)}</div>`;
|
|
if (partialOutput) {
|
|
outputHtml += '<div style="color: var(--text-muted); margin-top: 8px;">Partial output before timeout:</div>';
|
|
outputHtml += `<div style="color: var(--text-primary); margin-left: 10px;">${escapeHtml(partialOutput)}</div>`;
|
|
}
|
|
resultsContainer.innerHTML = outputHtml;
|
|
break;
|
|
}
|
|
|
|
// Show cancel button after poll #5 (3000ms) if still running
|
|
if (!cancelButtonShown && pollCount === 5) {
|
|
cancelButtonShown = true;
|
|
cancelButton.style.display = 'inline-block';
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error executing code:', error);
|
|
resultsContainer.innerHTML = `<div style="color: var(--text-error);">Error: ${escapeHtml(error.message)}</div>`;
|
|
} finally {
|
|
// Reset button state
|
|
playButton.textContent = '▶ Run';
|
|
playButton.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Helper function to sleep
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// Helper function to display execution results
|
|
function displayExecutionResults(result, resultsContainer, language) {
|
|
// Format and display results
|
|
let outputHtml = '';
|
|
|
|
// Handle nested response structure - check if stdout is an object with nested data
|
|
let actualStdout = result.stdout;
|
|
let actualStderr = result.stderr;
|
|
|
|
// If stdout is an object (nested response), extract the actual stdout/stderr
|
|
if (typeof result.stdout === 'object' && result.stdout !== null) {
|
|
actualStdout = result.stdout.stdout || '';
|
|
actualStderr = result.stdout.stderr || '';
|
|
}
|
|
|
|
// Show language
|
|
if (language) {
|
|
outputHtml += `<div style="color: var(--text-info); margin-bottom: 8px;">Language: ${language}</div>`;
|
|
}
|
|
|
|
// Show stdout
|
|
if (actualStdout) {
|
|
outputHtml += '<div style="color: var(--text-success); font-weight: bold;">Output:</div>';
|
|
outputHtml += `<div style="color: var(--text-primary); margin-left: 10px;">${escapeHtml(actualStdout)}</div>`;
|
|
}
|
|
|
|
// Show stderr if present
|
|
if (actualStderr) {
|
|
outputHtml += '<div style="color: var(--text-error); font-weight: bold; margin-top: 8px;">Errors/Warnings:</div>';
|
|
outputHtml += `<div style="color: var(--text-error); margin-left: 10px;">${escapeHtml(actualStderr)}</div>`;
|
|
}
|
|
|
|
// If no output at all
|
|
if (!actualStdout && !actualStderr) {
|
|
outputHtml += '<div style="color: var(--text-muted);">(No output produced)</div>';
|
|
}
|
|
|
|
resultsContainer.innerHTML = outputHtml;
|
|
}
|
|
|
|
// Helper function to escape HTML
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function addLineNumbers(block) {
|
|
const lines = block.textContent.split('\n').length - 1;
|
|
const lineNumbersWrapper = document.createElement('div');
|
|
lineNumbersWrapper.className = 'line-numbers-rows';
|
|
for (let i = 0; i < lines; i++) {
|
|
lineNumbersWrapper.appendChild(document.createElement('span'));
|
|
}
|
|
block.appendChild(lineNumbersWrapper);
|
|
}
|
|
|
|
// Socket event for setting the chat background
|
|
socket.on("set_background", (data) => {
|
|
// Use setTimeout to ensure background updates don't get blocked by TTS
|
|
setTimeout(() => {
|
|
const chat = document.getElementById("chat");
|
|
chat.style.backgroundImage = `url('data:image/png;base64,${data.image_data}')`;
|
|
chat.style.backgroundRepeat = "no-repeat";
|
|
chat.style.backgroundPosition = "right center";
|
|
chat.style.backgroundSize = "auto"; // Ensures the image is not stretched
|
|
console.log("Background image updated");
|
|
}, 0);
|
|
});
|
|
|
|
// Activity management functions
|
|
function refreshActivityList() {
|
|
fetch('/api/activities')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const activitySelect = document.getElementById('activity-select');
|
|
const activitySelectMobile = document.getElementById('activity-select-mobile');
|
|
|
|
// Clear existing options except the first one for desktop
|
|
while (activitySelect.options.length > 1) {
|
|
activitySelect.remove(1);
|
|
}
|
|
|
|
// Clear existing options except the first one for mobile
|
|
while (activitySelectMobile.options.length > 1) {
|
|
activitySelectMobile.remove(1);
|
|
}
|
|
|
|
// Add activities to both dropdowns
|
|
data.activities.forEach(activity => {
|
|
const option = document.createElement('option');
|
|
option.value = activity;
|
|
option.textContent = activity;
|
|
activitySelect.appendChild(option);
|
|
|
|
const optionMobile = document.createElement('option');
|
|
optionMobile.value = activity;
|
|
optionMobile.textContent = activity;
|
|
activitySelectMobile.appendChild(optionMobile);
|
|
});
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching activities:', error);
|
|
alert('Failed to fetch activities');
|
|
});
|
|
}
|
|
|
|
function loadSelectedActivity() {
|
|
const activitySelect = document.getElementById('activity-select');
|
|
const selectedActivity = activitySelect.value;
|
|
|
|
if (!selectedActivity) {
|
|
alert('Please select an activity');
|
|
return;
|
|
}
|
|
|
|
// Send command to load activity
|
|
socket.emit("chat_message", {
|
|
"username": username,
|
|
"message": `/activity ${selectedActivity}`,
|
|
"model": document.getElementById("model-select").value,
|
|
"room_name": room_name
|
|
});
|
|
}
|
|
|
|
function loadSelectedActivityMobile() {
|
|
const activitySelectMobile = document.getElementById('activity-select-mobile');
|
|
const selectedActivity = activitySelectMobile.value;
|
|
|
|
if (!selectedActivity) {
|
|
alert('Please select an activity');
|
|
return;
|
|
}
|
|
|
|
// Send command to load activity
|
|
socket.emit("chat_message", {
|
|
"username": username,
|
|
"message": `/activity ${selectedActivity}`,
|
|
"model": document.getElementById("model-select").value,
|
|
"room_name": room_name
|
|
});
|
|
}
|
|
|
|
function cancelActivity() {
|
|
if (confirm('Are you sure you want to cancel the current activity?')) {
|
|
socket.emit("chat_message", {
|
|
"username": username,
|
|
"message": "/activity cancel",
|
|
"model": document.getElementById("model-select").value,
|
|
"room_name": room_name
|
|
});
|
|
}
|
|
}
|
|
|
|
// Socket event for activity status updates
|
|
socket.on("activity_status", (data) => {
|
|
const currentActivityInfo = document.getElementById('current-activity-info');
|
|
const activityListSection = document.getElementById('activity-list-section');
|
|
const currentActivityName = document.getElementById('current-activity-name');
|
|
const currentActivityInfoMobile = document.getElementById('current-activity-info-mobile');
|
|
const activityListSectionMobile = document.getElementById('activity-list-section-mobile');
|
|
const currentActivityNameMobile = document.getElementById('current-activity-name-mobile');
|
|
|
|
if (data.active) {
|
|
currentActivityInfo.style.display = 'block';
|
|
activityListSection.style.display = 'none';
|
|
currentActivityName.textContent = data.activity_name || 'Unknown';
|
|
currentActivityInfoMobile.style.display = 'block';
|
|
activityListSectionMobile.style.display = 'none';
|
|
currentActivityNameMobile.textContent = data.activity_name || 'Unknown';
|
|
} else {
|
|
currentActivityInfo.style.display = 'none';
|
|
activityListSection.style.display = 'block';
|
|
currentActivityInfoMobile.style.display = 'none';
|
|
activityListSectionMobile.style.display = 'block';
|
|
}
|
|
});
|
|
|
|
// Load activities on page load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
refreshActivityList();
|
|
|
|
// Request current activity status
|
|
socket.emit("get_activity_status", {"room_name": room_name});
|
|
});
|
|
|
|
</script>
|
|
{% endblock %}
|