opencompletion.com/templates/chat.html
Russell a260b5fa02
Handle cancellation and timeout recovery (#37)
* Enable binary downloads on timeout/cancellation

When code execution times out or is cancelled, the compiled binary
may still be available. This change ensures displayExecutionResults()
is called for timeout/cancelled jobs, allowing users to download
the binary artifact even when execution doesn't complete normally.

* Fix partial output display for timeout/cancellation

Previous commit broke partial output display by passing job.result
directly to displayExecutionResults(), but for timeout/cancelled jobs
the output is in partial_output field, not stdout.

Now properly maps partial_output to stdout before displaying, so users
see both the error message and any output that was captured before
timeout/cancellation, plus binary downloads if available.

* Add debugging for missing artifact on timeout/cancel

Check multiple possible locations for artifact:
- job.artifact (top level)
- job.result.artifact (nested)

Add console logging to see full job structure when timeout/cancel
occurs so we can understand why the binary isn't appearing.

* Try fetching artifact from separate endpoint on timeout/cancel

When timeout/cancel occurs, the artifact isn't in the job response.
Try fetching from /jobs/{job_id}/artifact endpoint as a fallback.

This explores whether the executor service has a separate artifact
endpoint that we can use to retrieve compiled binaries even when
execution is cancelled or times out.

* Remove debug logging, document artifact limitation

Removed console.log debugging statements now that we've confirmed
the executor service doesn't include artifacts in timeout/cancelled
responses and doesn't have a /jobs/{job_id}/artifact endpoint.

Kept the artifact fetching code with comments for future compatibility
if the executor service adds this feature.

Current limitation: Binary downloads only work for completed executions,
not for timeout/cancelled ones. The binary exists but the executor
service doesn't return it.

* Try multiple artifact endpoint patterns for timeout/cancel

When artifact isn't in the job response, try fetching from:
- /artifacts/{job_id}
- /jobs/{job_id}/artifact
- /jobs/{job_id}/download
- /jobs/{job_id}/binary
- /download/{job_id}
- /binary/{job_id}

Handles both JSON responses and direct binary responses. Logs
each attempt to console so we can see which endpoint (if any) works.

* Revert endpoint searching - artifact should be in /jobs/{id}

According to OpenAPI spec, there are no separate artifact endpoints.
The artifact should be included in GET /jobs/{id} response for ALL
job statuses (completed, cancelled, timeout).

Current limitation: The executor service only includes result.artifact
for "completed" status, not for "cancelled" or "timeout" status.

The frontend code is correct - it checks job.artifact and
job.result.artifact. The issue is the executor service needs to
include the artifact in cancelled/timeout responses.

* Add debug logging for cancelled/timeout artifact checks

Since the executor service was supposedly patched to include artifacts
in GET /jobs/{id} responses even for cancelled/timeout jobs, add
detailed logging to verify:

1. What the full job response looks like
2. Whether artifact is at job.artifact or job.result.artifact
3. Artifact details if found

This will help determine if the patch is deployed and working.

* Add test-artifact Makefile target for testing executor API

Tests binary artifact retrieval from code executor service:
- Compiles C code with return_artifact=true
- Extracts base64 artifact from response
- Decodes and executes the binary

Can test against different URLs:
  make test-artifact URL=https://code.ai.unturf.com

Tested against production and confirmed:
- Artifacts ARE included for completed jobs
- Artifacts are NOT included for cancelled/timeout jobs (even with
  return_artifact=true). Exit code 137 indicates SIGKILL.

* Document confirmed limitation - no artifacts for cancelled jobs

Tested against production executor API (make test-artifact) and confirmed:
- Cancelled jobs return exit_code 137 (SIGKILL)
- NO artifact field in response (neither job.artifact nor job.result.artifact)
- Artifacts only returned for fully completed jobs

Code still checks for artifacts in case this limitation is fixed
in the future, but currently binary downloads will not work for
cancelled/timeout executions.

To fix: Executor service needs to include compiled binary in
response even when execution is killed (compilation succeeded).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-11 16:34:05 -05:00

1802 lines
71 KiB
HTML

{% extends "base.html" %}
{% block title %}Chatroom{% endblock %}
{% block content %}
<div id="chat-container">
<!-- Chat area where messages will be displayed -->
<div id="chat"></div>
<!-- Form for sending messages -->
<form id="message-form">
<textarea id="message" placeholder="Type your message..."></textarea>
</form>
</div>
<div class="utility-belt">
<div class="download-links">
History
<a href="/download_chat_history?room_name={{ room_name }}" download="{{ room_name }}.json">JSON</a> or
<a href="/download_chat_history_md?room_name={{ room_name }}" download="{{ room_name }}.md">Markdown</a>
</div>
<br>
<div>
<label for="username-input">Username</label>
<input type="text" id="username-input" placeholder="guest" maxlength="50">
</div>
<div>
<label for="model-select">Model</label>
<select id="model-select">
<option value="None">None</option>
</select>
</div>
<div>
<label for="voice-select">Voice</label>
<select id="voice-select">
<!-- Voices will be populated from API -->
</select>
</div>
<div>
<button id="theme-toggle-btn" onclick="toggleTheme()">
🌙 Dark Mode
</button>
</div>
<div>
<button id="auto-play-tts-btn" onclick="toggleAutoPlayTTS()" style="width: 100%; margin-top: 10px; background-color: #f44336; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer;">
Auto-Play TTS: OFF
</button>
</div>
<div id="activity-controls">
<h3>Activities</h3>
<div id="current-activity-info" style="display: none;">
<p>Current Activity: <span id="current-activity-name"></span></p>
<button id="cancel-activity-btn" onclick="cancelActivity()">Cancel Activity</button>
</div>
<div id="activity-list-section">
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 5px; margin-bottom: 5px;">
<select id="activity-select">
<option value="">-- Select an Activity --</option>
</select>
<button id="refresh-activities-btn" onclick="refreshActivityList()">🔄</button>
</div>
<button id="load-activity-btn" onclick="loadSelectedActivity()" style="margin-top: 5px;">Load Activity</button>
</div>
</div>
<div id="user-lists">
<div id="active-users-list">
<h3>Active Users</h3>
<ul id="active-users">
<!-- Active users will be dynamically populated here -->
</ul>
</div>
<div id="inactive-users-list">
<h3>Inactive Users</h3>
<ul id="inactive-users">
<!-- Inactive users will be dynamically populated here -->
</ul>
</div>
</div>
</div>
<script>
// Constants
const API_KEY = "dummy-api-key";
const TTS_API_URL = "https://speech.ai.unturf.com/v1/audio/speech";
const VOICES_API_URL = "https://speech.ai.unturf.com/v1/voices";
const CODE_EXEC_URL = "https://code.ai.unturf.com"; // Code execution service URL (served via Caddy)
const urlParams = new URLSearchParams(window.location.search);
let username = urlParams.get("username") || "guest"; // Default to "guest" if no username in URL
// If no username was in the URL, add it now
if (!urlParams.get("username")) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set("username", username);
window.history.replaceState({}, '', newUrl);
}
const room_name = "{{ room_name }}";
// Configuration for DOMPurify to specify which tags and attributes are allowed
const dompurify_config = {
ADD_TAGS: ["iframe", "img", "video"],
FORBID_TAGS: ["form"],
ALLOWED_ATTR: [
"src", "width", "height", "frameborder", "allowfullscreen",
"alt", "class", "title", "style", "controls",
]
};
// keeping track of scrolling to prevent autoscrolling.
let userHasScrolledUp = false;
let currentAudio = null; // To keep track of the currently playing audio
let currentQueuedAudio = null; // To keep track of currently playing queued TTS audio
let audioCache = {}; // Cache to store audio blobs
// Flag to prevent mutual updates on desktop/mobile
let isSyncingDropdowns = false;
// Auto-play TTS state
let autoPlayTTS = localStorage.getItem('autoPlayTTS') === 'true' || false;
let ttsQueue = [];
let isPlayingTTS = false;
// Function to sanitize the username
function sanitizeUsername(username) {
// Split the username on commas and take the first part.
// The backend denormalizes the user list in the room table via csv.
return username.split(',')[0].trim();
}
// Function to copy message content to clipboard
function copyMessageContent(content) {
navigator.clipboard.writeText(content).then(() => {
// Optional: show a temporary success message
const button = event.currentTarget;
const originalText = button.textContent;
button.textContent = 'Copied!';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}).catch(err => {
console.error('Error copying text: ', err);
});
}
// Function to sync all inputs and update the query string
function syncInputsAndQueryString() {
const usernameInputDesktop = document.getElementById("username-input");
const usernameInputMobile = document.getElementById("username-input-mobile");
const modelSelectDesktop = document.getElementById("model-select");
const voiceSelectDesktop = document.getElementById("voice-select");
const modelSelectMobile = document.getElementById("model-select-mobile");
const voiceSelectMobile = document.getElementById("voice-select-mobile");
// Get current values
const currentUsername = usernameInputDesktop?.value || username || "guest";
const currentModel = modelSelectDesktop.value;
const currentVoice = voiceSelectDesktop.value;
// Update global username variable
username = currentUsername;
const sanitizedUsername = sanitizeUsername(username);
// Sync username inputs
if (usernameInputDesktop) usernameInputDesktop.value = sanitizedUsername;
if (usernameInputMobile) usernameInputMobile.value = sanitizedUsername;
// Sync dropdowns
modelSelectDesktop.value = currentModel;
voiceSelectDesktop.value = currentVoice;
if (modelSelectMobile) modelSelectMobile.value = currentModel;
if (voiceSelectMobile) voiceSelectMobile.value = currentVoice;
// Save to localStorage for persistence
localStorage.setItem('selectedModel', currentModel);
localStorage.setItem('selectedVoice', currentVoice);
// Update URL
const newUrl = new URL(window.location.href);
newUrl.searchParams.set("username", sanitizedUsername);
newUrl.searchParams.set("model", currentModel);
newUrl.searchParams.set("voice", currentVoice);
window.history.replaceState({}, '', newUrl);
// Update room links with new parameters
if (typeof updateRoomLinksWithCurrentParams === 'function') {
updateRoomLinksWithCurrentParams();
}
}
// Backward compatibility
function syncDropdownsAndQueryString() {
syncInputsAndQueryString();
}
document.addEventListener('DOMContentLoaded', (event) => {
const chatContainer = document.getElementById("chat");
const modelSelectDesktop = document.getElementById("model-select");
const voiceSelectDesktop = document.getElementById("voice-select");
const modelSelectMobile = document.getElementById("model-select-mobile");
const voiceSelectMobile = document.getElementById("voice-select-mobile");
// Initialize auto-play TTS button state from localStorage
updateAutoPlayTTSDisplay();
// Function to populate the model dropdown
function populateModelDropdown(models) {
// Clear options starting from index 1 (preserve "None" at index 0)
while (modelSelectDesktop.options.length > 1) {
modelSelectDesktop.remove(1);
}
// Append new model options
models.forEach(modelId => {
const option = document.createElement('option');
option.value = modelId;
option.textContent = modelId;
modelSelectDesktop.appendChild(option);
});
// Set initial value from URL
const urlParams = new URLSearchParams(window.location.search);
const initialModel = urlParams.get("model") || "None";
modelSelectDesktop.value = initialModel;
}
// Function to populate the voice dropdown
function populateVoiceDropdown(voicesData) {
// Clear existing options
voiceSelectDesktop.innerHTML = '';
if (voiceSelectMobile) voiceSelectMobile.innerHTML = '';
// Group voices by model
const voicesByModel = {};
voicesData.data.forEach(modelData => {
const modelId = modelData.id;
voicesByModel[modelId] = modelData.voices || [];
});
// Create optgroups for each model
Object.entries(voicesByModel).forEach(([modelId, voices]) => {
if (voices.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = modelId;
voices.forEach(voice => {
const option = document.createElement('option');
option.value = `${modelId}:${voice}`;
option.textContent = `${modelId} - ${voice}`;
optgroup.appendChild(option);
});
voiceSelectDesktop.appendChild(optgroup);
if (voiceSelectMobile) {
voiceSelectMobile.appendChild(optgroup.cloneNode(true));
}
}
});
// Set initial value from URL, localStorage, or first available option
const urlParams = new URLSearchParams(window.location.search);
const initialVoice = urlParams.get("voice") || localStorage.getItem('selectedVoice') || voiceSelectDesktop.options[0]?.value;
if (initialVoice) {
voiceSelectDesktop.value = initialVoice;
if (voiceSelectMobile) voiceSelectMobile.value = initialVoice;
}
}
// Memoization with localStorage (1-minute cache)
const cacheKey = 'modelList';
const cacheExpirationKey = 'modelListExpiration';
const cacheDuration = 60 * 1000; // 1 minute in milliseconds
const cachedData = localStorage.getItem(cacheKey);
const cachedExpiration = localStorage.getItem(cacheExpirationKey);
if (cachedData && cachedExpiration && Date.now() < parseInt(cachedExpiration)) {
// Use cached data if it exists and hasn't expired
const models = JSON.parse(cachedData);
populateModelDropdown(models);
} else {
// Fetch from backend and update cache
fetch('/models')
.then(response => response.json())
.then(data => {
const models = data.models;
populateModelDropdown(models);
// Store in localStorage with expiration
localStorage.setItem(cacheKey, JSON.stringify(models));
localStorage.setItem(cacheExpirationKey, Date.now() + cacheDuration);
})
.catch(error => console.error("Error fetching models:", error));
}
// Fetch and populate voices with caching
const voicesCacheKey = 'voicesList';
const voicesCacheExpirationKey = 'voicesListExpiration';
const cachedVoices = localStorage.getItem(voicesCacheKey);
const cachedVoicesExpiration = localStorage.getItem(voicesCacheExpirationKey);
if (cachedVoices && cachedVoicesExpiration && Date.now() < parseInt(cachedVoicesExpiration)) {
// Use cached voices data
const voicesData = JSON.parse(cachedVoices);
populateVoiceDropdown(voicesData);
} else {
// Fetch voices from API
fetch(VOICES_API_URL, {
method: 'GET',
headers: {
'Authorization': `Bearer ${API_KEY}`
}
})
.then(response => response.json())
.then(voicesData => {
populateVoiceDropdown(voicesData);
// Store in localStorage with expiration
localStorage.setItem(voicesCacheKey, JSON.stringify(voicesData));
localStorage.setItem(voicesCacheExpirationKey, Date.now() + cacheDuration);
})
.catch(error => {
console.error("Error fetching voices:", error);
// 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;
});
// Load model and voice from localStorage if not in URL
const storedModel = localStorage.getItem('selectedModel');
const storedVoice = localStorage.getItem('selectedVoice');
// Set initial model, voice, and username from URL, localStorage, or defaults
const initialModel = urlParams.get("model") || storedModel || "None";
const initialVoice = urlParams.get("voice") || storedVoice || null;
const initialUsername = username; // Already set to URL param or "guest"
modelSelectDesktop.value = initialModel;
// Voice is set by populateVoiceDropdown after voices are fetched
modelSelectMobile.value = initialModel;
// Set initial username values
const usernameInputDesktop = document.getElementById("username-input");
const usernameInputMobile = document.getElementById("username-input-mobile");
if (usernameInputDesktop) usernameInputDesktop.value = initialUsername;
if (usernameInputMobile) usernameInputMobile.value = initialUsername;
// Initial sync to ensure localStorage and URL are updated with current values
syncInputsAndQueryString();
// Add event listeners for desktop dropdowns
modelSelectDesktop.addEventListener("change", () => {
if (isSyncingDropdowns) return;
isSyncingDropdowns = true;
modelSelectMobile.value = modelSelectDesktop.value;
syncDropdownsAndQueryString();
isSyncingDropdowns = false;
});
voiceSelectDesktop.addEventListener("change", () => {
if (isSyncingDropdowns) return;
isSyncingDropdowns = true;
voiceSelectMobile.value = voiceSelectDesktop.value;
syncDropdownsAndQueryString();
isSyncingDropdowns = false;
});
// Add event listeners for mobile dropdowns
modelSelectMobile.addEventListener("change", () => {
if (isSyncingDropdowns) return;
isSyncingDropdowns = true;
modelSelectDesktop.value = modelSelectMobile.value;
syncDropdownsAndQueryString();
isSyncingDropdowns = false;
});
voiceSelectMobile.addEventListener("change", () => {
if (isSyncingDropdowns) return;
isSyncingDropdowns = true;
voiceSelectDesktop.value = voiceSelectMobile.value;
syncDropdownsAndQueryString();
isSyncingDropdowns = false;
});
// Add event listeners for username inputs
if (usernameInputDesktop) {
usernameInputDesktop.addEventListener("input", () => {
if (isSyncingDropdowns) return;
isSyncingDropdowns = true;
if (usernameInputMobile) {
usernameInputMobile.value = usernameInputDesktop.value;
}
syncInputsAndQueryString();
isSyncingDropdowns = false;
});
usernameInputDesktop.addEventListener("blur", () => {
syncInputsAndQueryString();
});
}
if (usernameInputMobile) {
usernameInputMobile.addEventListener("input", () => {
if (isSyncingDropdowns) return;
isSyncingDropdowns = true;
if (usernameInputDesktop) {
usernameInputDesktop.value = usernameInputMobile.value;
}
syncInputsAndQueryString();
isSyncingDropdowns = false;
});
usernameInputMobile.addEventListener("blur", () => {
syncInputsAndQueryString();
});
}
});
// Socket event when the user connects
socket.on("connect", () => {
// Sanitize the username before joining
const sanitizedUsername = sanitizeUsername(username);
socket.emit("join", {"username": sanitizedUsername, "room_name": room_name});
// Sync inputs and update the query string
syncInputsAndQueryString();
});
// Function to update the active and inactive user lists in the DOM
function updateUserLists(activeUsers, inactiveUsers) {
const activeUserListElement = document.getElementById("active-users");
const inactiveUserListElement = document.getElementById("inactive-users");
const activeUserListElementMobile = document.getElementById("active-users-mobile");
const inactiveUserListElementMobile = document.getElementById("inactive-users-mobile");
activeUserListElement.innerHTML = ''; // Clear the current list
inactiveUserListElement.innerHTML = ''; // Clear the current list
activeUserListElementMobile.innerHTML = ''; // Clear the current list (mobile)
inactiveUserListElementMobile.innerHTML = ''; // Clear the current list (mobile)
// Populate the list with active users (desktop)
activeUsers.forEach(username => {
const userItem = document.createElement("li");
userItem.textContent = username;
activeUserListElement.appendChild(userItem);
});
// Populate the list with inactive users (desktop)
inactiveUsers.forEach(username => {
const userItem = document.createElement("li");
userItem.textContent = username;
inactiveUserListElement.appendChild(userItem);
});
// Populate the list with active users (mobile)
activeUsers.forEach(username => {
const userItemMobile = document.createElement("li");
userItemMobile.textContent = username;
activeUserListElementMobile.appendChild(userItemMobile);
});
// Populate the list with inactive users (mobile)
inactiveUsers.forEach(username => {
const userItemMobile = document.createElement("li");
userItemMobile.textContent = username;
inactiveUserListElementMobile.appendChild(userItemMobile);
});
}
// Update user lists whenever the event is received
socket.on("active_users", (data) => {
updateUserLists(data.active_users, data.inactive_users);
});
// Function to handle sending the message
function sendMessage() {
const messageTextarea = document.getElementById("message");
const message = messageTextarea.value;
const model = document.getElementById("model-select").value;
let messageToSend = message.trim();
if (messageToSend !== "") { // Ensure we're not sending empty messages
socket.emit("chat_message", {
"username": username,
"message": messageToSend,
"model": model, // Pass model as a separate attribute
"room_name": room_name
});
messageTextarea.value = "";
// Reset textarea height after sending
messageTextarea.style.height = 'auto';
}
}
// Function to handle deleting a message
function deleteMessage(messageId, room_name) {
socket.emit("delete_message", {"message_id": messageId, "room_name": room_name});
}
// Event listener for form submission to send a message
document.getElementById("message-form").addEventListener("submit", (e) => {
e.preventDefault();
sendMessage();
});
// Event listener for the Enter key press in the textarea to send a message
document.getElementById("message").addEventListener("keydown", function(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Auto-grow textarea as user types
document.getElementById("message").addEventListener("input", function() {
// Reset height to auto to get the correct scrollHeight
this.style.height = 'auto';
// Set height to scrollHeight to fit content
this.style.height = this.scrollHeight + 'px';
});
// Socket event for updating the room title
socket.on("update_room_title", (data) => {
document.title = data.title; // Update the window's title
});
// Socket event to update the room title in the sidebar.
socket.on('update_room_list', function(updatedRoom) {
// Find the room list item by its data-room-id attribute
const roomListItem = document.querySelector(`#rooms-list li[data-room-id="${updatedRoom.id}"]`);
if (roomListItem) {
// Update the room list item's content with the new title
roomListItem.innerHTML = `<b>${updatedRoom.name}</b> ${updatedRoom.title ? '<br />' + updatedRoom.title : ''}`;
}
});
// 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
const messageContent = data.username ? `**${data.username}:**\n\n${data.content}` : data.content;
// Check if the message starts with a base64 image tag
if (data.content.startsWith('<img src="data:image/jpeg;base64')) {
// Directly assign the message as innerHTML if it starts with a base64 image
newMessage.innerHTML = data.content;
} else {
// Otherwise, sanitize and process the message with marked
newMessage.innerHTML = DOMPurify.sanitize(marked.marked(messageContent), dompurify_config);
}
// Check if the message has an id which means we can delete it.
if (data.id) {
newMessage.dataset.rawMarkdown = data.content;
// Create a container for the buttons
const buttonContainer = document.createElement("div");
buttonContainer.className = "button-container";
// Create the delete button
const deleteButton = document.createElement("button");
deleteButton.innerHTML = "x";
deleteButton.onclick = () => deleteMessage(data.id, room_name);
buttonContainer.appendChild(deleteButton);
// Create the edit button
const editButton = document.createElement("button");
editButton.textContent = "Edit";
editButton.className = "edit-button";
editButton.onclick = () => editMessage(data.id, newMessage, data.content);
buttonContainer.appendChild(editButton);
// Create the copy button
const copyButton = document.createElement("button");
copyButton.textContent = "Copy";
copyButton.className = "copy-button";
copyButton.onclick = () => copyMessageContent(data.content);
buttonContainer.appendChild(copyButton);
// Create the play button for TTS
const playButton = document.createElement("button");
playButton.textContent = "Play";
playButton.onclick = () => speakText(data.content, playButton, data.id);
buttonContainer.appendChild(playButton);
// 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 to show the new message.
if (data.id) {
document.getElementById("chat").scrollTop = document.getElementById("chat").scrollHeight;
// Auto-play TTS if enabled and message has content - AFTER buttons are created
if (autoPlayTTS && data.content && data.content.trim() !== "") {
setTimeout(() => {
// Find the play button after buttons have been created
const buttons = messageWrapper.querySelectorAll("button");
const playButton = Array.from(buttons).find(btn => btn.textContent === "Play");
console.log("Auto-play TTS: enabled=", autoPlayTTS, "content=", data.content, "playButton=", playButton);
if (playButton) {
console.log("Queueing TTS for message:", data.id);
queueTTS(data.content, playButton, data.id);
}
}, 10); // Very short delay to let buttons be created
}
}
});
// Socket event for receiving previous messages
socket.on("previous_messages", (data) => {
if (document.getElementById("message-" + data.id)) {
// If it exists, skip appending it
return;
}
const messageWrapper = document.createElement("div");
messageWrapper.className = "message-wrapper";
messageWrapper.id = "message-" + data.id;
const newMessage = document.createElement("div");
newMessage.className = "message-content";
// Check if the message contains a base64 image
if (data.content.startsWith('<img src="data:image/jpeg;base64')) {
// Directly assign the message as innerHTML if it's a base64 image
newMessage.innerHTML = data.content;
} else {
// Otherwise, sanitize and process the message with marked
newMessage.innerHTML = DOMPurify.sanitize(marked.marked(`**${data.username}:**\n\n${data.content}`), dompurify_config);
}
newMessage.dataset.rawMarkdown = data.content;
// Create a container for the buttons
const buttonContainer = document.createElement("div");
buttonContainer.className = "button-container";
// Create the delete button
const deleteButton = document.createElement("button");
deleteButton.innerHTML = "x";
deleteButton.onclick = () => deleteMessage(data.id, room_name);
buttonContainer.appendChild(deleteButton);
// Create the edit button
const editButton = document.createElement("button");
editButton.textContent = "Edit";
editButton.className = "edit-button";
editButton.onclick = () => editMessage(data.id, newMessage);
buttonContainer.appendChild(editButton);
// Create the copy button
const copyButton = document.createElement("button");
copyButton.textContent = "Copy";
copyButton.className = "copy-button";
copyButton.onclick = () => copyMessageContent(data.content);
buttonContainer.appendChild(copyButton);
// Create the play button for TTS
const playButton = document.createElement("button");
playButton.textContent = "Play";
playButton.onclick = () => speakText(data.content, playButton, data.id);
buttonContainer.appendChild(playButton);
// 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
targetMessageElement.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, 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
messageContentContainer.querySelectorAll("pre code").forEach((block) => {
const wasTruncated = truncateCodeBlock(block);
hljs.highlightElement(block);
addLineNumbers(block);
addCopyButtonToCodeBlock(block, wasTruncated);
});
}
});
// 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);
};
// Add buttons to container
buttonContainer.appendChild(copyButton);
buttonContainer.appendChild(playButton);
// 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);
}
// Clear previous results
resultsContainer.innerHTML = '<div style="color: var(--text-muted);">Executing code...</div>';
try {
// Try to detect language from the code block's class
let language = null;
const classes = blockElement.className.split(' ');
for (const cls of classes) {
if (cls.startsWith('language-')) {
language = cls.replace('language-', '');
break;
}
}
// If no language specified (no class on code block), default to Python
if (!language) {
language = 'python';
}
// Use /execute/async endpoint with polling
const asyncResponse = await fetch(`${CODE_EXEC_URL}/execute/async`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
language: language,
code: code,
return_artifact: true // Request compiled binary for compiled languages
})
});
if (!asyncResponse.ok) {
throw new Error(`HTTP error! status: ${asyncResponse.status}`);
}
const { job_id } = await asyncResponse.json();
// Poll for results: 300ms, 750ms, 1450ms, 2350ms, 3000ms, 4600ms, 6600ms+
const delays = [300, 450, 700, 900, 650, 1600, 2000];
let pollCount = 0;
let cancelButtonShown = false;
// Create cancel button (hidden initially)
let cancelButton = resultsContainer.querySelector('.cancel-execution-btn');
if (!cancelButton) {
cancelButton = document.createElement('button');
cancelButton.textContent = 'Cancel';
cancelButton.classList.add('cancel-execution-btn');
cancelButton.style.display = 'none';
cancelButton.style.marginTop = '8px';
cancelButton.style.padding = '4px 8px';
cancelButton.style.backgroundColor = 'var(--button-danger)';
cancelButton.style.color = 'white';
cancelButton.style.border = 'none';
cancelButton.style.borderRadius = '3px';
cancelButton.style.cursor = 'pointer';
cancelButton.onclick = async () => {
try {
await fetch(`${CODE_EXEC_URL}/jobs/${job_id}`, { method: 'DELETE' });
cancelButton.disabled = true;
cancelButton.textContent = 'Cancelling...';
} catch (error) {
console.error('Error cancelling job:', error);
}
};
resultsContainer.appendChild(cancelButton);
}
while (true) {
await sleep(delays[Math.min(pollCount, delays.length - 1)]);
pollCount++;
const jobResponse = await fetch(`${CODE_EXEC_URL}/jobs/${job_id}`);
if (!jobResponse.ok) {
throw new Error(`Failed to fetch job status: ${jobResponse.status}`);
}
const job = await jobResponse.json();
if (job.status !== 'pending' && job.status !== 'running') {
// Job finished - hide cancel button
if (cancelButton) {
cancelButton.style.display = 'none';
}
if (job.status === 'completed') {
const result = job.result;
displayExecutionResults(result, resultsContainer, language);
break;
}
// timeout or cancelled - display results and artifact if available
const errorMsg = job.result?.error || 'Execution failed';
const partialOutput = job.result?.partial_output;
// CONFIRMED via testing (make test-artifact): Executor service does NOT
// include artifact field in GET /jobs/{id} response for cancelled jobs
// (exit_code 137 = SIGKILL), even when return_artifact=true was requested.
// Artifacts only included for fully completed jobs (exit_code 0).
//
// The code below checks for artifact anyway in case this gets fixed in the
// future, but currently it will always be undefined for cancelled/timeout.
const artifact = job.artifact || job.result?.artifact;
// Build result object that displayExecutionResults can understand
// For timeout/cancelled, partial_output contains the output before timeout
if (job.result) {
const resultForDisplay = {
stdout: partialOutput || job.result.stdout || '',
stderr: job.result.stderr || '',
artifact: artifact
};
displayExecutionResults(resultForDisplay, resultsContainer, language);
// 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 = errorMsg;
resultsContainer.insertBefore(errorDiv, resultsContainer.firstChild);
// Add note if there was partial output
if (partialOutput) {
const partialNote = document.createElement('div');
partialNote.style.color = 'var(--text-muted)';
partialNote.style.fontSize = '12px';
partialNote.style.marginBottom = '8px';
partialNote.textContent = '(Output before timeout/cancellation)';
resultsContainer.insertBefore(partialNote, resultsContainer.children[1]);
}
} else {
// No result object at all, just show error
resultsContainer.innerHTML = `<div style="color: var(--text-error); font-weight: bold;">${escapeHtml(errorMsg)}</div>`;
}
break;
}
// Show cancel button after poll #5 (3000ms) if still running
if (!cancelButtonShown && pollCount === 5) {
cancelButtonShown = true;
cancelButton.style.display = 'inline-block';
}
}
} catch (error) {
console.error('Error executing code:', error);
resultsContainer.innerHTML = `<div style="color: var(--text-error);">Error: ${escapeHtml(error.message)}</div>`;
} finally {
// Reset button state
playButton.textContent = '▶ Run';
playButton.disabled = false;
}
}
// Helper function to sleep
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Helper function to display execution results
function displayExecutionResults(result, resultsContainer, language) {
// Format and display results
let outputHtml = '';
// Handle nested response structure - check if stdout is an object with nested data
let actualStdout = result.stdout;
let actualStderr = result.stderr;
// If stdout is an object (nested response), extract the actual stdout/stderr
if (typeof result.stdout === 'object' && result.stdout !== null) {
actualStdout = result.stdout.stdout || '';
actualStderr = result.stdout.stderr || '';
}
// Show language
if (language) {
outputHtml += `<div style="color: var(--text-info); margin-bottom: 8px;">Language: ${language}</div>`;
}
// Show stdout
if (actualStdout) {
outputHtml += '<div style="color: var(--text-success); font-weight: bold;">Output:</div>';
outputHtml += `<div style="color: var(--text-primary); margin-left: 10px;">${escapeHtml(actualStdout)}</div>`;
}
// Show stderr if present
if (actualStderr) {
outputHtml += '<div style="color: var(--text-error); font-weight: bold; margin-top: 8px;">Errors/Warnings:</div>';
outputHtml += `<div style="color: var(--text-error); margin-left: 10px;">${escapeHtml(actualStderr)}</div>`;
}
// If no output at all
if (!actualStdout && !actualStderr) {
outputHtml += '<div style="color: var(--text-muted);">(No output produced)</div>';
}
resultsContainer.innerHTML = outputHtml;
// Check for compiled binary artifact
if (result.artifact && result.artifact.type === 'base64' && result.artifact.data) {
// Create download binary button
const downloadButton = document.createElement('button');
downloadButton.textContent = '⬇ Download Binary';
downloadButton.style.marginTop = '10px';
downloadButton.style.padding = '6px 12px';
downloadButton.style.backgroundColor = 'var(--button-primary)';
downloadButton.style.color = 'white';
downloadButton.style.border = 'none';
downloadButton.style.borderRadius = '4px';
downloadButton.style.cursor = 'pointer';
downloadButton.style.fontFamily = 'inherit';
downloadButton.style.fontSize = '14px';
downloadButton.onclick = () => {
try {
// 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 = result.artifact.filename || 'compiled_binary';
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);
}
};
resultsContainer.appendChild(downloadButton);
} else if (result.artifact && result.artifact.type === 'error') {
// Show artifact error if present
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 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 %}