Major improvements to battleship game feedback accuracy and user experience: ## New Multi-Prompt Feedback System - Replaced single feedback with 3 specialized prompts: Shot Report, Ship Status, Game Over - Each prompt has individual metadata filtering to see only relevant data - Shot Report only sees hit/miss data, Ship Status only sees ship destruction data - Added STFU token system to suppress empty messages (filtered out automatically) ## Technical Implementation - Added per-prompt metadata_filter support in YAML structure - Updated app.py and guarded_ai.py to handle prompt-specific filtering - Legacy single-prompt system still works with transition-level filtering - Added comprehensive test suite for feedback system validation ## User Experience Fixes - Fixed TTS queue blocking JavaScript execution (async promises instead of await) - Ship Status now correctly reports who destroyed which ship (role confusion fixed) - Game Over only appears when game actually ends (no more random messages) - Maintained dramatic storytelling while ensuring factual accuracy ## Battleship-Specific Improvements - Ship destruction messages only appear when ships actually sink - Clear separation of concerns: hits/misses vs ship destruction vs game over - Eliminated false positive ship destruction reports - Fixed role reversal where wrong player got credit for destruction The battleship narrator now provides accurate, contextual feedback while preserving the dramatic naval warfare atmosphere.
1170 lines
44 KiB
HTML
1170 lines
44 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" rows="4" 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="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">
|
|
<option value="onyx">Onyx</option>
|
|
<option value="alloy">Alloy</option>
|
|
<option value="echo">Echo</option>
|
|
<option value="fable">Fable</option>
|
|
<option value="nova">Nova</option>
|
|
<option value="shimmer">Shimmer</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<button id="auto-play-tts-btn" onclick="toggleAutoPlayTTS()" style="width: 100%; margin-top: 10px; background-color: #f44336; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer;">
|
|
Auto-Play TTS: OFF
|
|
</button>
|
|
</div>
|
|
|
|
<div id="activity-controls">
|
|
<h3>Activities</h3>
|
|
<div id="current-activity-info" style="display: none;">
|
|
<p>Current Activity: <span id="current-activity-name"></span></p>
|
|
<button id="cancel-activity-btn" onclick="cancelActivity()">Cancel Activity</button>
|
|
</div>
|
|
<div id="activity-list-section">
|
|
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 5px; margin-bottom: 5px;">
|
|
<select id="activity-select">
|
|
<option value="">-- Select an Activity --</option>
|
|
</select>
|
|
<button id="refresh-activities-btn" onclick="refreshActivityList()">🔄</button>
|
|
</div>
|
|
<button id="load-activity-btn" onclick="loadSelectedActivity()" style="margin-top: 5px;">Load Activity</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="user-lists">
|
|
<div id="active-users-list">
|
|
<h3>Active Users</h3>
|
|
<ul id="active-users">
|
|
<!-- Active users will be dynamically populated here -->
|
|
</ul>
|
|
</div>
|
|
<div id="inactive-users-list">
|
|
<h3>Inactive Users</h3>
|
|
<ul id="inactive-users">
|
|
<!-- Inactive users will be dynamically populated here -->
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// Constants
|
|
const API_KEY = "dummy-api-key";
|
|
const TTS_API_URL = "https://speech.ai.unturf.com/v1/audio/speech";
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const username = urlParams.get("username");
|
|
const room_name = "{{ room_name }}";
|
|
|
|
// Global constants for valid voices
|
|
const VALID_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
|
|
|
|
// 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 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 sync dropdowns and update the query string
|
|
function syncDropdownsAndQueryString() {
|
|
const sanitizedUsername = sanitizeUsername(username);
|
|
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");
|
|
|
|
// Determine the current model and voice from any dropdown
|
|
const currentModel = modelSelectDesktop.value;
|
|
const currentVoice = VALID_VOICES.includes(voiceSelectDesktop.value) ? voiceSelectDesktop.value : 'onyx';
|
|
|
|
// Sync both desktop and mobile dropdowns
|
|
modelSelectDesktop.value = currentModel;
|
|
voiceSelectDesktop.value = currentVoice;
|
|
modelSelectMobile.value = currentModel;
|
|
voiceSelectMobile.value = currentVoice;
|
|
|
|
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);
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
chatContainer.addEventListener('scroll', () => {
|
|
const distanceFromBottom = chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight;
|
|
userHasScrolledUp = distanceFromBottom > 5;
|
|
});
|
|
|
|
// Set initial model and voice from query string
|
|
const initialModel = urlParams.get("model") || "None";
|
|
const initialVoice = urlParams.get("voice") || "onyx";
|
|
modelSelectDesktop.value = initialModel;
|
|
voiceSelectDesktop.value = initialVoice;
|
|
modelSelectMobile.value = initialModel;
|
|
voiceSelectMobile.value = initialVoice;
|
|
|
|
// Add event listeners for desktop dropdowns
|
|
modelSelectDesktop.addEventListener("change", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
modelSelectMobile.value = modelSelectDesktop.value;
|
|
syncDropdownsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
|
|
voiceSelectDesktop.addEventListener("change", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
voiceSelectMobile.value = voiceSelectDesktop.value;
|
|
syncDropdownsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
|
|
// Add event listeners for mobile dropdowns
|
|
modelSelectMobile.addEventListener("change", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
modelSelectDesktop.value = modelSelectMobile.value;
|
|
syncDropdownsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
|
|
voiceSelectMobile.addEventListener("change", () => {
|
|
if (isSyncingDropdowns) return;
|
|
isSyncingDropdowns = true;
|
|
voiceSelectDesktop.value = voiceSelectMobile.value;
|
|
syncDropdownsAndQueryString();
|
|
isSyncingDropdowns = false;
|
|
});
|
|
});
|
|
|
|
// Socket event when the user connects
|
|
socket.on("connect", () => {
|
|
// Sanitize the username before joining
|
|
const sanitizedUsername = sanitizeUsername(username);
|
|
socket.emit("join", {"username": sanitizedUsername, "room_name": room_name});
|
|
// Sync dropdowns and update the query string
|
|
syncDropdownsAndQueryString();
|
|
});
|
|
|
|
// 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 message = document.getElementById("message").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
|
|
});
|
|
document.getElementById("message").value = "";
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
});
|
|
|
|
// 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 : ''}`;
|
|
}
|
|
});
|
|
|
|
// 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 voice = document.getElementById("voice-select").value;
|
|
const cacheKey = `${messageId}-${voice}`; // 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];
|
|
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: 'tts-1',
|
|
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 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 voice = document.getElementById("voice-select").value;
|
|
const cacheKey = `${messageId}-${voice}`;
|
|
const cleanText = text.replace(/[^a-zA-Z0-9\s.,!?]/g, '');
|
|
|
|
const playAudio = (audio) => {
|
|
audio.onended = () => {
|
|
console.log("TTS finished for:", messageId);
|
|
resolve();
|
|
};
|
|
audio.onerror = () => {
|
|
console.error("TTS audio error for:", messageId);
|
|
reject(new Error("Audio playback failed"));
|
|
};
|
|
audio.play().catch(reject);
|
|
};
|
|
|
|
// Check if audio is cached
|
|
if (audioCache[cacheKey]) {
|
|
playAudio(audioCache[cacheKey]);
|
|
return;
|
|
}
|
|
|
|
// Fetch new audio
|
|
fetch(TTS_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${API_KEY}`
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'tts-1',
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 play button for TTS
|
|
const playButton = document.createElement("button");
|
|
playButton.textContent = "Play";
|
|
playButton.onclick = () => speakText(data.content, playButton, data.id);
|
|
buttonContainer.appendChild(playButton);
|
|
|
|
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) => {
|
|
addCopyButtonToCodeBlock(block);
|
|
truncateCodeBlock(block);
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
});
|
|
|
|
// 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 play button for TTS
|
|
const playButton = document.createElement("button");
|
|
playButton.textContent = "Play";
|
|
playButton.onclick = () => speakText(data.content, playButton, data.id);
|
|
buttonContainer.appendChild(playButton);
|
|
|
|
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) => {
|
|
addCopyButtonToCodeBlock(block);
|
|
truncateCodeBlock(block);
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
});
|
|
|
|
// 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 for the corresponding message ID
|
|
delete messageBuffers[msg_id];
|
|
});
|
|
|
|
// A dictionary to hold buffers for each message ID
|
|
const messageBuffers = {};
|
|
|
|
// 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")) {
|
|
targetMessageElement = document.createElement("div");
|
|
targetMessageElement.className = "message-content";
|
|
messageWrapper.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] = "";
|
|
}
|
|
|
|
// Append the chunk to the buffer
|
|
messageBuffers[data.id] += data.content;
|
|
|
|
// Process the entire buffer with marked and set it as the content of the target 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
|
|
targetMessageElement.dataset.rawMarkdown = messageBuffers[data.id];
|
|
|
|
// Apply syntax highlighting to code blocks within the content
|
|
targetMessageElement.querySelectorAll("pre code").forEach((block) => {
|
|
addCopyButtonToCodeBlock(block);
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
});
|
|
|
|
// 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 play button for TTS
|
|
const playButton = document.createElement("button");
|
|
playButton.textContent = "Play";
|
|
playButton.onclick = () => {
|
|
const fullText = targetMessageElement.textContent || targetMessageElement.innerText;
|
|
speakText(fullText, playButton, data.id);
|
|
};
|
|
buttonContainer.appendChild(playButton);
|
|
|
|
// Append the button container before the message content
|
|
messageWrapper.insertBefore(buttonContainer, targetMessageElement);
|
|
|
|
// Auto-play TTS if enabled and message is complete (only when streaming finishes)
|
|
if (autoPlayTTS && data.is_complete && messageBuffers[data.id] && messageBuffers[data.id].trim() !== "") {
|
|
const playButton = buttonContainer.querySelector("button");
|
|
if (playButton && playButton.textContent === "Play") {
|
|
setTimeout(() => {
|
|
const fullText = targetMessageElement.textContent || targetMessageElement.innerText;
|
|
queueTTS(fullText, 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) => {
|
|
addCopyButtonToCodeBlock(block);
|
|
truncateCodeBlock(block);
|
|
hljs.highlightElement(block);
|
|
addLineNumbers(block);
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// 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;
|
|
|
|
// Create a button to expand the code block
|
|
const expandButton = document.createElement('button');
|
|
expandButton.textContent = 'Show More';
|
|
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;
|
|
|
|
// Insert the expand button after the code block
|
|
block.parentNode.insertBefore(expandButton, block.nextSibling);
|
|
}
|
|
}
|
|
|
|
|
|
// Modify the addCopyButtonToCodeBlock function to use the full content
|
|
function addCopyButtonToCodeBlock(block) {
|
|
// Check if the full content is stored in a data attribute, otherwise use textContent
|
|
const contentToCopy = block.dataset.fullContent || block.textContent;
|
|
|
|
// Create a button to copy the code block's content
|
|
const copyButton = document.createElement('button');
|
|
copyButton.textContent = 'Copy';
|
|
copyButton.classList.add('copy-button'); // Add a class for styling if needed
|
|
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);
|
|
});
|
|
};
|
|
|
|
// Insert the button before the code block
|
|
block.parentNode.insertBefore(copyButton, block);
|
|
}
|
|
|
|
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 %}
|