Auto-play TTS used to show zero feedback between message arrival and audio start — users waited blind. Now the play button shows: Queued + pulsing dot — message is waiting in line Loading + spinner — actively fetching audio from speech service Pause — audio is actually playing Manual Play also uses the same spinner instead of the old 'Streaming...' text-only state. Indicator clears on play, pause, end, error, and when auto-play is toggled off.
2950 lines
120 KiB
HTML
2950 lines
120 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 = "Loading";
|
|
playButton.classList.add("tts-loading");
|
|
playButton.disabled = true;
|
|
|
|
// Use streaming TTS
|
|
const result = await fetchTTSStreaming(cleanText, model, voice);
|
|
const audio = result.audio;
|
|
|
|
// Enable button immediately and start playback
|
|
playButton.classList.remove("tts-loading");
|
|
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.classList.remove("tts-loading");
|
|
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.onplay = () => {
|
|
playButton.classList.remove("tts-queued", "tts-loading");
|
|
playButton.disabled = false;
|
|
playButton.textContent = "Pause";
|
|
currentAudio = audio;
|
|
currentAudio.playButton = playButton;
|
|
};
|
|
audio.onpause = () => {
|
|
if (!audio.ended) playButton.textContent = "Play";
|
|
};
|
|
audio.onended = () => {
|
|
console.log("TTS finished for:", messageId);
|
|
playButton.classList.remove("tts-queued", "tts-loading");
|
|
playButton.disabled = false;
|
|
playButton.textContent = "Play";
|
|
currentQueuedAudio = null;
|
|
resolve();
|
|
};
|
|
audio.onerror = () => {
|
|
console.error("TTS audio error for:", messageId);
|
|
playButton.classList.remove("tts-queued", "tts-loading");
|
|
playButton.disabled = false;
|
|
playButton.textContent = "Play";
|
|
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) {
|
|
playButton.classList.remove("tts-queued", "tts-loading");
|
|
playButton.disabled = false;
|
|
playButton.textContent = "Play";
|
|
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);
|
|
// Immediately show "Queued" so users see TTS is incoming even before fetch starts.
|
|
playButton.classList.remove("tts-loading");
|
|
playButton.classList.add("tts-queued");
|
|
playButton.disabled = true;
|
|
playButton.textContent = "Queued";
|
|
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);
|
|
|
|
// Swap "Queued" indicator for active "Loading" spinner while we fetch audio.
|
|
playButton.classList.remove("tts-queued");
|
|
playButton.classList.add("tts-loading");
|
|
playButton.disabled = true;
|
|
playButton.textContent = "Loading";
|
|
|
|
// 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");
|
|
// Reset any buttons that were showing the Queued/Loading indicator.
|
|
ttsQueue.forEach((item) => {
|
|
if (item.playButton) {
|
|
item.playButton.classList.remove("tts-queued", "tts-loading");
|
|
item.playButton.disabled = false;
|
|
item.playButton.textContent = "Play";
|
|
}
|
|
});
|
|
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 %}
|