opencompletion.com/templates/chat.html
russell@unturf.com 5576179ffa Add comprehensive artifact support for code execution
Implement full artifact support for Unsandbox code execution, enabling
download and inline viewing of generated files (binaries, images, videos).

Backend changes (app.py):
- Update /api/code/execute to accept artifacts parameter
- Add /api/code/artifacts/<path> proxy endpoint for authenticated downloads
- Use SDK's _make_request for full parameter support
- Support artifacts in execution request body

Frontend changes (templates/chat.html):
- Pass artifacts=true in all code execution requests
- Display artifacts section with file info (name, type, size)
- Add download button for all artifact types
- Add view button with inline display for images/videos (disabled for binaries)
- Implement formatFileSize, downloadArtifact, viewArtifact helper functions
- Images and videos render directly in chat
- Text files display in formatted <pre> blocks

Documentation (CLAUDE.md):
- Document artifact support section
- List supported artifact types
- Describe frontend and backend features
- Update frontend integration notes

Artifact types supported:
- Compiled binaries (executables from C, C++, Rust, Go, etc.)
- Images (PNG, JPG, GIF, SVG)
- Videos (MP4, WebM)
- Text/data files (JSON, CSV, TXT)
2026-01-20 08:34:35 -05:00

2364 lines
94 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>
</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="auto-play-tts-btn" onclick="toggleAutoPlayTTS()" style="width: 100%; margin-top: 10px; background-color: #f44336; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer;">
Auto-Play TTS: OFF
</button>
</div>
<div id="activity-controls">
<h3>Activities</h3>
<div id="current-activity-info" style="display: none;">
<p>Current Activity: <span id="current-activity-name"></span></p>
<button id="cancel-activity-btn" onclick="cancelActivity()">Cancel Activity</button>
</div>
<div id="activity-list-section">
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 5px; margin-bottom: 5px;">
<select id="activity-select">
<option value="">-- Select an Activity --</option>
</select>
<button id="refresh-activities-btn" onclick="refreshActivityList()">🔄</button>
</div>
<button id="load-activity-btn" onclick="loadSelectedActivity()" style="margin-top: 5px;">Load Activity</button>
</div>
</div>
<div id="user-lists">
<div id="active-users-list">
<h3>Active Users</h3>
<ul id="active-users">
<!-- Active users will be dynamically populated here -->
</ul>
</div>
<div id="inactive-users-list">
<h3>Inactive Users</h3>
<ul id="inactive-users">
<!-- Inactive users will be dynamically populated here -->
</ul>
</div>
</div>
</div>
<script>
// Constants
const API_KEY = "dummy-api-key";
const TTS_API_URL = "https://speech.ai.unturf.com/v1/audio/speech";
const VOICES_API_URL = "https://speech.ai.unturf.com/v1/voices";
// Code execution API (proxied through backend to keep API key secure)
const room_name = "{{ room_name }}";
// 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 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;
// 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();
// 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
});
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}.mp3`;
link.click();
};
}
}
// Function to read text using TTS (for manual button clicks)
async function speakText(text, playButton, messageId) {
console.log("speakText called with:", {text: text, messageId, autoPlay: autoPlayTTS});
const voiceSelectValue = document.getElementById("voice-select").value;
// Parse model and voice from the dropdown value (format: "model:voice")
const [model, voice] = voiceSelectValue.includes(':') ? voiceSelectValue.split(':') : ['tts-1', voiceSelectValue];
const cacheKey = `${messageId}-${voiceSelectValue}`; // Unique cache key for each message and voice
// Clean the text to include only alphanumeric characters, spaces, and key punctuation
const cleanText = text.replace(/[^a-zA-Z0-9\s.,!?]/g, '');
try {
// Check if the audio is already cached
if (audioCache[cacheKey]) {
const audio = audioCache[cacheKey];
enableDownloadButton(messageId, playButton, audio.src, voice);
toggleAudioPlayback(audio, playButton);
return;
}
// Set button to processing state
playButton.textContent = "Processing...";
playButton.disabled = true;
const response = await fetch(TTS_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: model,
voice: voice,
input: cleanText // Use the cleaned text
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.playbackRate = 0.9;
// Cache the audio only after it is successfully created
audioCache[cacheKey] = audio;
// Enable and show download button
enableDownloadButton(messageId, playButton, audioUrl, voice);
// Enable button and change text to "Pause"
playButton.disabled = false;
toggleAudioPlayback(audio, playButton);
} catch (error) {
console.error('Error in TTS:', error);
playButton.textContent = "Play"; // Reset button text on error
playButton.disabled = false;
}
}
// Function to read text using TTS (for queued auto-play)
async function speakTextQueued(text, playButton, messageId) {
return new Promise((resolve, reject) => {
const voiceSelectValue = document.getElementById("voice-select").value;
// Parse model and voice from the dropdown value (format: "model:voice")
const [model, voice] = voiceSelectValue.includes(':') ? voiceSelectValue.split(':') : ['tts-1', voiceSelectValue];
const cacheKey = `${messageId}-${voiceSelectValue}`;
const cleanText = text.replace(/[^a-zA-Z0-9\s.,!?]/g, '');
const playAudio = (audio) => {
currentQueuedAudio = audio; // Track the currently playing queued audio
audio.onended = () => {
console.log("TTS finished for:", messageId);
currentQueuedAudio = null; // Clear when finished
resolve();
};
audio.onerror = () => {
console.error("TTS audio error for:", messageId);
currentQueuedAudio = null; // Clear on error
reject(new Error("Audio playback failed"));
};
audio.play().catch(reject);
};
// Check if audio is cached
if (audioCache[cacheKey]) {
const audio = audioCache[cacheKey];
enableDownloadButton(messageId, playButton, audio.src, voice);
playAudio(audio);
return;
}
// Fetch new audio
fetch(TTS_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: model,
voice: voice,
input: cleanText
})
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.blob();
})
.then(audioBlob => {
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.playbackRate = 0.9;
audioCache[cacheKey] = audio;
// Enable and show download button
enableDownloadButton(messageId, playButton, audioUrl, voice);
playAudio(audio);
})
.catch(reject);
});
}
// Function to add TTS to queue
function queueTTS(text, playButton, messageId) {
ttsQueue.push({ text, playButton, messageId });
console.log("Added to TTS queue:", messageId, "Queue length:", ttsQueue.length);
processNextTTS();
}
// Function to process the next TTS in queue
function processNextTTS() {
if (isPlayingTTS || ttsQueue.length === 0) {
return;
}
isPlayingTTS = true;
const { text, playButton, messageId } = ttsQueue.shift();
console.log("Processing TTS from queue:", messageId);
// Use non-blocking async processing
speakTextQueued(text, playButton, messageId)
.then(() => {
console.log("TTS completed successfully for:", messageId);
})
.catch((error) => {
console.error("TTS error:", error);
})
.finally(() => {
isPlayingTTS = false;
// Schedule next item with minimal delay to prevent blocking
setTimeout(processNextTTS, 10);
});
}
// Function to update auto-play TTS button display
function updateAutoPlayTTSDisplay() {
const autoPlayBtn = document.getElementById("auto-play-tts-btn");
const autoPlayBtnMobile = document.getElementById("auto-play-tts-btn-mobile");
if (autoPlayTTS) {
autoPlayBtn.textContent = "Auto-Play TTS: ON";
autoPlayBtn.style.backgroundColor = "#4CAF50";
autoPlayBtnMobile.textContent = "Auto-Play TTS: ON";
autoPlayBtnMobile.style.backgroundColor = "#4CAF50";
} else {
autoPlayBtn.textContent = "Auto-Play TTS: OFF";
autoPlayBtn.style.backgroundColor = "#f44336";
autoPlayBtnMobile.textContent = "Auto-Play TTS: OFF";
autoPlayBtnMobile.style.backgroundColor = "#f44336";
// Clear queue when turning off
ttsQueue = [];
isPlayingTTS = false;
}
}
// Function to toggle auto-play TTS
function toggleAutoPlayTTS() {
autoPlayTTS = !autoPlayTTS;
console.log("Auto-play TTS toggled to:", autoPlayTTS);
// Save to localStorage
localStorage.setItem('autoPlayTTS', autoPlayTTS.toString());
// If turning off, clear the queue and stop current audio
if (!autoPlayTTS) {
console.log("Clearing TTS queue, had", ttsQueue.length, "items");
ttsQueue = [];
isPlayingTTS = false;
// Stop any currently playing audio
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
if (currentAudio.playButton) {
currentAudio.playButton.textContent = "Play";
}
currentAudio = null;
}
// Stop any currently playing queued TTS audio
if (currentQueuedAudio) {
currentQueuedAudio.pause();
currentQueuedAudio.currentTime = 0;
currentQueuedAudio = null;
}
}
updateAutoPlayTTSDisplay();
}
// Function to toggle audio playback
function toggleAudioPlayback(audio, playButton) {
if (currentAudio && currentAudio !== audio) {
currentAudio.pause();
currentAudio.currentTime = 0;
currentAudio.playButton.textContent = "Play";
}
if (audio.paused) {
audio.play();
playButton.textContent = "Pause";
} else {
audio.pause();
playButton.textContent = "Play";
}
currentAudio = audio;
currentAudio.playButton = playButton;
audio.onended = () => {
playButton.textContent = "Play";
};
}
// Socket event for receiving a new message
socket.on("chat_message", (data) => {
const messageWrapper = document.createElement("div");
messageWrapper.className = "message-wrapper";
messageWrapper.id = "message-" + data.id;
const newMessage = document.createElement("div");
newMessage.className = "message-content";
// Check if the username is present and prepend it to the message content
// 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);
}
// If the message-content div doesn't exist, create it
if (!messageWrapper.querySelector(".message-content")) {
// Create a message body wrapper to contain both header and content
const messageBodyWrapper = document.createElement("div");
messageBodyWrapper.className = "message-body";
messageWrapper.appendChild(messageBodyWrapper);
// Create header element for username/model
const headerElement = document.createElement("div");
headerElement.className = "message-header";
messageBodyWrapper.appendChild(headerElement);
// Create content element for actual message content
targetMessageElement = document.createElement("div");
targetMessageElement.className = "message-content";
messageBodyWrapper.appendChild(targetMessageElement);
} else {
targetMessageElement = messageWrapper.querySelector(".message-content");
}
// If the message buffer for this ID doesn't exist, create it
if (!messageBuffers[data.id]) {
messageBuffers[data.id] = "";
}
// Store header info on first chunk and update header element
if (data.is_first_chunk && data.username && data.model_name) {
messageHeaders[data.id] = {
username: data.username,
model_name: data.model_name
};
// Update header element
const headerElement = messageWrapper.querySelector(".message-header");
if (headerElement) {
const headerContent = `**${data.username} (${data.model_name}):**`;
headerElement.innerHTML = DOMPurify.sanitize(marked.marked(headerContent), dompurify_config);
}
}
// Append the chunk to the buffer
messageBuffers[data.id] += data.content;
// Process just the content and set it in the content element
const sanitizedContent = DOMPurify.sanitize(marked.marked(messageBuffers[data.id]), dompurify_config);
targetMessageElement.innerHTML = sanitizedContent;
// Store the raw markdown in a data attribute for later use in editing (without header for clean editing)
targetMessageElement.dataset.rawMarkdown = messageBuffers[data.id];
// Apply syntax highlighting to code blocks within the content
// 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();
}
});
// 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,
artifacts: 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.name || artifact.filename || `Artifact ${index + 1}`;
artifactItem.appendChild(artifactName);
// Artifact type/size info
if (artifact.type || artifact.size) {
const artifactInfo = document.createElement('div');
artifactInfo.style.fontSize = '12px';
artifactInfo.style.color = 'var(--text-muted)';
artifactInfo.style.marginBottom = '8px';
let infoText = '';
if (artifact.type) infoText += `Type: ${artifact.type}`;
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 mimeType = artifact.mime_type || artifact.type || '';
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 {
const artifactUrl = artifact.url || artifact.download_url;
if (!artifactUrl) {
alert('Artifact URL not available');
return;
}
// Use our backend proxy to download with authentication
const encodedUrl = encodeURIComponent(artifactUrl);
const proxyUrl = `/api/code/artifacts/${encodedUrl}`;
// Open in new tab to trigger download
window.open(proxyUrl, '_blank');
} 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 {
const artifactUrl = artifact.url || artifact.download_url;
if (!artifactUrl) {
alert('Artifact URL 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';
// Use our backend proxy for authenticated access
const encodedUrl = encodeURIComponent(artifactUrl);
const proxyUrl = `/api/code/artifacts/${encodedUrl}`;
if (isImage) {
const img = document.createElement('img');
img.src = proxyUrl;
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) {
const video = document.createElement('video');
video.src = proxyUrl;
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)';
};
viewerDiv.appendChild(video);
} else {
// For other types, try to fetch and display as text
const response = await fetch(proxyUrl);
if (response.ok) {
const text = await response.text();
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);
} else {
viewerDiv.textContent = 'Failed to load 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 %}