diff --git a/Makefile b/Makefile index 7a08317..3fb13b2 100644 --- a/Makefile +++ b/Makefile @@ -201,6 +201,19 @@ clean: find . -name "*.pyc" -delete 2>/dev/null || true find . -name "*.pyo" -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 htmlcov/ 2>/dev/null || true rm -rf .coverage 2>/dev/null || true diff --git a/app.py b/app.py index e38f553..6c6386e 100644 --- a/app.py +++ b/app.py @@ -31,10 +31,12 @@ from sqlalchemy.exc import InvalidRequestError 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["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 db.init_app(app) @@ -233,6 +235,28 @@ def get_models(): 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/") def chat(room_name): # 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): if not messages: return [] @@ -1542,12 +1590,14 @@ def loop_through_steps_until_question( # Check if the current step has a question if "question" in step: - question_content = f"Question: {step['question']}" + question_content = step["question"] translated_question_content = translate_text( question_content, user_language ) 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.commit() @@ -1623,6 +1673,18 @@ def start_activity(room_name, s3_file_path, 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): with app.app_context(): @@ -1656,6 +1718,9 @@ def cancel_activity(room_name, username): room=room_name, ) + # Emit activity status update + socketio.emit("activity_status", {"active": False}, room=room_name) + def display_activity_metadata(room_name, username): with app.app_context(): @@ -2114,7 +2179,7 @@ def handle_activity_response(room_name, user_response, username): if feedback: # feedback is metadata language aware, doesn't need to be translated. 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.commit() @@ -2123,7 +2188,7 @@ def handle_activity_response(room_name, user_response, username): "chat_message", { "id": new_message.id, - "username": "System", + "username": "System (Feedback)", "content": feedback, }, room=room_name, @@ -2199,12 +2264,12 @@ def handle_activity_response(room_name, user_response, username): db.session.commit() # Emit the question again - question_content = f"Question: {step['question']}" + question_content = step["question"] translated_question_content = translate_text( question_content, user_language ) new_message = Message( - username="System", + username="System (Question)", content=translated_question_content, room_id=room.id, ) @@ -2517,7 +2582,7 @@ def provide_feedback( json_metadata, json_new_metadata, ) - feedback += f"\n\nAI Feedback: {ai_feedback}" + feedback += f"\n\n{ai_feedback}" return feedback diff --git a/research/activity29-battleship.yaml b/research/activity29-battleship.yaml index 997275d..caf5753 100644 --- a/research/activity29-battleship.yaml +++ b/research/activity29-battleship.yaml @@ -187,21 +187,22 @@ sections: If the user wants to exit, categorize as 'exit'. Otherwise, categorize as 'invalid_move'. 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: - - If "hit": Describe the impact and explosion - - If "miss": Describe the splash and fog of war - 2. AI's shot result - report where the AI fired: - - 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! + STEP 1 - CHECK SHIP DESTRUCTION (MANDATORY): + Look in the metadata for these exact fields: + - 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!" + - 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!" - 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: | import random diff --git a/templates/base.html b/templates/base.html index 10684ba..d0f5fb7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -243,6 +243,78 @@ .utility-belt { 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 (max-width: 768px) { @@ -296,6 +368,28 @@ +
+ +
+
+

Activities

+ +
+
+ + +
+ +
+
+

Active Users

diff --git a/templates/chat.html b/templates/chat.html index b7168bc..ae62d5f 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -37,6 +37,29 @@
+
+ +
+ +
+

Activities

+ +
+
+ + +
+ +
+
+

Active Users

@@ -83,6 +106,11 @@ 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. @@ -121,6 +149,9 @@ document.addEventListener('DOMContentLoaded', (event) => { 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) { @@ -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) { + 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 @@ -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 toggleAudioPlayback(audio, playButton) { 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. 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); + } + }, 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 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 }); +// 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}); +}); + {% endblock %}