Improve user experience with battleship feedback and auto-play TTS

- Fix battleship feedback perspective confusion with better Hermes prompting
- Add auto-play TTS button with localStorage persistence and queueing system
- Move activity controls below model/voice selectors in sidebar
- Add activity controls to mobile hamburger menu
- Fix model/activity dropdowns to stay within container bounds
- Filter activities API to only show .yaml/.yml files
- Clean up system message labels by moving to usernames (System (Feedback), System (Question))
- Apply black formatting to app.py
This commit is contained in:
Russell Ballestrini 2025-08-11 09:34:29 -04:00
parent 74bb98b517
commit e28dc11f04
5 changed files with 484 additions and 23 deletions

View file

@ -201,6 +201,19 @@ clean:
find . -name "*.pyc" -delete 2>/dev/null || true find . -name "*.pyc" -delete 2>/dev/null || true
find . -name "*.pyo" -delete 2>/dev/null || true find . -name "*.pyo" -delete 2>/dev/null || true
find . -name "*~" -delete 2>/dev/null || true find . -name "*~" -delete 2>/dev/null || true
.PHONY: init-db
init-db:
@echo "🗄️ Initializing database tables..."
@if [ -f vars.sh ]; then \
. ./vars.sh && python init_db.py; \
echo "✅ Database tables created successfully"; \
else \
echo "❌ Error: vars.sh not found. Please create it from vars.sh.sample"; \
exit 1; \
fi
clean-cache:
rm -rf .pytest_cache/ 2>/dev/null || true rm -rf .pytest_cache/ 2>/dev/null || true
rm -rf htmlcov/ 2>/dev/null || true rm -rf htmlcov/ 2>/dev/null || true
rm -rf .coverage 2>/dev/null || true rm -rf .coverage 2>/dev/null || true

83
app.py
View file

@ -31,10 +31,12 @@ from sqlalchemy.exc import InvalidRequestError
from models import db, Room, UserSession, Message, ActivityState from models import db, Room, UserSession, Message, ActivityState
app = Flask(__name__) app = Flask(__name__, instance_relative_config=True)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-key-change-in-production") app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-key-change-in-production")
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///chat.db" app.config["SQLALCHEMY_DATABASE_URI"] = (
f"sqlite:///{os.path.join(app.instance_path, 'chat.db')}"
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app) db.init_app(app)
@ -233,6 +235,28 @@ def get_models():
return jsonify({"models": list(MODEL_CLIENT_MAP.keys())}) return jsonify({"models": list(MODEL_CLIENT_MAP.keys())})
@app.route("/api/activities", methods=["GET"])
def get_activities():
"""Return the list of available activities."""
activities = []
if app.config.get("LOCAL_ACTIVITIES"):
# List local activity files from research directory
import os
research_dir = "research"
if os.path.exists(research_dir):
for filename in sorted(os.listdir(research_dir)):
if filename.endswith((".yaml", ".yml")):
activities.append(f"research/{filename}")
else:
# For S3 activities, you would list from S3
# This is a placeholder - you'd need to implement S3 listing
pass
return jsonify({"activities": activities})
@app.route("/chat/<room_name>") @app.route("/chat/<room_name>")
def chat(room_name): def chat(room_name):
# Query all rooms so that newest is first. # Query all rooms so that newest is first.
@ -651,6 +675,30 @@ def handle_update_message(data):
) )
@socketio.on("get_activity_status")
def handle_get_activity_status(data):
"""Get the current activity status for a room."""
room_name = data["room_name"]
room = get_room(room_name)
if room:
activity_state = ActivityState.query.filter_by(room_id=room.id).first()
if activity_state:
emit(
"activity_status",
{
"active": True,
"activity_name": activity_state.s3_file_path,
"section_id": activity_state.section_id,
"step_id": activity_state.step_id,
},
room=request.sid,
)
else:
emit("activity_status", {"active": False}, room=request.sid)
def group_consecutive_roles(messages): def group_consecutive_roles(messages):
if not messages: if not messages:
return [] return []
@ -1542,12 +1590,14 @@ def loop_through_steps_until_question(
# Check if the current step has a question # Check if the current step has a question
if "question" in step: if "question" in step:
question_content = f"Question: {step['question']}" question_content = step["question"]
translated_question_content = translate_text( translated_question_content = translate_text(
question_content, user_language question_content, user_language
) )
new_message = Message( new_message = Message(
username="System", content=translated_question_content, room_id=room.id username="System (Question)",
content=translated_question_content,
room_id=room.id,
) )
db.session.add(new_message) db.session.add(new_message)
db.session.commit() db.session.commit()
@ -1623,6 +1673,18 @@ def start_activity(room_name, s3_file_path, username):
activity_content, activity_state, room_name, username activity_content, activity_state, room_name, username
) )
# Emit activity status update
socketio.emit(
"activity_status",
{
"active": True,
"activity_name": s3_file_path,
"section_id": initial_section["section_id"],
"step_id": initial_step["step_id"],
},
room=room_name,
)
def cancel_activity(room_name, username): def cancel_activity(room_name, username):
with app.app_context(): with app.app_context():
@ -1656,6 +1718,9 @@ def cancel_activity(room_name, username):
room=room_name, room=room_name,
) )
# Emit activity status update
socketio.emit("activity_status", {"active": False}, room=room_name)
def display_activity_metadata(room_name, username): def display_activity_metadata(room_name, username):
with app.app_context(): with app.app_context():
@ -2114,7 +2179,7 @@ def handle_activity_response(room_name, user_response, username):
if feedback: if feedback:
# feedback is metadata language aware, doesn't need to be translated. # feedback is metadata language aware, doesn't need to be translated.
new_message = Message( new_message = Message(
username="System", content=feedback, room_id=room.id username="System (Feedback)", content=feedback, room_id=room.id
) )
db.session.add(new_message) db.session.add(new_message)
db.session.commit() db.session.commit()
@ -2123,7 +2188,7 @@ def handle_activity_response(room_name, user_response, username):
"chat_message", "chat_message",
{ {
"id": new_message.id, "id": new_message.id,
"username": "System", "username": "System (Feedback)",
"content": feedback, "content": feedback,
}, },
room=room_name, room=room_name,
@ -2199,12 +2264,12 @@ def handle_activity_response(room_name, user_response, username):
db.session.commit() db.session.commit()
# Emit the question again # Emit the question again
question_content = f"Question: {step['question']}" question_content = step["question"]
translated_question_content = translate_text( translated_question_content = translate_text(
question_content, user_language question_content, user_language
) )
new_message = Message( new_message = Message(
username="System", username="System (Question)",
content=translated_question_content, content=translated_question_content,
room_id=room.id, room_id=room.id,
) )
@ -2517,7 +2582,7 @@ def provide_feedback(
json_metadata, json_metadata,
json_new_metadata, json_new_metadata,
) )
feedback += f"\n\nAI Feedback: {ai_feedback}" feedback += f"\n\n{ai_feedback}"
return feedback return feedback

View file

@ -187,21 +187,22 @@ sections:
If the user wants to exit, categorize as 'exit'. If the user wants to exit, categorize as 'exit'.
Otherwise, categorize as 'invalid_move'. Otherwise, categorize as 'invalid_move'.
feedback_tokens_for_ai: | feedback_tokens_for_ai: |
Write battleship feedback from the game's perspective that covers: You are the naval battle narrator. Look at the metadata provided and report what happened.
1. User's shot result - check user_hit_result in metadata: STEP 1 - CHECK SHIP DESTRUCTION (MANDATORY):
- If "hit": Describe the impact and explosion Look in the metadata for these exact fields:
- If "miss": Describe the splash and fog of war - user_sunk_ship_this_round: If this contains a ship name like "Carrier" or "Battleship", say: "💥 SHIP DESTROYED! You have sunk the enemy's [ship name]! The enemy vessel explodes and sinks! Victory!"
2. AI's shot result - report where the AI fired: - ai_sunk_ship_this_round: If this contains a ship name, say: "🔥 YOUR SHIP SUNK! The enemy destroyed your [ship name]! Your vessel burns and sinks!"
- If hit: Describe the damage to the player's ship
- If miss: Describe the near miss and ocean spray
3. CRITICAL: If ai_sunk_ship_this_round contains a ship name, express dismay that the AI destroyed the player's ship in 2 sentences describing the carnage at sea
4. CRITICAL: If user_sunk_ship_this_round contains a ship name, celebrate the player destroying the AI ship in 2 sentences describing the carnage at sea
5. CRITICAL: If game_over is true, announce the victory:
- If user_wins is true: Celebrate the player's total victory with excitement!
- If ai_wins is true: Express dismay at the player's defeat!
Describe the sights and sounds of naval warfare! You are the game system rooting for the player! STEP 2 - REPORT SHOTS:
- Your shot result (user_hit_result): "hit" or "miss"
- Enemy shot result (ai_hit_result): "hit" or "miss"
EXAMPLE RESPONSE FORMAT:
If user_sunk_ship_this_round = "Carrier": "💥 SHIP DESTROYED! You have sunk the enemy's Carrier! [shot details]"
If ai_sunk_ship_this_round = "Destroyer": "🔥 YOUR SHIP SUNK! The enemy destroyed your Destroyer! [shot details]"
Always check the metadata for user_sunk_ship_this_round and ai_sunk_ship_this_round first. These are the most important events to report.
processing_script: | processing_script: |
import random import random

View file

@ -243,6 +243,78 @@
.utility-belt { .utility-belt {
padding: 10px; padding: 10px;
} }
/* Activity controls styling */
#activity-controls {
margin-top: 20px;
padding: 10px;
border-top: 1px solid #e1e1e1;
}
#activity-controls h3 {
margin-top: 0;
margin-bottom: 10px;
}
#current-activity-info {
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
}
#current-activity-info p {
margin: 0 0 10px 0;
}
#activity-controls button {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 16px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 14px;
margin: 4px 2px;
cursor: pointer;
border-radius: 4px;
}
#cancel-activity-btn {
background-color: #f44336;
}
#activity-controls button:hover {
opacity: 0.8;
}
#activity-select {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 5px;
border: 1px solid #e1e1e1;
border-radius: 4px;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Model and voice select dropdowns styling */
#model-select, #voice-select, #model-select-mobile, #voice-select-mobile {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 5px;
border: 1px solid #e1e1e1;
border-radius: 4px;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Media query for mobile devices */ /* Media query for mobile devices */
@media (max-width: 768px) { @media (max-width: 768px) {
@ -296,6 +368,28 @@
<option value="shimmer">Shimmer</option> <option value="shimmer">Shimmer</option>
</select> </select>
</div> </div>
<div id="auto-play-tts-mobile">
<button id="auto-play-tts-btn-mobile" 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-mobile">
<h3>Activities</h3>
<div id="current-activity-info-mobile" style="display: none;">
<p>Current Activity: <span id="current-activity-name-mobile"></span></p>
<button id="cancel-activity-btn-mobile" onclick="cancelActivity()">Cancel Activity</button>
</div>
<div id="activity-list-section-mobile">
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 5px; margin-bottom: 5px;">
<select id="activity-select-mobile" style="width: 100%; max-width: 100%; box-sizing: border-box;">
<option value="">-- Select an Activity --</option>
</select>
<button id="refresh-activities-btn-mobile" onclick="refreshActivityList()">🔄</button>
</div>
<button id="load-activity-btn-mobile" onclick="loadSelectedActivityMobile()" style="margin-top: 5px;">Load Activity</button>
</div>
</div>
<div id="user-lists-mobile"> <div id="user-lists-mobile">
<div id="active-users-list"> <div id="active-users-list">
<h3>Active Users</h3> <h3>Active Users</h3>

View file

@ -37,6 +37,29 @@
<option value="shimmer">Shimmer</option> <option value="shimmer">Shimmer</option>
</select> </select>
</div> </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="user-lists">
<div id="active-users-list"> <div id="active-users-list">
<h3>Active Users</h3> <h3>Active Users</h3>
@ -83,6 +106,11 @@ let audioCache = {}; // Cache to store audio blobs
// Flag to prevent mutual updates on desktop/mobile // Flag to prevent mutual updates on desktop/mobile
let isSyncingDropdowns = false; 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 to sanitize the username
function sanitizeUsername(username) { function sanitizeUsername(username) {
// Split the username on commas and take the first part. // Split the username on commas and take the first part.
@ -121,6 +149,9 @@ document.addEventListener('DOMContentLoaded', (event) => {
const voiceSelectDesktop = document.getElementById("voice-select"); const voiceSelectDesktop = document.getElementById("voice-select");
const modelSelectMobile = document.getElementById("model-select-mobile"); const modelSelectMobile = document.getElementById("model-select-mobile");
const voiceSelectMobile = document.getElementById("voice-select-mobile"); const voiceSelectMobile = document.getElementById("voice-select-mobile");
// Initialize auto-play TTS button state from localStorage
updateAutoPlayTTSDisplay();
// Function to populate the dropdown // Function to populate the dropdown
function populateModelDropdown(models) { function populateModelDropdown(models) {
@ -322,8 +353,9 @@ socket.on('update_room_list', function(updatedRoom) {
} }
}); });
// Function to read text using TTS // Function to read text using TTS (for manual button clicks)
async function speakText(text, playButton, messageId) { async function speakText(text, playButton, messageId) {
console.log("speakText called with:", {text: text, messageId, autoPlay: autoPlayTTS});
const voice = document.getElementById("voice-select").value; const voice = document.getElementById("voice-select").value;
const cacheKey = `${messageId}-${voice}`; // Unique cache key for each message and voice const cacheKey = `${messageId}-${voice}`; // Unique cache key for each message and voice
@ -377,6 +409,121 @@ async function speakText(text, playButton, messageId) {
} }
} }
// 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
async function processNextTTS() {
if (isPlayingTTS || ttsQueue.length === 0) {
return;
}
isPlayingTTS = true;
const { text, playButton, messageId } = ttsQueue.shift();
console.log("Processing TTS from queue:", messageId);
try {
await speakTextQueued(text, playButton, messageId);
} catch (error) {
console.error("TTS error:", error);
}
isPlayingTTS = false;
// Process next item in queue
setTimeout(processNextTTS, 100);
}
// 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());
updateAutoPlayTTSDisplay();
}
// Function to toggle audio playback // Function to toggle audio playback
function toggleAudioPlayback(audio, playButton) { function toggleAudioPlayback(audio, playButton) {
if (currentAudio && currentAudio !== audio) { if (currentAudio && currentAudio !== audio) {
@ -467,6 +614,20 @@ socket.on("chat_message", (data) => {
// Scroll to the bottom of the chat container to show the new message. // Scroll to the bottom of the chat container to show the new message.
if (data.id) { if (data.id) {
document.getElementById("chat").scrollTop = document.getElementById("chat").scrollHeight; 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);
}
}, 100); // Short delay to let buttons be created
}
} }
}); });
@ -630,6 +791,17 @@ socket.on("message_chunk", (data) => {
// Append the button container before the message content // Append the button container before the message content
messageWrapper.insertBefore(buttonContainer, targetMessageElement); 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);
}, 500); // Small delay to let the message render
}
}
} }
}); });
@ -853,5 +1025,121 @@ socket.on("set_background", (data) => {
chat.style.backgroundSize = "auto"; // Ensures the image is not stretched chat.style.backgroundSize = "auto"; // Ensures the image is not stretched
}); });
// 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> </script>
{% endblock %} {% endblock %}