* 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>
1802 lines
71 KiB
HTML
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 %}
|