Replace the non-streaming whole-clip timestamps fetch with an SSE consumer. Each event carries one sentence's mp3 + exact start/end ms; audio feeds an MSE SourceBuffer in sequence mode so playback starts after sentence 0, and the sentences array grows live to drive the glow (tick reads its length each frame). Falls back to a buffered single blob when the browser lacks mp3 MSE. Removes fetchTTSWithTimestamps. Best of both: instant start and exact sync.
2909 lines
118 KiB
HTML
2909 lines
118 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Chatroom{% endblock %}
|
|
|
|
{% block content %}
|
|
|
|
<div id="chat-container">
|
|
<!-- Search form -->
|
|
<div id="search-form">
|
|
<form action="/search" method="get">
|
|
<input type="text" id="search-keywords" name="keywords" placeholder="Search for keywords..." value="{{ keywords }}">
|
|
</form>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
<button type="button" id="send-button" onclick="sendMessage()">Send</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="utility-belt">
|
|
<!-- Room Actions -->
|
|
{% if current_room %}
|
|
<div class="room-actions-section" style="margin-bottom: 15px;">
|
|
<h3 style="margin-top: 0; margin-bottom: 10px;">Room Actions</h3>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
|
<button class="room-action-btn" onclick="forkRoom({{ current_room.id }})" style="width: 100%;">🍴 Fork</button>
|
|
{% if user and current_room.owner_id == user.id %}
|
|
<button class="room-action-btn delete-btn" onclick="deleteRoom({{ current_room.id }})" style="width: 100%;">🗑️ Delete</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<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="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">
|
|
<!-- Voices will be populated from API -->
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<button id="theme-toggle-btn" onclick="toggleTheme()" style="width: 100%; margin-top: 10px; background-color: #555; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer;">
|
|
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>
|
|
<button id="show-thinking-btn" onclick="toggleShowThinking()" style="width: 100%; margin-top: 10px; background-color: #4CAF50; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer;">
|
|
Thinking: ON
|
|
</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";
|
|
// Code execution API (proxied through backend to keep API key secure)
|
|
const room_name = "{{ room_name }}";
|
|
|
|
// TTS Streaming: Detect best audio format for this browser
|
|
// Chrome/Edge: mp3 works, MediaSource supports audio/mpeg
|
|
// Firefox: mp3 often broken on Linux; use webm+opus
|
|
function detectAudioFormat() {
|
|
const isFirefox = navigator.userAgent.includes('Firefox');
|
|
if (isFirefox) {
|
|
console.log("Firefox detected, using webm+opus format for TTS");
|
|
return { format: 'webm', mime: 'audio/webm', mseMime: 'audio/webm;codecs=opus' };
|
|
}
|
|
// Chromium-based browsers: mp3 works, MSE supports audio/mpeg
|
|
return { format: 'mp3', mime: 'audio/mpeg', mseMime: 'audio/mpeg' };
|
|
}
|
|
const AUDIO_FORMAT = detectAudioFormat();
|
|
|
|
// Get username from server (authenticated user's display name or None)
|
|
let username = {% if username %}"{{ username }}"{% else %}null{% endif %};
|
|
|
|
// If not authenticated, prompt for username
|
|
if (!username) {
|
|
username = prompt("Enter your username:", "guest") || "guest";
|
|
}
|
|
|
|
// 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 currentQueuedMessageId = null; // messageId of the queued audio currently playing (for cleanup on delete)
|
|
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;
|
|
|
|
// Thinking-mode state. Default ON. When OFF we send enable_thinking=false
|
|
// with each chat_message so the server asks the model (Qwen3-style) to skip
|
|
// chain-of-thought entirely — saving tokens, not just hiding output. As a
|
|
// fallback, any reasoning_content that still arrives is dropped client-side
|
|
// (see message_chunk handler). Toggle persists to localStorage.
|
|
let showThinking = localStorage.getItem('showThinking') !== 'false';
|
|
|
|
// Vision model state for auto alt-text
|
|
let visionAvailable = false;
|
|
let visionModel = null;
|
|
const imageDescriptionCache = new Map(); // Cache descriptions by image src hash
|
|
const imageBase64Cache = new Map(); // Cache fetched external images as base64
|
|
|
|
// CORS proxy for ethical external image fetching (respects robots.txt)
|
|
const CORS_PROXY_URL = 'https://cors-proxy.uncloseai.com/api/fetch';
|
|
|
|
// Check vision availability on load
|
|
async function initVisionCapability() {
|
|
try {
|
|
const response = await fetch('/vision');
|
|
const data = await response.json();
|
|
visionAvailable = data.available;
|
|
visionModel = data.default;
|
|
if (visionAvailable) {
|
|
console.log(`Vision available: ${visionModel}`);
|
|
setupImageHoverDescriptions();
|
|
}
|
|
} catch (e) {
|
|
console.warn('Vision check failed:', e);
|
|
}
|
|
}
|
|
|
|
// Generate a simple hash for caching
|
|
function hashString(str) {
|
|
let hash = 0;
|
|
for (let i = 0; i < Math.min(str.length, 1000); i++) {
|
|
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
|
hash |= 0;
|
|
}
|
|
return hash.toString();
|
|
}
|
|
|
|
// Check if URL is an external image URL
|
|
function isExternalImageUrl(src) {
|
|
if (!src) return false;
|
|
if (src.startsWith('data:')) return false;
|
|
try {
|
|
const url = new URL(src);
|
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Fetch external image via CORS proxy and convert to base64
|
|
async function fetchImageAsBase64(imageUrl) {
|
|
const cacheKey = hashString(imageUrl);
|
|
if (imageBase64Cache.has(cacheKey)) {
|
|
return imageBase64Cache.get(cacheKey);
|
|
}
|
|
|
|
try {
|
|
// Use CORS proxy for ethical fetching (handles robots.txt server-side)
|
|
const proxyUrl = `${CORS_PROXY_URL}?uri_target=${encodeURIComponent(imageUrl)}`;
|
|
const response = await fetch(proxyUrl);
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 403) {
|
|
console.warn(`Image blocked by robots.txt: ${imageUrl}`);
|
|
return null;
|
|
}
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
|
|
// Verify it's actually an image
|
|
if (!blob.type.startsWith('image/')) {
|
|
console.warn(`Not an image: ${imageUrl} (${blob.type})`);
|
|
return null;
|
|
}
|
|
|
|
// Convert to base64
|
|
return new Promise((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
const base64 = reader.result;
|
|
imageBase64Cache.set(cacheKey, base64);
|
|
resolve(base64);
|
|
};
|
|
reader.onerror = () => resolve(null);
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
} catch (e) {
|
|
console.warn(`Failed to fetch image: ${imageUrl}`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Get base64 image data (handles both base64 and external URLs)
|
|
async function getImageBase64(imgSrc) {
|
|
if (imgSrc.startsWith('data:image')) {
|
|
return imgSrc;
|
|
}
|
|
if (isExternalImageUrl(imgSrc)) {
|
|
return await fetchImageAsBase64(imgSrc);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Fetch description for an image
|
|
async function getImageDescription(imgSrc) {
|
|
const cacheKey = hashString(imgSrc);
|
|
if (imageDescriptionCache.has(cacheKey)) {
|
|
return imageDescriptionCache.get(cacheKey);
|
|
}
|
|
|
|
// Get base64 version of image (fetch if external)
|
|
const base64Image = await getImageBase64(imgSrc);
|
|
if (!base64Image) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/vision/describe', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ image: base64Image })
|
|
});
|
|
const data = await response.json();
|
|
if (data.description) {
|
|
imageDescriptionCache.set(cacheKey, data.description);
|
|
return data.description;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to get image description:', e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Setup hover handlers for images in chat
|
|
function setupImageHoverDescriptions() {
|
|
const messagesContainer = document.getElementById('messages');
|
|
if (!messagesContainer) return;
|
|
|
|
// Use mouseover (bubbles) for event delegation
|
|
messagesContainer.addEventListener('mouseover', async (e) => {
|
|
if (!visionAvailable) return;
|
|
|
|
// Check if target is an image
|
|
const img = e.target;
|
|
if (img.tagName !== 'IMG') return;
|
|
if (!img.src) return;
|
|
|
|
// Handle both base64 and external URLs
|
|
const isBase64 = img.src.startsWith('data:image');
|
|
const isExternal = isExternalImageUrl(img.src);
|
|
if (!isBase64 && !isExternal) return;
|
|
|
|
if (img.dataset.visionProcessing || img.dataset.visionDone) return;
|
|
|
|
// Mark as processing to avoid duplicate requests
|
|
img.dataset.visionProcessing = 'true';
|
|
img.style.cursor = 'wait';
|
|
img.title = isExternal ? 'Fetching image...' : 'Generating description...';
|
|
|
|
const description = await getImageDescription(img.src);
|
|
if (description) {
|
|
img.title = description;
|
|
img.alt = description;
|
|
} else {
|
|
img.title = '';
|
|
}
|
|
|
|
img.style.cursor = '';
|
|
delete img.dataset.visionProcessing;
|
|
img.dataset.visionDone = 'true';
|
|
});
|
|
}
|
|
|
|
// 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 dropdowns and save to localStorage
|
|
function syncDropdownsAndQueryString() {
|
|
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 currentModel = modelSelectDesktop.value;
|
|
const currentVoice = voiceSelectDesktop.value;
|
|
|
|
// 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);
|
|
}
|
|
|
|
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();
|
|
|
|
// Initialize show-thinking button state from localStorage
|
|
updateShowThinkingDisplay();
|
|
|
|
// Check for vision model availability (enables image hover descriptions)
|
|
initVisionCapability();
|
|
|
|
// 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);
|
|
}
|
|
while (modelSelectMobile.options.length > 1) {
|
|
modelSelectMobile.remove(1);
|
|
}
|
|
// Append new model options to both dropdowns
|
|
models.forEach(modelId => {
|
|
const option = document.createElement('option');
|
|
option.value = modelId;
|
|
option.textContent = modelId;
|
|
modelSelectDesktop.appendChild(option);
|
|
|
|
const optionMobile = document.createElement('option');
|
|
optionMobile.value = modelId;
|
|
optionMobile.textContent = modelId;
|
|
modelSelectMobile.appendChild(optionMobile);
|
|
});
|
|
// Restore from localStorage (check if value exists in options)
|
|
const storedModel = localStorage.getItem('selectedModel') || "None";
|
|
const validOptions = Array.from(modelSelectDesktop.options).map(o => o.value);
|
|
const modelToSelect = validOptions.includes(storedModel) ? storedModel : "None";
|
|
modelSelectDesktop.value = modelToSelect;
|
|
modelSelectMobile.value = modelToSelect;
|
|
console.log(`Model restored from localStorage: ${modelToSelect}`);
|
|
}
|
|
|
|
// 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 localStorage or first available option (not URL)
|
|
const initialVoice = localStorage.getItem('selectedVoice') || voiceSelectDesktop.options[0]?.value;
|
|
if (initialVoice) {
|
|
voiceSelectDesktop.value = initialVoice;
|
|
if (voiceSelectMobile) voiceSelectMobile.value = initialVoice;
|
|
console.log(`Voice restored from localStorage: ${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);
|
|
// Leave dropdown empty if API fails
|
|
voiceSelectDesktop.innerHTML = '';
|
|
if (voiceSelectMobile) voiceSelectMobile.innerHTML = '';
|
|
});
|
|
}
|
|
|
|
chatContainer.addEventListener('scroll', () => {
|
|
const distanceFromBottom = chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight;
|
|
userHasScrolledUp = distanceFromBottom > 5;
|
|
});
|
|
|
|
// NOTE: Model and voice restoration happens in populateModelDropdown() and
|
|
// populateVoiceDropdown() AFTER the async fetch completes. Don't set values
|
|
// here or call syncDropdownsAndQueryString() - that would overwrite localStorage
|
|
// with empty values before the dropdowns are populated.
|
|
|
|
// 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;
|
|
});
|
|
});
|
|
|
|
// 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});
|
|
});
|
|
|
|
// 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(user => {
|
|
const userItem = document.createElement("li");
|
|
// If it's the logged-in user, make it clickable to profile
|
|
if (user === username) {
|
|
const userLink = document.createElement("a");
|
|
userLink.href = "/profile";
|
|
userLink.textContent = user;
|
|
userLink.style.color = "var(--text-primary)";
|
|
userLink.style.textDecoration = "none";
|
|
userItem.appendChild(userLink);
|
|
} else {
|
|
userItem.textContent = user;
|
|
}
|
|
activeUserListElement.appendChild(userItem);
|
|
});
|
|
|
|
// Populate the list with inactive users (desktop)
|
|
inactiveUsers.forEach(user => {
|
|
const userItem = document.createElement("li");
|
|
// If it's the logged-in user, make it clickable to profile
|
|
if (user === username) {
|
|
const userLink = document.createElement("a");
|
|
userLink.href = "/profile";
|
|
userLink.textContent = user;
|
|
userLink.style.color = "var(--text-secondary)";
|
|
userLink.style.textDecoration = "none";
|
|
userItem.appendChild(userLink);
|
|
} else {
|
|
userItem.textContent = user;
|
|
}
|
|
inactiveUserListElement.appendChild(userItem);
|
|
});
|
|
|
|
// Populate the list with active users (mobile)
|
|
activeUsers.forEach(user => {
|
|
const userItemMobile = document.createElement("li");
|
|
// If it's the logged-in user, make it clickable to profile
|
|
if (user === username) {
|
|
const userLink = document.createElement("a");
|
|
userLink.href = "/profile";
|
|
userLink.textContent = user;
|
|
userLink.style.color = "var(--text-primary)";
|
|
userLink.style.textDecoration = "none";
|
|
userItemMobile.appendChild(userLink);
|
|
} else {
|
|
userItemMobile.textContent = user;
|
|
}
|
|
activeUserListElementMobile.appendChild(userItemMobile);
|
|
});
|
|
|
|
// Populate the list with inactive users (mobile)
|
|
inactiveUsers.forEach(user => {
|
|
const userItemMobile = document.createElement("li");
|
|
// If it's the logged-in user, make it clickable to profile
|
|
if (user === username) {
|
|
const userLink = document.createElement("a");
|
|
userLink.href = "/profile";
|
|
userLink.textContent = user;
|
|
userLink.style.color = "var(--text-secondary)";
|
|
userLink.style.textDecoration = "none";
|
|
userItemMobile.appendChild(userLink);
|
|
} else {
|
|
userItemMobile.textContent = user;
|
|
}
|
|
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,
|
|
"enable_thinking": showThinking // false => ask model to skip chain-of-thought
|
|
});
|
|
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 or add new rooms.
|
|
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) {
|
|
// Room exists - update it
|
|
// Extract user count from existing content (safer than regex on innerHTML)
|
|
const userCountText = roomListItem.textContent.match(/(\d+)\s*users/);
|
|
const userCount = userCountText ? userCountText[1] : null;
|
|
const ownerBadge = roomListItem.querySelector('.owner-badge');
|
|
|
|
// Clear and rebuild content safely using DOM methods
|
|
roomListItem.innerHTML = '';
|
|
|
|
// Add room name
|
|
const nameElement = document.createElement('b');
|
|
nameElement.textContent = updatedRoom.name;
|
|
roomListItem.appendChild(nameElement);
|
|
|
|
// Add room title if present
|
|
if (updatedRoom.title) {
|
|
roomListItem.appendChild(document.createElement('br'));
|
|
const titleText = document.createTextNode(updatedRoom.title);
|
|
roomListItem.appendChild(titleText);
|
|
}
|
|
|
|
// Add user count if it existed
|
|
if (userCount) {
|
|
roomListItem.appendChild(document.createElement('br'));
|
|
const userText = document.createTextNode(` ${userCount} users`);
|
|
roomListItem.appendChild(userText);
|
|
}
|
|
|
|
// Re-add owner badge if it existed
|
|
if (ownerBadge) {
|
|
roomListItem.appendChild(ownerBadge);
|
|
}
|
|
} else if (updatedRoom.is_new) {
|
|
// New room - add it to the appropriate list
|
|
const targetList = updatedRoom.is_private
|
|
? document.querySelector('#private-rooms-section .rooms-list')
|
|
: document.querySelector('#public-rooms-section .rooms-list');
|
|
|
|
if (targetList) {
|
|
// Create new room list item using safe DOM methods
|
|
const newLink = document.createElement('a');
|
|
newLink.href = `/chat/${encodeURIComponent(updatedRoom.name)}`;
|
|
|
|
const newLi = document.createElement('li');
|
|
newLi.setAttribute('data-room-id', updatedRoom.id);
|
|
if (updatedRoom.is_private) {
|
|
newLi.classList.add('private-room');
|
|
}
|
|
|
|
// Add room name safely
|
|
const nameElement = document.createElement('b');
|
|
nameElement.textContent = updatedRoom.name;
|
|
newLi.appendChild(nameElement);
|
|
|
|
// Add room title if present
|
|
if (updatedRoom.title) {
|
|
newLi.appendChild(document.createElement('br'));
|
|
const titleText = document.createTextNode(updatedRoom.title);
|
|
newLi.appendChild(titleText);
|
|
}
|
|
|
|
// Add user count
|
|
newLi.appendChild(document.createElement('br'));
|
|
const userText = document.createTextNode(' 0 users');
|
|
newLi.appendChild(userText);
|
|
|
|
newLink.appendChild(newLi);
|
|
targetList.appendChild(newLink);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Helper function to enable and wire download button
|
|
function enableDownloadButton(messageId, playButton, audioUrl, voice) {
|
|
const messageWrapper = playButton.closest('.message-wrapper');
|
|
if (!messageWrapper) return;
|
|
|
|
const downloadButton = messageWrapper.querySelector('.tts-download-button');
|
|
// Use == instead of === because dataset values are strings, messageId might be number
|
|
if (downloadButton && downloadButton.dataset.messageId == messageId) {
|
|
downloadButton.style.display = "inline-block";
|
|
downloadButton.onclick = () => {
|
|
const link = document.createElement('a');
|
|
link.href = audioUrl;
|
|
link.download = `tts-${messageId}-${voice}.${AUDIO_FORMAT.format}`;
|
|
link.click();
|
|
};
|
|
}
|
|
}
|
|
|
|
// Streaming TTS function - plays audio as chunks arrive using MediaSource API
|
|
// Returns a promise that resolves with { audio, blob, blobUrl, streamed }
|
|
async function fetchTTSStreaming(cleanText, model, voice) {
|
|
const mseMime = AUDIO_FORMAT.mseMime;
|
|
const canStream = mseMime && window.MediaSource && MediaSource.isTypeSupported(mseMime);
|
|
const requestFormat = AUDIO_FORMAT.format;
|
|
|
|
console.log(`TTS streaming: canStream=${canStream}, format=${requestFormat}, mseMime=${mseMime}`);
|
|
|
|
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,
|
|
response_format: requestFormat
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
// If MSE isn't available, fall back to full buffered download
|
|
if (!canStream) {
|
|
console.warn(`No streaming support for ${requestFormat}, using full buffer`);
|
|
const audioBlob = await response.blob();
|
|
const audioUrl = URL.createObjectURL(audioBlob);
|
|
const audio = new Audio(audioUrl);
|
|
audio.playbackRate = 0.9;
|
|
return { audio, blob: audioBlob, blobUrl: audioUrl, streamed: false };
|
|
}
|
|
|
|
// Use MediaSource API for true streaming
|
|
const mediaSource = new MediaSource();
|
|
const audioUrl = URL.createObjectURL(mediaSource);
|
|
const audio = new Audio(audioUrl);
|
|
audio.playbackRate = 0.9;
|
|
console.log(`TTS streaming: MediaSource created, readyState=${mediaSource.readyState}`);
|
|
|
|
// Collect all chunks for download later
|
|
const chunks = [];
|
|
|
|
// Create a promise that resolves when streaming is complete
|
|
const streamingComplete = new Promise((resolve, reject) => {
|
|
mediaSource.addEventListener('sourceopen', async () => {
|
|
console.log("TTS streaming: sourceopen fired");
|
|
let sourceBuffer;
|
|
try {
|
|
sourceBuffer = mediaSource.addSourceBuffer(mseMime);
|
|
sourceBuffer.mode = 'sequence';
|
|
} catch (e) {
|
|
console.error("Failed to create SourceBuffer:", e);
|
|
reject(e);
|
|
return;
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
let totalBytes = 0;
|
|
|
|
// Queue for appending buffers (SourceBuffer can only append one at a time)
|
|
const appendQueue = [];
|
|
let appending = false;
|
|
|
|
function processQueue() {
|
|
if (appending || appendQueue.length === 0) return;
|
|
appending = true;
|
|
const chunk = appendQueue.shift();
|
|
try {
|
|
sourceBuffer.appendBuffer(chunk);
|
|
} catch (e) {
|
|
console.error("appendBuffer error:", e);
|
|
appending = false;
|
|
}
|
|
}
|
|
|
|
sourceBuffer.addEventListener('updateend', () => {
|
|
appending = false;
|
|
processQueue();
|
|
});
|
|
|
|
try {
|
|
while (true) {
|
|
const { done: readerDone, value } = await reader.read();
|
|
if (readerDone) break;
|
|
|
|
chunks.push(value);
|
|
totalBytes += value.byteLength;
|
|
|
|
// Queue the chunk for appending (use slice to avoid shared ArrayBuffer issues)
|
|
appendQueue.push(value.slice().buffer);
|
|
processQueue();
|
|
|
|
// Auto-play once we have some data (~1KB)
|
|
if (totalBytes > 1024 && audio.paused) {
|
|
console.log("TTS streaming: starting playback at", totalBytes, "bytes");
|
|
audio.play().catch(e => console.warn("Auto-play blocked:", e.message));
|
|
}
|
|
}
|
|
|
|
// Wait for all queued appends to finish
|
|
await new Promise((res) => {
|
|
const check = () => {
|
|
if (!appending && appendQueue.length === 0) {
|
|
res();
|
|
} else {
|
|
setTimeout(check, 50);
|
|
}
|
|
};
|
|
check();
|
|
});
|
|
|
|
if (mediaSource.readyState === 'open') {
|
|
mediaSource.endOfStream();
|
|
}
|
|
|
|
console.log(`TTS streaming complete: ${totalBytes} bytes`);
|
|
|
|
// Build blob for download/caching
|
|
const audioBlob = new Blob(chunks, { type: mseMime });
|
|
resolve({ blob: audioBlob });
|
|
|
|
} catch (error) {
|
|
console.error("Streaming read error:", error);
|
|
if (mediaSource.readyState === 'open') {
|
|
mediaSource.endOfStream('network');
|
|
}
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
mediaSource.addEventListener('error', (e) => {
|
|
console.error("MediaSource error:", e);
|
|
reject(new Error("MediaSource error"));
|
|
});
|
|
});
|
|
|
|
// Return immediately with audio element - streaming happens in background
|
|
return {
|
|
audio,
|
|
blobUrl: audioUrl,
|
|
streamed: true,
|
|
streamingComplete // Promise that resolves with { blob } when done
|
|
};
|
|
}
|
|
|
|
// ---- Per-sentence glow synced to TTS audio via exact server timestamps ----
|
|
// The speech service (tts-1-f5) returns each sentence's start/end in ms — it
|
|
// synthesizes one audio chunk per sentence, so the timing is exact, not guessed.
|
|
// We wrap the rendered message into sentence spans and light the active one by
|
|
// comparing audio.currentTime to those boundaries. No Web Audio / RMS heuristics.
|
|
|
|
function wrapSentencesForGlow(container) {
|
|
if (!container || container.dataset.glowWrapped === '1') return;
|
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
|
const nodes = [];
|
|
let t;
|
|
while ((t = walker.nextNode())) { if (t.nodeValue && t.nodeValue.trim()) nodes.push(t); }
|
|
if (!nodes.length) return;
|
|
const full = nodes.map(function (x) { return x.nodeValue; }).join('');
|
|
const parts = full.match(/[^.!?]+[.!?]*\s*/g) || [full];
|
|
const ranges = [];
|
|
let start = 0;
|
|
for (let i = 0; i < parts.length; i++) {
|
|
ranges.push([start, start + parts[i].length]);
|
|
start += parts[i].length;
|
|
}
|
|
function sentAt(pos) {
|
|
for (let s = 0; s < ranges.length; s++) { if (pos < ranges[s][1]) return s; }
|
|
return ranges.length - 1;
|
|
}
|
|
let off = 0;
|
|
nodes.forEach(function (node) {
|
|
const text = node.nodeValue;
|
|
const frag = document.createDocumentFragment();
|
|
let i = 0;
|
|
while (i < text.length) {
|
|
const si = sentAt(off + i);
|
|
let j = i + 1;
|
|
while (j < text.length && sentAt(off + j) === si) j++;
|
|
const span = document.createElement('span');
|
|
span.className = 'tts-sentence';
|
|
span.dataset.si = si;
|
|
span.textContent = text.slice(i, j);
|
|
frag.appendChild(span);
|
|
i = j;
|
|
}
|
|
off += text.length;
|
|
node.parentNode.replaceChild(frag, node);
|
|
});
|
|
container.dataset.glowWrapped = '1';
|
|
container._glowSentenceCount = parts.length;
|
|
}
|
|
|
|
// Light the spoken sentence using exact server timing. `sentences` is the
|
|
// array from the speech service: [{index, text, start_ms, end_ms}, ...].
|
|
// Without it (non-F5 models), we no-op rather than guess.
|
|
function attachSentenceGlow(audio, playButton, sentences) {
|
|
if (!audio || !playButton) return;
|
|
// Allow an initially-empty array: under SSE it grows as sentences arrive,
|
|
// and the tick reads its length live. Undefined (non-F5) still no-ops.
|
|
if (!Array.isArray(sentences)) return;
|
|
const wrapper = playButton.closest('.message-wrapper');
|
|
const container = wrapper && wrapper.querySelector('.message-content');
|
|
if (!container) return;
|
|
wrapSentencesForGlow(container);
|
|
const spans = container.querySelectorAll('.tts-sentence');
|
|
const domCount = container._glowSentenceCount || 0;
|
|
if (!spans.length || domCount <= 0) return;
|
|
|
|
let active = -1;
|
|
function setActive(idx) {
|
|
if (idx === active) return;
|
|
active = idx;
|
|
spans.forEach(function (s) {
|
|
s.classList.toggle('tts-reading', parseInt(s.dataset.si, 10) === idx);
|
|
});
|
|
}
|
|
function clear() { spans.forEach(function (s) { s.classList.remove('tts-reading'); }); }
|
|
|
|
// The rendered message and the synthesized text usually split into the same
|
|
// sentence count; when they don't, map server index onto DOM spans by ratio.
|
|
function domIndexFor(serverIdx) {
|
|
const sc = sentences.length || 1;
|
|
if (domCount === sc) return serverIdx;
|
|
return Math.min(domCount - 1, Math.floor(serverIdx * domCount / sc));
|
|
}
|
|
|
|
function tick() {
|
|
if (audio.paused || audio.ended) return;
|
|
const ms = audio.currentTime * 1000;
|
|
let si = 0;
|
|
for (let i = 0; i < sentences.length; i++) {
|
|
if (ms >= sentences[i].start_ms) si = i; else break;
|
|
}
|
|
setActive(domIndexFor(si));
|
|
requestAnimationFrame(tick);
|
|
}
|
|
setActive(0);
|
|
audio.addEventListener('play', function () { requestAnimationFrame(tick); });
|
|
audio.addEventListener('ended', clear);
|
|
audio.addEventListener('pause', function () { if (audio.ended) clear(); });
|
|
if (!audio.paused) requestAnimationFrame(tick);
|
|
}
|
|
|
|
// Decode a base64 string to a Uint8Array.
|
|
function b64ToBytes(b64) {
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
// Parse a fetch byte stream as Server-Sent Events, yielding {event, data}.
|
|
async function* sseEvents(reader) {
|
|
const decoder = new TextDecoder();
|
|
let buf = "";
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
let nl;
|
|
while ((nl = buf.indexOf("\n\n")) >= 0) {
|
|
const block = buf.slice(0, nl);
|
|
buf = buf.slice(nl + 2);
|
|
let ev = "message", data = "";
|
|
block.split("\n").forEach((line) => {
|
|
if (line.startsWith("event:")) ev = line.slice(6).trim();
|
|
else if (line.startsWith("data:")) data += line.slice(5).trim();
|
|
});
|
|
yield { event: ev, data: data };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stream TTS over SSE (tts-1-f5 only). Each event carries one sentence's mp3
|
|
// plus exact timing. Audio feeds an MSE SourceBuffer (sequence mode) so playback
|
|
// starts after sentence 0; `sentences` grows live to drive the glow. Returns
|
|
// { audio, sentences, blobUrl, streamingComplete } — streamingComplete resolves
|
|
// with the full { blob } once every sentence has arrived.
|
|
async function fetchTTSStreamingSSE(cleanText, model, voice) {
|
|
const mseMime = "audio/mpeg";
|
|
const canStream = window.MediaSource && MediaSource.isTypeSupported(mseMime);
|
|
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, sse: true })
|
|
});
|
|
if (!response.ok) throw new Error(`TTS SSE request failed: ${response.status}`);
|
|
|
|
const sentences = []; // grows as events arrive; shared with the glow
|
|
const chunks = []; // mp3 bytes per sentence, for the final blob
|
|
const reader = response.body.getReader();
|
|
|
|
// No MSE for mp3 (e.g. Firefox/Safari): collect all, then play one blob.
|
|
if (!canStream) {
|
|
for await (const e of sseEvents(reader)) {
|
|
if (e.event !== "sentence") continue;
|
|
const o = JSON.parse(e.data);
|
|
sentences.push({ index: o.index, text: o.text, start_ms: o.start_ms, end_ms: o.end_ms });
|
|
chunks.push(b64ToBytes(o.audio_b64));
|
|
}
|
|
const blob = new Blob(chunks, { type: mseMime });
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const audio = new Audio(blobUrl);
|
|
audio.playbackRate = 0.9;
|
|
return { audio, sentences, blobUrl, streamingComplete: Promise.resolve({ blob }) };
|
|
}
|
|
|
|
const mediaSource = new MediaSource();
|
|
const blobUrl = URL.createObjectURL(mediaSource);
|
|
const audio = new Audio(blobUrl);
|
|
audio.playbackRate = 0.9;
|
|
|
|
const streamingComplete = new Promise((resolve, reject) => {
|
|
mediaSource.addEventListener("sourceopen", async () => {
|
|
let sourceBuffer;
|
|
try {
|
|
sourceBuffer = mediaSource.addSourceBuffer(mseMime);
|
|
sourceBuffer.mode = "sequence";
|
|
} catch (e) { reject(e); return; }
|
|
const appendQueue = [];
|
|
let appending = false;
|
|
function processQueue() {
|
|
if (appending || appendQueue.length === 0) return;
|
|
appending = true;
|
|
try { sourceBuffer.appendBuffer(appendQueue.shift()); }
|
|
catch (e) { appending = false; }
|
|
}
|
|
sourceBuffer.addEventListener("updateend", () => { appending = false; processQueue(); });
|
|
try {
|
|
for await (const e of sseEvents(reader)) {
|
|
if (e.event === "error") throw new Error("TTS SSE error");
|
|
if (e.event !== "sentence") continue;
|
|
const o = JSON.parse(e.data);
|
|
sentences.push({ index: o.index, text: o.text, start_ms: o.start_ms, end_ms: o.end_ms });
|
|
const bytes = b64ToBytes(o.audio_b64);
|
|
chunks.push(bytes);
|
|
appendQueue.push(bytes.slice().buffer);
|
|
processQueue();
|
|
if (audio.paused) audio.play().catch(() => {});
|
|
}
|
|
await new Promise((res) => {
|
|
const check = () => (!appending && appendQueue.length === 0) ? res() : setTimeout(check, 50);
|
|
check();
|
|
});
|
|
if (mediaSource.readyState === "open") mediaSource.endOfStream();
|
|
resolve({ blob: new Blob(chunks, { type: mseMime }) });
|
|
} catch (error) {
|
|
if (mediaSource.readyState === "open") { try { mediaSource.endOfStream("network"); } catch (e) { /* ignore */ } }
|
|
reject(error);
|
|
}
|
|
});
|
|
mediaSource.addEventListener("error", () => reject(new Error("MediaSource error")));
|
|
});
|
|
|
|
return { audio, sentences, blobUrl, streamed: true, streamingComplete };
|
|
}
|
|
|
|
// Function to read text using TTS (for manual button clicks) - now with streaming
|
|
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 cachedData = audioCache[cacheKey];
|
|
// Create new audio from cached blob for replay
|
|
const audioUrl = URL.createObjectURL(cachedData.blob);
|
|
const audio = new Audio(audioUrl);
|
|
audio.playbackRate = 0.9;
|
|
enableDownloadButton(messageId, playButton, audioUrl, voice);
|
|
toggleAudioPlayback(audio, playButton);
|
|
return;
|
|
}
|
|
|
|
// Set button to streaming state
|
|
playButton.textContent = "Streaming...";
|
|
playButton.disabled = true;
|
|
|
|
// Use streaming TTS
|
|
const result = await fetchTTSStreaming(cleanText, model, voice);
|
|
const audio = result.audio;
|
|
|
|
// Enable button immediately and start playback
|
|
playButton.disabled = false;
|
|
toggleAudioPlayback(audio, playButton);
|
|
|
|
// Handle streaming completion for caching and download button
|
|
if (result.streamed && result.streamingComplete) {
|
|
result.streamingComplete.then(({ blob }) => {
|
|
// Cache the blob for replay
|
|
audioCache[cacheKey] = { blob };
|
|
// Create downloadable URL from blob
|
|
const downloadUrl = URL.createObjectURL(blob);
|
|
enableDownloadButton(messageId, playButton, downloadUrl, voice);
|
|
}).catch(e => console.error("Streaming completion error:", e));
|
|
} else {
|
|
// Non-streamed fallback - cache immediately
|
|
audioCache[cacheKey] = { blob: result.blob };
|
|
enableDownloadButton(messageId, playButton, result.blobUrl, voice);
|
|
}
|
|
} 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) - now with streaming
|
|
async function speakTextQueued(text, playButton, messageId) {
|
|
return new Promise(async (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, '');
|
|
|
|
function bindLifecycle(audio) {
|
|
currentQueuedAudio = audio;
|
|
audio.onended = () => {
|
|
console.log("TTS finished for:", messageId);
|
|
currentQueuedAudio = null;
|
|
resolve();
|
|
};
|
|
audio.onerror = () => {
|
|
console.error("TTS audio error for:", messageId);
|
|
currentQueuedAudio = null;
|
|
reject(new Error("Audio playback failed"));
|
|
};
|
|
}
|
|
|
|
// Check if audio is cached
|
|
if (audioCache[cacheKey]) {
|
|
const cachedData = audioCache[cacheKey];
|
|
// Create new audio from cached blob for replay
|
|
const audioUrl = URL.createObjectURL(cachedData.blob);
|
|
const audio = new Audio(audioUrl);
|
|
audio.playbackRate = 0.9;
|
|
enableDownloadButton(messageId, playButton, audioUrl, voice);
|
|
attachSentenceGlow(audio, playButton, cachedData.sentences);
|
|
bindLifecycle(audio);
|
|
audio.play().catch(reject);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// F5 streams over SSE: audio starts after sentence 0 (gapless via MSE)
|
|
// and the glow tracks exact per-sentence timing as events arrive.
|
|
if (model === 'tts-1-f5') {
|
|
const result = await fetchTTSStreamingSSE(cleanText, model, voice);
|
|
const audio = result.audio;
|
|
attachSentenceGlow(audio, playButton, result.sentences);
|
|
bindLifecycle(audio);
|
|
// Cache the full clip + final sentence timing once streaming finishes.
|
|
result.streamingComplete.then(({ blob }) => {
|
|
audioCache[cacheKey] = { blob, sentences: result.sentences };
|
|
enableDownloadButton(messageId, playButton, URL.createObjectURL(blob), voice);
|
|
}).catch(e => console.error("TTS SSE completion error:", e));
|
|
if (audio.paused) audio.play().catch(reject);
|
|
return;
|
|
}
|
|
|
|
// Other models: streaming playback, no glow (no server timing).
|
|
const result = await fetchTTSStreaming(cleanText, model, voice);
|
|
const audio = result.audio;
|
|
bindLifecycle(audio);
|
|
|
|
// Handle streaming completion for caching
|
|
if (result.streamed && result.streamingComplete) {
|
|
result.streamingComplete.then(({ blob }) => {
|
|
// Cache the blob for replay
|
|
audioCache[cacheKey] = { blob };
|
|
// Create downloadable URL from blob
|
|
const downloadUrl = URL.createObjectURL(blob);
|
|
enableDownloadButton(messageId, playButton, downloadUrl, voice);
|
|
}).catch(e => console.error("Streaming completion error:", e));
|
|
} else {
|
|
// Non-streamed fallback - cache immediately
|
|
audioCache[cacheKey] = { blob: result.blob };
|
|
enableDownloadButton(messageId, playButton, result.blobUrl, voice);
|
|
}
|
|
|
|
// Audio should auto-play from streaming, but ensure it starts
|
|
if (audio.paused) {
|
|
audio.play().catch(reject);
|
|
}
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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();
|
|
currentQueuedMessageId = messageId;
|
|
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;
|
|
currentQueuedMessageId = null;
|
|
// Schedule next item with minimal delay to prevent blocking
|
|
setTimeout(processNextTTS, 10);
|
|
});
|
|
}
|
|
|
|
// Update show-thinking button labels/colors on both desktop + mobile.
|
|
function updateShowThinkingDisplay() {
|
|
const btn = document.getElementById("show-thinking-btn");
|
|
const btnMobile = document.getElementById("show-thinking-btn-mobile");
|
|
const label = showThinking ? "Thinking: ON" : "Thinking: OFF";
|
|
const color = showThinking ? "#4CAF50" : "#f44336";
|
|
if (btn) {
|
|
btn.textContent = label;
|
|
btn.style.backgroundColor = color;
|
|
}
|
|
if (btnMobile) {
|
|
btnMobile.textContent = label;
|
|
btnMobile.style.backgroundColor = color;
|
|
}
|
|
}
|
|
|
|
// Toggle thinking mode. On by default; persisted in localStorage.
|
|
function toggleShowThinking() {
|
|
showThinking = !showThinking;
|
|
localStorage.setItem('showThinking', showThinking.toString());
|
|
updateShowThinkingDisplay();
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
currentQueuedMessageId = 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
|
|
// Link to /profile if it's the logged-in user, otherwise /profile/username
|
|
let messageContent;
|
|
if (data.username) {
|
|
const profileLink = data.username === username ? '/profile' : `/profile/${data.username}`;
|
|
messageContent = `**[${data.username}](${profileLink}):**\n\n${data.content}`;
|
|
} else {
|
|
messageContent = 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);
|
|
|
|
// Create the download button for TTS audio (hidden initially)
|
|
const downloadButton = document.createElement("button");
|
|
downloadButton.textContent = "Download";
|
|
downloadButton.className = "tts-download-button";
|
|
downloadButton.style.display = "none";
|
|
downloadButton.dataset.messageId = data.id;
|
|
buttonContainer.appendChild(downloadButton);
|
|
|
|
messageWrapper.appendChild(buttonContainer);
|
|
}
|
|
|
|
messageWrapper.appendChild(newMessage);
|
|
|
|
document.getElementById("chat").appendChild(messageWrapper);
|
|
|
|
// Apply syntax highlighting to code blocks within the message
|
|
// Don't truncate new messages - only truncate on page load (previous_messages)
|
|
newMessage.querySelectorAll("pre code").forEach((block) => {
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
addCopyButtonToCodeBlock(block, false);
|
|
});
|
|
|
|
// Scroll to the bottom of the chat container to show the new message.
|
|
if (data.id) {
|
|
document.getElementById("chat").scrollTop = document.getElementById("chat").scrollHeight;
|
|
|
|
// Check if this message should auto-execute (from auto-fix)
|
|
if (window.pendingAutoExec) {
|
|
const autoExecData = window.pendingAutoExec;
|
|
window.pendingAutoExec = null; // Clear it so we don't re-execute
|
|
|
|
// Find the code block that was just added
|
|
const codeBlocks = newMessage.querySelectorAll("pre code");
|
|
if (codeBlocks.length > 0) {
|
|
// Get the first code block (should be the fixed code)
|
|
const codeBlock = codeBlocks[0];
|
|
|
|
// Find the Run button for this code block
|
|
setTimeout(() => {
|
|
// The Run button is in a sibling container after the <pre> element
|
|
const preElement = codeBlock.parentNode;
|
|
const buttonContainer = preElement.nextSibling;
|
|
const runButton = buttonContainer?.querySelector('.play-button');
|
|
|
|
if (runButton) {
|
|
console.log(`Auto-executing fixed code (attempt ${autoExecData.attempt}/3)...`);
|
|
|
|
// Store the attempt count so executeCodeBlock can pick it up
|
|
// We'll use a data attribute on the code block itself
|
|
codeBlock.dataset.autoExecAttempt = autoExecData.attempt.toString();
|
|
|
|
// Trigger execution - it will create its own results container
|
|
runButton.click();
|
|
}
|
|
}, 100); // Delay to ensure buttons are fully rendered
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// Link to /profile if it's the logged-in user, otherwise /profile/username
|
|
const profileLink = data.username === username ? '/profile' : `/profile/${data.username}`;
|
|
const usernameLink = `**[${data.username}](${profileLink}):**`;
|
|
// Otherwise, sanitize and process the message with marked
|
|
newMessage.innerHTML = DOMPurify.sanitize(marked.marked(`${usernameLink}\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);
|
|
|
|
// Create the download button for TTS audio (hidden initially)
|
|
const downloadButton = document.createElement("button");
|
|
downloadButton.textContent = "Download";
|
|
downloadButton.className = "tts-download-button";
|
|
downloadButton.style.display = "none";
|
|
downloadButton.dataset.messageId = data.id;
|
|
buttonContainer.appendChild(downloadButton);
|
|
|
|
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) => {
|
|
const wasTruncated = truncateCodeBlock(block);
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
addCopyButtonToCodeBlock(block, wasTruncated);
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Reasoning channel: lazy-create a collapsible <details> block above
|
|
// the message-content div. Only appears if the server actually streams
|
|
// delta.reasoning_content (i.e., thinking is on AND the model is using it).
|
|
// Auto-collapses below on the first content delta.
|
|
// Fallback guard: with Thinking OFF the server already suppresses reasoning
|
|
// at the model, but if a model ignores the switch and streams anyway, drop it.
|
|
if (data.reasoning_content) {
|
|
if (!showThinking) return;
|
|
let thinkingDetails = messageWrapper.querySelector(".message-thinking");
|
|
if (!thinkingDetails) {
|
|
// Ensure a message-body wrapper exists to anchor against
|
|
let messageBodyWrapper = messageWrapper.querySelector(".message-body");
|
|
if (!messageBodyWrapper) {
|
|
messageBodyWrapper = document.createElement("div");
|
|
messageBodyWrapper.className = "message-body";
|
|
messageWrapper.appendChild(messageBodyWrapper);
|
|
}
|
|
thinkingDetails = document.createElement("details");
|
|
thinkingDetails.className = "message-thinking";
|
|
thinkingDetails.open = true;
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = "thinking…";
|
|
thinkingDetails.appendChild(summary);
|
|
const body = document.createElement("div");
|
|
body.className = "message-thinking-body";
|
|
body.style.opacity = "0.6";
|
|
body.style.fontStyle = "italic";
|
|
body.style.whiteSpace = "pre-wrap";
|
|
thinkingDetails.appendChild(body);
|
|
// Insert at top of message-body so thinking appears above the answer
|
|
messageBodyWrapper.insertBefore(thinkingDetails, messageBodyWrapper.firstChild);
|
|
}
|
|
const body = thinkingDetails.querySelector(".message-thinking-body");
|
|
body.textContent += data.reasoning_content;
|
|
if (!userHasScrolledUp) {
|
|
document.getElementById("chat").scrollTop = document.getElementById("chat").scrollHeight;
|
|
}
|
|
return; // reasoning deltas don't touch buffer/markdown render
|
|
}
|
|
|
|
// 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
|
|
let messageBodyWrapper = messageWrapper.querySelector(".message-body");
|
|
if (!messageBodyWrapper) {
|
|
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");
|
|
}
|
|
|
|
// First content delta after thinking: auto-collapse the thinking block
|
|
// so the answer is the visible focus. Thinking stays one click away.
|
|
const thinkingDetails = messageWrapper.querySelector(".message-thinking");
|
|
if (thinkingDetails && thinkingDetails.open) {
|
|
thinkingDetails.open = false;
|
|
const summary = thinkingDetails.querySelector("summary");
|
|
if (summary) summary.textContent = "thinking (click to expand)";
|
|
}
|
|
|
|
// 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
|
|
// Don't truncate streaming messages - only truncate on page load (previous_messages)
|
|
targetMessageElement.querySelectorAll("pre code").forEach((block) => {
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
addCopyButtonToCodeBlock(block, false);
|
|
});
|
|
|
|
// 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);
|
|
|
|
// Create the download button for TTS audio (hidden initially)
|
|
const downloadButton = document.createElement("button");
|
|
downloadButton.textContent = "Download";
|
|
downloadButton.className = "tts-download-button";
|
|
downloadButton.style.display = "none";
|
|
downloadButton.dataset.messageId = data.id;
|
|
buttonContainer.appendChild(downloadButton);
|
|
|
|
// 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();
|
|
}
|
|
|
|
// Drop cached TTS audio for this message (keys are `${messageId}-${voice}`).
|
|
// The trailing dash keeps "12-" from matching "120-...".
|
|
const cachePrefix = data.message_id + "-";
|
|
Object.keys(audioCache).forEach((key) => {
|
|
if (key.startsWith(cachePrefix)) {
|
|
delete audioCache[key];
|
|
}
|
|
});
|
|
|
|
// Remove any pending queue entries for this message so the rest play in order.
|
|
ttsQueue = ttsQueue.filter((item) => item.messageId != data.message_id);
|
|
|
|
// If it's the message currently playing, stop it and advance the queue.
|
|
if (currentQueuedMessageId != null && currentQueuedMessageId == data.message_id) {
|
|
if (currentQueuedAudio) {
|
|
currentQueuedAudio.pause();
|
|
currentQueuedAudio.currentTime = 0;
|
|
currentQueuedAudio = null;
|
|
}
|
|
currentQueuedMessageId = null;
|
|
isPlayingTTS = false;
|
|
setTimeout(processNextTTS, 10);
|
|
}
|
|
});
|
|
|
|
// 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
|
|
// Don't truncate edited messages - only truncate on page load (previous_messages)
|
|
messageContentContainer.querySelectorAll("pre code").forEach((block) => {
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
addCopyButtonToCodeBlock(block, false);
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// 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;
|
|
|
|
// Store truncated text for Show More/Show Less toggle
|
|
block.dataset.truncatedText = truncatedText;
|
|
|
|
return true; // Indicate that the block was truncated
|
|
}
|
|
return false; // Indicate that the block was not truncated
|
|
}
|
|
|
|
|
|
// Modify the addCopyButtonToCodeBlock function to use the full content
|
|
function addCopyButtonToCodeBlock(block, wasTruncated = false) {
|
|
// 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.marginTop = '8px';
|
|
|
|
// If truncated, add a "Show More" button
|
|
if (wasTruncated) {
|
|
const expandButton = document.createElement('button');
|
|
expandButton.textContent = 'Show More';
|
|
expandButton.classList.add('show-more-button');
|
|
|
|
const truncatedText = block.dataset.truncatedText;
|
|
|
|
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;
|
|
|
|
buttonContainer.appendChild(expandButton);
|
|
}
|
|
|
|
// Create a button to copy the code block's content
|
|
const copyButton = document.createElement('button');
|
|
copyButton.textContent = 'Copy';
|
|
copyButton.classList.add('copy-button');
|
|
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);
|
|
};
|
|
|
|
// Create a button to download compiled binary (hidden initially)
|
|
const downloadBinaryButton = document.createElement('button');
|
|
downloadBinaryButton.textContent = '⬇ Download Binary';
|
|
downloadBinaryButton.classList.add('download-binary-button');
|
|
downloadBinaryButton.style.display = 'none';
|
|
|
|
// Add buttons to container
|
|
buttonContainer.appendChild(copyButton);
|
|
buttonContainer.appendChild(playButton);
|
|
buttonContainer.appendChild(downloadBinaryButton);
|
|
|
|
// Insert the button container after the <pre> element (not inside it)
|
|
// block is <code>, block.parentNode is <pre>
|
|
// We want to insert after <pre>, so we use <pre>.parentNode and <pre>.nextSibling
|
|
const preElement = block.parentNode;
|
|
preElement.parentNode.insertBefore(buttonContainer, preElement.nextSibling);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Check if this is an auto-exec from a fix and set the attempt counter
|
|
if (blockElement.dataset.autoExecAttempt) {
|
|
resultsContainer.dataset.fixAttempts = blockElement.dataset.autoExecAttempt;
|
|
delete blockElement.dataset.autoExecAttempt; // Clean up after use
|
|
}
|
|
|
|
// 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 backend proxy for code execution (keeps API key secure)
|
|
const asyncResponse = await fetch('/api/code/execute', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
language: language,
|
|
code: code,
|
|
return_artifact: true // Request artifacts (binaries, images, videos, etc)
|
|
})
|
|
});
|
|
|
|
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(`/api/code/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(`/api/code/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') {
|
|
// Unsandbox returns stdout/stderr/exit_code at top level of job response
|
|
displayExecutionResults(job, resultsContainer, language, code);
|
|
|
|
// Check if execution failed and attempt auto-fix
|
|
const exitCode = job.exit_code;
|
|
const stderr = job.stderr || '';
|
|
|
|
// Only use stderr for error detection (stdout/stderr properly separated now)
|
|
const shouldAutoFix = exitCode !== 0 && stderr.trim() !== '';
|
|
|
|
// Track attempts per code block (store in resultsContainer dataset)
|
|
if (!resultsContainer.dataset.fixAttempts) {
|
|
resultsContainer.dataset.fixAttempts = '0';
|
|
}
|
|
|
|
const currentAttempts = parseInt(resultsContainer.dataset.fixAttempts);
|
|
|
|
if (shouldAutoFix && currentAttempts < 3) {
|
|
// Show auto-fix message
|
|
const autoFixDiv = document.createElement('div');
|
|
autoFixDiv.style.color = 'var(--text-info)';
|
|
autoFixDiv.style.fontWeight = 'bold';
|
|
autoFixDiv.style.marginTop = '12px';
|
|
autoFixDiv.textContent = `Attempting to auto-fix errors (attempt ${currentAttempts + 1}/3)...`;
|
|
resultsContainer.appendChild(autoFixDiv);
|
|
|
|
// Increment attempt counter
|
|
resultsContainer.dataset.fixAttempts = (currentAttempts + 1).toString();
|
|
|
|
// Call backend to fix the code
|
|
try {
|
|
const fixResponse = await fetch('/api/fix-code', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
code: code,
|
|
language: language,
|
|
stderr: stderr, // Send only stderr (properly separated now)
|
|
exit_code: exitCode,
|
|
attempt: currentAttempts + 1
|
|
})
|
|
});
|
|
|
|
if (fixResponse.ok) {
|
|
const fixData = await fixResponse.json();
|
|
|
|
if (fixData.success && fixData.fixed_code) {
|
|
// Update status
|
|
autoFixDiv.textContent = `Code fixed! Posting and re-executing (attempt ${currentAttempts + 1}/3)...`;
|
|
|
|
// Store fixed code and attempt count for auto-execution after message is posted
|
|
const autoExecData = {
|
|
code: fixData.fixed_code,
|
|
language: language,
|
|
attempt: currentAttempts + 1
|
|
};
|
|
|
|
// Store in global variable so chat_message handler can access it
|
|
window.pendingAutoExec = autoExecData;
|
|
|
|
// Post the fixed code as a new message in the chat
|
|
socket.emit("chat_message", {
|
|
"username": username,
|
|
"message": `**Auto-fixed code (attempt ${currentAttempts + 1}/3):**\n\n\`\`\`${language}\n${fixData.fixed_code}\n\`\`\``,
|
|
"model": "None",
|
|
"room_name": room_name
|
|
});
|
|
|
|
return; // Exit - the message handler will trigger execution
|
|
} else {
|
|
autoFixDiv.textContent = `Auto-fix failed: ${fixData.error || 'Unknown error'}`;
|
|
autoFixDiv.style.color = 'var(--text-error)';
|
|
}
|
|
} else {
|
|
autoFixDiv.textContent = `Auto-fix request failed (HTTP ${fixResponse.status})`;
|
|
autoFixDiv.style.color = 'var(--text-error)';
|
|
}
|
|
} catch (autoFixError) {
|
|
console.error('Error during auto-fix:', autoFixError);
|
|
autoFixDiv.textContent = `Auto-fix error: ${autoFixError.message}`;
|
|
autoFixDiv.style.color = 'var(--text-error)';
|
|
}
|
|
} else if (currentAttempts >= 3 && shouldAutoFix) {
|
|
// Max attempts reached
|
|
const maxAttemptsDiv = document.createElement('div');
|
|
maxAttemptsDiv.style.color = 'var(--text-warning)';
|
|
maxAttemptsDiv.style.fontWeight = 'bold';
|
|
maxAttemptsDiv.style.marginTop = '12px';
|
|
maxAttemptsDiv.textContent = 'Maximum auto-fix attempts (3) reached. Code still has errors.';
|
|
resultsContainer.appendChild(maxAttemptsDiv);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
// timeout, cancelled, or failed
|
|
const errorMsg = job.error || job.status;
|
|
|
|
// Display whatever output we have
|
|
displayExecutionResults(job, resultsContainer, language, code);
|
|
|
|
// Prepend error message to the results
|
|
const errorDiv = document.createElement('div');
|
|
errorDiv.style.color = 'var(--text-error)';
|
|
errorDiv.style.fontWeight = 'bold';
|
|
errorDiv.style.marginBottom = '8px';
|
|
errorDiv.textContent = `Execution ${errorMsg}`;
|
|
resultsContainer.insertBefore(errorDiv, resultsContainer.firstChild);
|
|
|
|
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, code) {
|
|
// Format and display results
|
|
let outputHtml = '';
|
|
|
|
// Unsandbox API returns flat structure: {success, stdout, stderr, exit_code, artifacts}
|
|
const actualStdout = result.stdout || '';
|
|
const actualStderr = result.stderr || '';
|
|
const exitCode = result.exit_code;
|
|
const artifacts = result.artifacts || [];
|
|
|
|
// Show language
|
|
if (language) {
|
|
outputHtml += `<div style="color: var(--text-info); margin-bottom: 8px;">Language: ${language}</div>`;
|
|
}
|
|
|
|
// Show exit code
|
|
if (exitCode !== undefined && exitCode !== null) {
|
|
const exitColor = exitCode === 0 ? 'var(--text-success)' : 'var(--text-error)';
|
|
outputHtml += `<div style="color: ${exitColor}; margin-bottom: 8px;">Exit Code: ${exitCode}</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;
|
|
|
|
// Handle artifacts (binaries, images, videos, etc.)
|
|
if (artifacts && artifacts.length > 0) {
|
|
const artifactsDiv = document.createElement('div');
|
|
artifactsDiv.style.marginTop = '12px';
|
|
artifactsDiv.style.borderTop = '1px solid var(--border-color)';
|
|
artifactsDiv.style.paddingTop = '12px';
|
|
|
|
const artifactsTitle = document.createElement('div');
|
|
artifactsTitle.style.color = 'var(--text-info)';
|
|
artifactsTitle.style.fontWeight = 'bold';
|
|
artifactsTitle.style.marginBottom = '8px';
|
|
artifactsTitle.textContent = `Artifacts (${artifacts.length}):`;
|
|
artifactsDiv.appendChild(artifactsTitle);
|
|
|
|
artifacts.forEach((artifact, index) => {
|
|
const artifactItem = document.createElement('div');
|
|
artifactItem.style.marginBottom = '8px';
|
|
artifactItem.style.padding = '8px';
|
|
artifactItem.style.backgroundColor = 'var(--bg-secondary)';
|
|
artifactItem.style.borderRadius = '4px';
|
|
|
|
// Artifact name/filename
|
|
const artifactName = document.createElement('div');
|
|
artifactName.style.fontWeight = 'bold';
|
|
artifactName.style.marginBottom = '4px';
|
|
artifactName.textContent = artifact.filename || artifact.name || `Artifact ${index + 1}`;
|
|
artifactItem.appendChild(artifactName);
|
|
|
|
// Artifact type/size info
|
|
const mimeType = artifact.mime_type || artifact.type || '';
|
|
if (mimeType || artifact.size) {
|
|
const artifactInfo = document.createElement('div');
|
|
artifactInfo.style.fontSize = '12px';
|
|
artifactInfo.style.color = 'var(--text-muted)';
|
|
artifactInfo.style.marginBottom = '8px';
|
|
let infoText = '';
|
|
if (mimeType) infoText += `Type: ${mimeType}`;
|
|
if (artifact.size) infoText += ` | Size: ${formatFileSize(artifact.size)}`;
|
|
artifactInfo.textContent = infoText;
|
|
artifactItem.appendChild(artifactInfo);
|
|
}
|
|
|
|
// Buttons container
|
|
const buttonsDiv = document.createElement('div');
|
|
buttonsDiv.style.display = 'flex';
|
|
buttonsDiv.style.gap = '8px';
|
|
|
|
// Determine if artifact is viewable (images, videos, text)
|
|
const isImage = mimeType.startsWith('image/');
|
|
const isVideo = mimeType.startsWith('video/');
|
|
const isBinary = mimeType.includes('octet-stream') || mimeType.includes('executable');
|
|
|
|
// Download button (always available)
|
|
const downloadBtn = document.createElement('button');
|
|
downloadBtn.textContent = '⬇ Download';
|
|
downloadBtn.style.padding = '4px 8px';
|
|
downloadBtn.style.fontSize = '12px';
|
|
downloadBtn.onclick = () => downloadArtifact(artifact);
|
|
buttonsDiv.appendChild(downloadBtn);
|
|
|
|
// View button (disabled for binaries)
|
|
const viewBtn = document.createElement('button');
|
|
viewBtn.textContent = '👁 View';
|
|
viewBtn.style.padding = '4px 8px';
|
|
viewBtn.style.fontSize = '12px';
|
|
if (isBinary) {
|
|
viewBtn.disabled = true;
|
|
viewBtn.style.opacity = '0.5';
|
|
viewBtn.style.cursor = 'not-allowed';
|
|
} else {
|
|
viewBtn.onclick = () => viewArtifact(artifact, artifactItem, isImage, isVideo);
|
|
}
|
|
buttonsDiv.appendChild(viewBtn);
|
|
|
|
artifactItem.appendChild(buttonsDiv);
|
|
artifactsDiv.appendChild(artifactItem);
|
|
});
|
|
|
|
resultsContainer.appendChild(artifactsDiv);
|
|
}
|
|
|
|
// Find the download binary button (it's in the button container next to the Run button)
|
|
// resultsContainer is inside <pre>, button container is the next sibling of <pre>
|
|
const preElement = resultsContainer.parentNode;
|
|
const buttonContainer = preElement.nextSibling;
|
|
const downloadButton = buttonContainer?.querySelector('.download-binary-button');
|
|
|
|
// Check for compiled binary artifact
|
|
if (result.artifact && result.artifact.type === 'base64' && result.artifact.data) {
|
|
// Show and populate the download button
|
|
if (downloadButton) {
|
|
downloadButton.style.display = 'inline-block';
|
|
downloadButton.onclick = async () => {
|
|
try {
|
|
// Generate AI filename if code is available
|
|
let filename = result.artifact.filename || 'compiled_binary';
|
|
|
|
if (code && language) {
|
|
try {
|
|
const nameResponse = await fetch('/api/generate-artifact-name', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
code: code,
|
|
language: language
|
|
})
|
|
});
|
|
|
|
if (nameResponse.ok) {
|
|
const nameData = await nameResponse.json();
|
|
if (nameData.filename) {
|
|
filename = nameData.filename;
|
|
}
|
|
}
|
|
} catch (nameError) {
|
|
// If naming fails, fall back to original filename
|
|
console.warn('Failed to generate AI filename:', nameError);
|
|
}
|
|
}
|
|
|
|
// Convert base64 to binary
|
|
const binaryString = atob(result.artifact.data);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
|
|
// Create blob and download
|
|
const blob = new Blob([bytes], { type: 'application/octet-stream' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
console.error('Error downloading binary:', error);
|
|
alert('Failed to download binary: ' + error.message);
|
|
}
|
|
};
|
|
}
|
|
} else {
|
|
// Hide the download button if no artifact or artifact error
|
|
if (downloadButton) {
|
|
downloadButton.style.display = 'none';
|
|
}
|
|
|
|
// Show artifact error if present
|
|
if (result.artifact && result.artifact.type === 'error') {
|
|
const artifactError = document.createElement('div');
|
|
artifactError.style.color = 'var(--text-warning)';
|
|
artifactError.style.marginTop = '8px';
|
|
artifactError.style.fontSize = '12px';
|
|
artifactError.textContent = `Artifact error: ${result.artifact.error}`;
|
|
resultsContainer.appendChild(artifactError);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to format file size
|
|
function formatFileSize(bytes) {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
|
|
// Helper function to download artifact
|
|
async function downloadArtifact(artifact) {
|
|
try {
|
|
// Artifacts come as base64 in the response (field: content_base64)
|
|
const base64Data = artifact.content_base64 || artifact.data || artifact.content;
|
|
|
|
if (!base64Data) {
|
|
console.error('No base64 data in artifact:', artifact);
|
|
alert('Artifact data not available');
|
|
return;
|
|
}
|
|
|
|
// Decode base64 to binary
|
|
const binaryString = atob(base64Data);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
|
|
// Determine mime type
|
|
const mimeType = artifact.mime_type || artifact.type || 'application/octet-stream';
|
|
|
|
// Create blob and download
|
|
const blob = new Blob([bytes], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = artifact.filename || artifact.name || 'download';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
console.error('Error downloading artifact:', error);
|
|
alert('Failed to download artifact: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Helper function to view artifact inline
|
|
async function viewArtifact(artifact, artifactItem, isImage, isVideo) {
|
|
try {
|
|
// Artifacts come as base64 in the response (field: content_base64)
|
|
const base64Data = artifact.content_base64 || artifact.data || artifact.content;
|
|
if (!base64Data) {
|
|
console.error('No base64 data in artifact:', artifact);
|
|
alert('Artifact data not available');
|
|
return;
|
|
}
|
|
|
|
// Check if already viewing
|
|
const existingViewer = artifactItem.querySelector('.artifact-viewer');
|
|
if (existingViewer) {
|
|
existingViewer.remove();
|
|
return;
|
|
}
|
|
|
|
// Create viewer container
|
|
const viewerDiv = document.createElement('div');
|
|
viewerDiv.classList.add('artifact-viewer');
|
|
viewerDiv.style.marginTop = '8px';
|
|
viewerDiv.style.padding = '8px';
|
|
viewerDiv.style.backgroundColor = 'var(--bg-primary)';
|
|
viewerDiv.style.borderRadius = '4px';
|
|
viewerDiv.style.maxWidth = '100%';
|
|
viewerDiv.style.overflow = 'auto';
|
|
|
|
// Determine mime type
|
|
const mimeType = artifact.mime_type || artifact.type || 'application/octet-stream';
|
|
|
|
if (isImage) {
|
|
// Create data URL from base64
|
|
const dataUrl = `data:${mimeType};base64,${base64Data}`;
|
|
const img = document.createElement('img');
|
|
img.src = dataUrl;
|
|
img.style.maxWidth = '100%';
|
|
img.style.height = 'auto';
|
|
img.style.display = 'block';
|
|
img.onerror = () => {
|
|
viewerDiv.textContent = 'Failed to load image';
|
|
viewerDiv.style.color = 'var(--text-error)';
|
|
};
|
|
viewerDiv.appendChild(img);
|
|
} else if (isVideo) {
|
|
// Create blob URL from base64
|
|
const binaryString = atob(base64Data);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
const blob = new Blob([bytes], { type: mimeType });
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
|
|
const video = document.createElement('video');
|
|
video.src = blobUrl;
|
|
video.controls = true;
|
|
video.style.maxWidth = '100%';
|
|
video.style.height = 'auto';
|
|
video.style.display = 'block';
|
|
video.onerror = () => {
|
|
viewerDiv.textContent = 'Failed to load video';
|
|
viewerDiv.style.color = 'var(--text-error)';
|
|
URL.revokeObjectURL(blobUrl);
|
|
};
|
|
viewerDiv.appendChild(video);
|
|
} else {
|
|
// For text types, decode and display
|
|
try {
|
|
const binaryString = atob(base64Data);
|
|
const text = decodeURIComponent(escape(binaryString));
|
|
const pre = document.createElement('pre');
|
|
pre.style.margin = '0';
|
|
pre.style.whiteSpace = 'pre-wrap';
|
|
pre.style.wordWrap = 'break-word';
|
|
pre.textContent = text;
|
|
viewerDiv.appendChild(pre);
|
|
} catch (decodeError) {
|
|
viewerDiv.textContent = 'Failed to decode content';
|
|
viewerDiv.style.color = 'var(--text-error)';
|
|
}
|
|
}
|
|
|
|
artifactItem.appendChild(viewerDiv);
|
|
} catch (error) {
|
|
console.error('Error viewing artifact:', error);
|
|
alert('Failed to view artifact: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 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 %}
|