diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,4 +1,4 @@ -# app.py (Merged Version - Fixed Chat Input Clearing) +# app.py (Refactored & Consolidated) import streamlit as st import asyncio import websockets @@ -8,48 +8,50 @@ import os import random import time import hashlib -# from PIL import Image # Keep commented unless needed for image pasting->3D texture? import glob import base64 import io import streamlit.components.v1 as components import edge_tts -# from audio_recorder_streamlit import audio_recorder # Keep commented unless re-adding audio input import nest_asyncio import re import pytz import shutil -# import anthropic # Keep commented unless integrating Claude -# import openai # Keep commented unless integrating OpenAI from PyPDF2 import PdfReader import threading import json import zipfile -# from gradio_client import Client # Keep commented unless integrating ArXiv/Gradio from dotenv import load_dotenv from streamlit_marquee import streamlit_marquee from collections import defaultdict, Counter -import pandas as pd -from streamlit_js_eval import streamlit_js_eval # Still needed for some UI interactions -from PIL import Image # Needed for paste_image_component +import pandas as pd # Still used for fallback CSV load? Keep for now. +from streamlit_js_eval import streamlit_js_eval +from PIL import Image + +# ============================================================================== +# Configuration & Constants +# ============================================================================== # ๐Ÿ› ๏ธ Patch asyncio for nesting nest_asyncio.apply() -# ๐ŸŽจ Page Config (From New App) +# ๐ŸŽจ Page Config st.set_page_config( page_title="๐Ÿค–๐Ÿ—๏ธ Shared World Builder ๐Ÿ†", page_icon="๐Ÿ—๏ธ", layout="wide", - initial_sidebar_state="expanded" # Keep sidebar open initially + initial_sidebar_state="expanded" ) -# --- Constants (Combined & 3D Added) --- -# Chat/User Constants -icons = '๐Ÿค–๐Ÿ—๏ธ๐Ÿ—ฃ๏ธ' # Updated icons +# General Constants +icons = '๐Ÿค–๐Ÿ—๏ธ๐Ÿ—ฃ๏ธ๐Ÿ’พ' Site_Name = '๐Ÿค–๐Ÿ—๏ธ Shared World Builder ๐Ÿ—ฃ๏ธ' START_ROOM = "World Lobby ๐ŸŒ" -FUN_USERNAMES = { # Simplified for clarity, can expand later +MEDIA_DIR = "." # Base directory for general files +STATE_FILE = "user_state.txt" # For remembering username + +# User/Chat Constants +FUN_USERNAMES = { "BuilderBot ๐Ÿค–": "en-US-AriaNeural", "WorldWeaver ๐Ÿ•ธ๏ธ": "en-US-JennyNeural", "Terraformer ๐ŸŒฑ": "en-GB-SoniaNeural", "SkyArchitect โ˜๏ธ": "en-AU-NatashaNeural", "PixelPainter ๐ŸŽจ": "en-CA-ClaraNeural", "VoxelVortex ๐ŸŒช๏ธ": "en-US-GuyNeural", @@ -57,1208 +59,894 @@ FUN_USERNAMES = { # Simplified for clarity, can expand later "BlockBard ๐Ÿงฑ": "en-CA-LiamNeural", "SoundSculptor ๐Ÿ”Š": "en-US-AnaNeural", } EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values())) -FILE_EMOJIS = {"md": "๐Ÿ“", "mp3": "๐ŸŽต", "png": "๐Ÿ–ผ๏ธ", "mp4": "๐ŸŽฅ", "zip": "๐Ÿ“ฆ", "csv":"๐Ÿ“„", "json": "๐Ÿ“„"} - -# 3D World Constants -SAVE_DIR = "saved_worlds" -PLOT_WIDTH = 50.0 -PLOT_DEPTH = 50.0 -CSV_COLUMNS = ['obj_id', 'type', 'pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z', 'rot_order'] -WORLD_STATE_FILE = "world_state.json" # Using JSON for simpler in-memory<->disk state - -# --- Directories (Combined) --- -for d in ["chat_logs", "audio_logs", "audio_cache", SAVE_DIR]: # Added SAVE_DIR - os.makedirs(d, exist_ok=True) - CHAT_DIR = "chat_logs" -MEDIA_DIR = "." # Where general files are saved/served from + +# Audio Constants AUDIO_CACHE_DIR = "audio_cache" AUDIO_DIR = "audio_logs" -STATE_FILE = "user_state.txt" # For remembering username -CHAT_FILE = os.path.join(CHAT_DIR, "global_chat.md") # Used for initial load maybe? -# Removed vote files for simplicity +# World Builder Constants +SAVED_WORLDS_DIR = "saved_worlds" # Directory for MD world files +PLOT_WIDTH = 50.0 # Needed for JS injection +PLOT_DEPTH = 50.0 # Needed for JS injection +WORLD_STATE_FILE_MD_PREFIX = "๐ŸŒ_" # Prefix for world save files -# --- API Keys (Keep placeholder logic) --- +# File Emojis +FILE_EMOJIS = {"md": "๐Ÿ“", "mp3": "๐ŸŽต", "png": "๐Ÿ–ผ๏ธ", "mp4": "๐ŸŽฅ", "zip": "๐Ÿ“ฆ", "json": "๐Ÿ“„"} + +# API Keys (Load from .env or secrets) load_dotenv() -# anthropic_key = os.getenv('ANTHROPIC_API_KEY', st.secrets.get('ANTHROPIC_API_KEY', "")) -# openai_api_key = os.getenv('OPENAI_API_KEY', st.secrets.get('OPENAI_API_KEY', "")) -# openai_client = openai.OpenAI(api_key=openai_api_key) - -# --- Helper Functions (Combined & Adapted) --- - -def format_timestamp_prefix(username=""): - # Using UTC for consistency in logs/filenames across timezones potentially - now = datetime.now(pytz.utc) - # Simplified format - # Added randomness to avoid rare collisions if multiple users save at exact same second - rand_suffix = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=4)) - return f"{now.strftime('%Y%m%d_%H%M%S')}_{username}_{rand_suffix}" - -# --- Performance Timer (Optional, Keep if desired) --- -class PerformanceTimer: - def __init__(self, name): self.name, self.start = name, None - def __enter__(self): self.start = time.time(); return self - def __exit__(self, *args): - duration = time.time() - self.start - if 'operation_timings' not in st.session_state: st.session_state['operation_timings'] = {} - if 'performance_metrics' not in st.session_state: st.session_state['performance_metrics'] = defaultdict(list) - st.session_state['operation_timings'][self.name] = duration - st.session_state['performance_metrics'][self.name].append(duration) - - -# --- 3D World State Management (Adapted from original + WebSocket focus) --- - -# Global structure to hold the current state of the world IN MEMORY -# Use defaultdict for easier adding -# Needs thread safety if accessed by multiple websocket handlers simultaneously. -# For now, relying on Streamlit's single-thread-per-session execution -# and assuming broadcast updates are okay without strict locking for this scale. -# A lock would be needed for production robustness. -world_objects_lock = threading.Lock() # Use lock for modifying the global dict -world_objects = defaultdict(dict) # Holds {obj_id: object_data} - -def load_world_state_from_disk(): - """Loads world state from the JSON file or fallback to CSVs.""" - global world_objects - loaded_count = 0 - print(f"[{time.time():.2f}] Attempting to load world state...") - # Use lock for reading/writing the global dict - with world_objects_lock: - if os.path.exists(WORLD_STATE_FILE): - try: - with open(WORLD_STATE_FILE, 'r') as f: - data = json.load(f) - # Ensure keys are strings if they got saved as ints somehow - world_objects = defaultdict(dict, {str(k): v for k, v in data.items()}) - loaded_count = len(world_objects) - print(f"Loaded {loaded_count} objects from {WORLD_STATE_FILE}") - except json.JSONDecodeError: - print(f"Error reading {WORLD_STATE_FILE}. Falling back to CSVs.") - world_objects = defaultdict(dict) # Reset before loading from CSV - except Exception as e: - print(f"Error loading from {WORLD_STATE_FILE}: {e}. Falling back to CSVs.") - world_objects = defaultdict(dict) # Reset - - # Fallback or initial load from CSVs if JSON fails or doesn't exist - if not world_objects: - print("Loading world state from CSV files...") - # Use the cached CSV loading logic, but populate the global dict - loaded_from_csv = get_all_world_objects_from_csv() # Gets list - for obj in loaded_from_csv: - world_objects[obj['obj_id']] = obj - loaded_count = len(world_objects) - print(f"Loaded {loaded_count} objects from CSVs.") - # Save immediately to JSON for next time - save_world_state_to_disk_internal() # Call internal save that assumes lock is held +# ANTHROPIC_KEY = os.getenv('ANTHROPIC_API_KEY', st.secrets.get('ANTHROPIC_API_KEY', "")) +# OPENAI_KEY = os.getenv('OPENAI_API_KEY', st.secrets.get('OPENAI_API_KEY', "")) + +# Mapping Emojis to Primitive Types +PRIMITIVE_MAP = { + "๐ŸŒณ": "Tree", "๐Ÿ—ฟ": "Rock", "๐Ÿ›๏ธ": "Simple House", "๐ŸŒฒ": "Pine Tree", "๐Ÿงฑ": "Brick Wall", + "๐Ÿ”ต": "Sphere", "๐Ÿ“ฆ": "Cube", "๐Ÿงด": "Cylinder", "๐Ÿฆ": "Cone", "๐Ÿฉ": "Torus", + "๐Ÿ„": "Mushroom", "๐ŸŒต": "Cactus", "๐Ÿ”ฅ": "Campfire", "โญ": "Star", "๐Ÿ’Ž": "Gem", + "๐Ÿ—ผ": "Tower", "๐Ÿšง": "Barrier", "โ›ฒ": "Fountain", "๐Ÿฎ": "Lantern", "ํŒป": "Sign Post" +} - return loaded_count +# ============================================================================== +# Global State & Locks +# ============================================================================== -def save_world_state_to_disk(): - """Saves the current in-memory world state to a JSON file. Acquires lock.""" - with world_objects_lock: - return save_world_state_to_disk_internal() +# Thread lock for accessing shared world state +world_objects_lock = threading.Lock() +# In-memory world state {obj_id: data} - Use defaultdict for convenience +world_objects = defaultdict(dict) +# Set of active WebSocket client IDs +connected_clients = set() -def save_world_state_to_disk_internal(): - """Internal save function - assumes lock is already held.""" - global world_objects - print(f"Saving {len(world_objects)} objects to {WORLD_STATE_FILE}...") +# ============================================================================== +# Utility Functions +# ============================================================================== + +def get_current_time_str(tz='UTC'): + """Gets formatted timestamp string in specified timezone (default UTC).""" try: - with open(WORLD_STATE_FILE, 'w') as f: - # Convert defaultdict back to regular dict for saving - json.dump(dict(world_objects), f, indent=2) - print("World state saved successfully.") - return True + timezone = pytz.timezone(tz) + # Get current datetime localized to the specified timezone + now_aware = datetime.now(timezone) + except pytz.UnknownTimeZoneError: + # Fallback to UTC if timezone is unknown + now_aware = datetime.now(pytz.utc) except Exception as e: - print(f"Error saving world state to {WORLD_STATE_FILE}: {e}") - # Avoid st.error here as it might be called from background thread - return False + # General fallback if timezone localization fails + print(f"Timezone error ({tz}), using UTC. Error: {e}") + now_aware = datetime.now(pytz.utc) -# --- Functions to load from CSVs (kept for initial load/fallback) --- -@st.cache_data(ttl=3600) -def load_plot_metadata(): - """Scans save dir for plot_X*_Z*.csv, sorts, calculates metadata.""" - print(f"[{time.time():.2f}] Loading plot metadata...") - plot_files = [] + return now_aware.strftime('%Y%m%d_%H%M%S') + + +def clean_filename_part(text, max_len=30): + """Cleans a string part for use in a filename.""" + if not isinstance(text, str): text = "invalid_name" + text = re.sub(r'\s+', '_', text) # Replace spaces + text = re.sub(r'[^\w\-.]', '', text) # Keep word chars, hyphen, period + return text[:max_len] + +def run_async(async_func, *args, **kwargs): + """Runs an async function safely from a sync context.""" try: - plot_files = [f for f in os.listdir(SAVE_DIR) if f.endswith(".csv") and f.startswith("plot_X")] - except FileNotFoundError: - print(f"Save directory '{SAVE_DIR}' not found during metadata load.") - return [] + loop = asyncio.get_running_loop() + # Schedule as task if loop is running + return loop.create_task(async_func(*args, **kwargs)) + except RuntimeError: # No running loop + # Run in a new loop (blocks until completion) + try: + return asyncio.run(async_func(*args, **kwargs)) + except Exception as e: + print(f"Error running async func {async_func.__name__} in new loop: {e}") + return None # Indicate error or failure except Exception as e: - print(f"Error listing save directory '{SAVE_DIR}': {e}") - return [] + print(f"Error scheduling async task {async_func.__name__}: {e}") + return None + +def ensure_dir(dir_path): + """Creates directory if it doesn't exist.""" + os.makedirs(dir_path, exist_ok=True) + +# Ensure directories exist on startup +for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]: + ensure_dir(d) + +# ============================================================================== +# World State File Handling (Markdown + JSON) +# ============================================================================== + +def generate_world_save_filename(name="World"): + """Generates a filename for saving world state MD files.""" + timestamp = get_current_time_str() # Use UTC for consistency + clean_name = clean_filename_part(name) + rand_hash = hashlib.md5(str(time.time()).encode() + name.encode()).hexdigest()[:6] # Seed hash + return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_name}_{timestamp}_{rand_hash}.md" + +def parse_world_filename(filename): + """Extracts info from filename if possible, otherwise returns defaults.""" + basename = os.path.basename(filename) + # Ensure prefix and suffix are correct + if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"): + parts = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3].split('_') # Remove prefix and suffix before split + if len(parts) >= 3: # Expecting Name_Timestamp_Hash + timestamp_str = parts[-2] + name = " ".join(parts[:-2]) # Join potentially multiple parts for name + dt_obj = None + try: # Try parsing timestamp + dt_obj = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S') + dt_obj = pytz.utc.localize(dt_obj) # Assume UTC since generated with UTC + except (ValueError, pytz.exceptions.AmbiguousTimeError, pytz.exceptions.NonExistentTimeError): + dt_obj = None # Parsing failed or timezone issue + return {"name": name or "Untitled", "timestamp": timestamp_str, "dt": dt_obj, "filename": filename} + + # Fallback for unknown format or parsing failure + dt_fallback = None + try: + mtime = os.path.getmtime(filename) + dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc) + except Exception: pass # Ignore errors getting mtime + return {"name": basename.replace('.md',''), "timestamp": "Unknown", "dt": dt_fallback, "filename": filename} - parsed_plots = [] - for filename in plot_files: +def save_world_state_to_md(target_filename_base): + """Saves the current in-memory world state to a specific MD file (basename).""" + global world_objects + save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base) + print(f"Acquiring lock to save {len(world_objects)} objects to: {save_path}...") + success = False + with world_objects_lock: + world_data_dict = dict(world_objects) # Create copy inside lock + print(f"Saving {len(world_data_dict)} objects...") + parsed_info = parse_world_filename(target_filename_base) # Use original filename for info + timestamp_save = get_current_time_str() + md_content = f"""# World State: {parsed_info['name']} +* **File Saved:** {timestamp_save} (UTC) +* **Original Timestamp:** {parsed_info['timestamp']} +* **Objects:** {len(world_data_dict)} + +```json +{json.dumps(world_data_dict, indent=2)} +```""" try: - file_path = os.path.join(SAVE_DIR, filename) - # Basic check for empty file before parsing name - if not os.path.exists(file_path) or os.path.getsize(file_path) <= 2: continue - - parts = filename[:-4].split('_') - grid_x = int(parts[1][1:]) - grid_z = int(parts[2][1:]) - plot_name = " ".join(parts[3:]) if len(parts) > 3 else f"Plot ({grid_x},{grid_z})" - parsed_plots.append({ - 'id': filename[:-4], 'filename': filename, - 'grid_x': grid_x, 'grid_z': grid_z, 'name': plot_name, - 'x_offset': grid_x * PLOT_WIDTH, 'z_offset': grid_z * PLOT_DEPTH - }) + ensure_dir(SAVED_WORLDS_DIR) # Ensure dir exists + with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content) + print(f"World state saved successfully to {target_filename_base}") + success = True except Exception as e: - print(f"Warning: Error parsing metadata from filename '{filename}': {e}. Skipping.") - continue - parsed_plots.sort(key=lambda p: (p['grid_x'], p['grid_z'])) - print(f"Found {len(parsed_plots)} valid plot files.") - return parsed_plots - -def load_single_plot_objects_relative(filename): - """Loads objects from a specific CSV file, keeping coordinates relative.""" - file_path = os.path.join(SAVE_DIR, filename) - objects = [] + print(f"Error saving world state to {save_path}: {e}") + # Avoid st.error in potentially non-main thread + return success + +def load_world_state_from_md(filename_base): + """Loads world state from an MD file (basename), updates global state, returns success bool.""" + global world_objects + load_path = os.path.join(SAVED_WORLDS_DIR, filename_base) + print(f"Loading world state from MD file: {load_path}...") + if not os.path.exists(load_path): + st.error(f"World file not found: {filename_base}") + return False + try: - if not os.path.exists(file_path) or os.path.getsize(file_path) == 0: return [] + with open(load_path, 'r', encoding='utf-8') as f: content = f.read() + json_match = re.search(r"```json\s*(\{.*?\})\s*```", content, re.DOTALL | re.IGNORECASE) + if not json_match: st.error(f"Could not find valid JSON block in {filename_base}"); return False - df = pd.read_csv(file_path) - if df.empty: return [] + world_data_dict = json.loads(json_match.group(1)) - # Data Cleaning & Defaulting - if 'obj_id' not in df.columns or df['obj_id'].isnull().any(): - print(f"Warning: Generating missing obj_ids for {filename}") - df['obj_id'] = df['obj_id'].fillna(pd.Series([str(uuid.uuid4()) for _ in range(len(df))])) - df['obj_id'] = df['obj_id'].astype(str) + print(f"Acquiring lock to update world state from {filename_base}...") + with world_objects_lock: + world_objects.clear() # Clear previous state + for k, v in world_data_dict.items(): world_objects[str(k)] = v # Update with loaded data + loaded_count = len(world_objects) + print(f"Loaded {loaded_count} objects from {filename_base}. Lock released.") + st.session_state.current_world_file = filename_base # Track loaded file (basename) + return True - for col in ['type', 'pos_x', 'pos_y', 'pos_z']: - if col not in df.columns: - print(f"Warning: CSV '{filename}' missing essential column '{col}'. Skipping file.") - return [] + except json.JSONDecodeError as e: st.error(f"Invalid JSON found in {filename_base}: {e}"); return False + except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return False - for col, default in [('rot_x', 0.0), ('rot_y', 0.0), ('rot_z', 0.0), ('rot_order', 'XYZ')]: - if col not in df.columns: df[col] = default - df.fillna({'rot_x': 0.0, 'rot_y': 0.0, 'rot_z': 0.0, 'rot_order': 'XYZ'}, inplace=True) +def get_saved_worlds(): + """Scans the saved worlds directory for MD files and parses them.""" + try: + ensure_dir(SAVED_WORLDS_DIR) + world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")) + parsed_worlds = [parse_world_filename(f) for f in world_files] + # Sort by datetime object if available (newest first) + parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True) + return parsed_worlds + except Exception as e: + print(f"Error scanning saved worlds: {e}") + st.error(f"Could not scan saved worlds: {e}") + return [] - for col in ['pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z']: - df[col] = pd.to_numeric(df[col], errors='coerce') - df.dropna(subset=['pos_x', 'pos_y', 'pos_z'], inplace=True) # Drop rows where essential position is invalid +# ============================================================================== +# User State & Session Init +# ============================================================================== - df['type'] = df['type'].astype(str).fillna('Unknown') +def save_username(username): + try: + with open(STATE_FILE, 'w') as f: f.write(username) + except Exception as e: print(f"Failed save username: {e}") - # Convert valid rows to dicts - objects = df[CSV_COLUMNS].to_dict('records') +def load_username(): + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE, 'r') as f: return f.read().strip() + except Exception as e: print(f"Failed load username: {e}") + return None - except pd.errors.EmptyDataError: - pass # Normal for empty files - except FileNotFoundError: - pass # Normal if file doesn't exist yet - except Exception as e: - print(f"Error loading objects from {filename}: {e}") - # Optionally raise or return partial data? For now, return empty on error. - return [] - return objects - - -@st.cache_data(show_spinner="Loading initial world objects from CSVs...") -def get_all_world_objects_from_csv(): - """Loads ALL objects from ALL known plots into world coordinates FROM CSVs.""" - print(f"[{time.time():.2f}] Reloading ALL world objects from CSV files...") - all_objects = {} # Use dict keyed by obj_id for auto-deduplication during load - plots_meta = load_plot_metadata() - for plot in plots_meta: - relative_objects = load_single_plot_objects_relative(plot['filename']) - for obj in relative_objects: - obj_id = obj.get('obj_id') - if not obj_id: continue # Skip objects that failed ID generation/loading - # Convert to world coordinates - world_obj = { - 'obj_id': obj_id, - 'type': obj.get('type', 'Unknown'), - 'position': { - 'x': obj.get('pos_x', 0.0) + plot['x_offset'], - 'y': obj.get('pos_y', 0.0), - 'z': obj.get('pos_z', 0.0) + plot['z_offset'] - }, - 'rotation': { - '_x': obj.get('rot_x', 0.0), - '_y': obj.get('rot_y', 0.0), - '_z': obj.get('rot_z', 0.0), - '_order': obj.get('rot_order', 'XYZ') - } - } - # If obj_id already exists, this will overwrite. Last plot file read wins. - all_objects[obj_id] = world_obj - world_list = list(all_objects.values()) - print(f"Loaded {len(world_list)} total objects from CSVs.") - return world_list - -# --- Session State Init (Combined & Expanded) --- def init_session_state(): + """Initializes Streamlit session state variables.""" defaults = { - # From Chat App 'server_running_flag': False, 'server_instance': None, 'server_task': None, - 'active_connections': defaultdict(dict), # Stores actual websocket objects by ID - 'last_chat_update': 0, 'message_input': "", 'audio_cache': {}, - 'tts_voice': "en-US-AriaNeural", 'chat_history': [], 'marquee_settings': { - "background": "#1E1E1E", "color": "#FFFFFF", "font-size": "14px", - "animationDuration": "20s", "width": "100%", "lineHeight": "35px" - }, - 'enable_audio': True, 'download_link_cache': {}, 'username': None, - 'autosend': False, # Default autosend off for chat - 'last_message': "", 'timer_start': time.time(), - 'last_sent_transcript': "", 'last_refresh': time.time(), - 'auto_refresh': False, # Auto-refresh for chat display? Maybe not needed with WS - 'refresh_rate': 30, - - # From 3D World App (or adapted) - 'selected_object': 'None', # Current building tool - 'initial_world_state_loaded': False, # Flag to load state only once - - # Keep others if needed, removed some for clarity + 'active_connections': defaultdict(dict), 'last_chat_update': 0, 'message_input': "", + 'audio_cache': {}, 'tts_voice': "en-US-AriaNeural", 'chat_history': [], + 'marquee_settings': {"background": "#1E1E1E", "color": "#FFFFFF", "font-size": "14px", "animationDuration": "20s", "width": "100%", "lineHeight": "35px"}, + 'enable_audio': True, 'download_link_cache': {}, 'username': None, 'autosend': False, + 'last_message': "", 'timer_start': time.time(), 'last_sent_transcript': "", + 'last_refresh': time.time(), 'auto_refresh': False, 'refresh_rate': 30, + 'selected_object': 'None', 'initial_world_state_loaded': False, + 'current_world_file': None, # Track loaded world filename (basename) 'operation_timings': {}, 'performance_metrics': defaultdict(list), - 'paste_image_base64': "", # For paste component state tracking + 'paste_image_base64': "", 'new_world_name': "MyWorld" } for k, v in defaults.items(): - if k not in st.session_state: - st.session_state[k] = v - # Ensure nested dicts are present - if 'marquee_settings' not in st.session_state: st.session_state.marquee_settings = defaults['marquee_settings'] - if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict) - -# --- Marquee Helpers (Keep from New App) --- -def update_marquee_settings_ui(): # ... (keep function as is) ... - pass # Placeholder if not immediately needed -def display_marquee(text, settings, key_suffix=""): # ... (keep function as is) ... - pass # Placeholder - -# --- Text & File Helpers (Keep & Adapt from New App) --- -def clean_text_for_tts(text): # ... (keep function as is) ... - # Remove markdown links but keep the text - text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text) - # Remove other potential problematic characters for TTS - text = re.sub(r'[#*!\[\]]+', '', ' '.join(text.split())) - return text[:250] or "No text" # Limit length slightly more - -def generate_filename(prompt, username, file_type="md", title=None): # ... (keep function as is) ... - timestamp = format_timestamp_prefix(username) - # Simplified filename generation - base = clean_text_for_filename(title if title else prompt[:30]) - hash_val = hashlib.md5(prompt.encode()).hexdigest()[:6] - # Ensure file type is added correctly - filename_base = f"{timestamp}_{base}_{hash_val}" - return f"{filename_base}.{file_type}" - - -def clean_text_for_filename(text): # ... (keep function as is) ... - # Replace spaces with underscores, remove invalid chars - text = re.sub(r'\s+', '_', text) - text = re.sub(r'[^\w\-.]', '', text) # Allow underscore, hyphen, period - return text[:50] # Limit length - -def create_file(content, username, file_type="md", save_path=None): # Added explicit save_path + if k not in st.session_state: st.session_state[k] = v + # Ensure complex types initialized correctly + if not isinstance(st.session_state.active_connections, defaultdict): st.session_state.active_connections = defaultdict(dict) + if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = [] + if not isinstance(st.session_state.marquee_settings, dict): st.session_state.marquee_settings = defaults['marquee_settings'] + if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {} + if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {} + +# ============================================================================== +# Audio / TTS / Chat / File Handling Helpers +# ============================================================================== + +# --- Text & File Helpers --- +def clean_text_for_tts(text): + if not isinstance(text, str): return "No text" + text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text) # Remove markdown links, keep text + text = re.sub(r'[#*_`!]', '', text) # Remove some markdown chars + text = ' '.join(text.split()) # Normalize whitespace + return text[:250] or "No text" + +def create_file(content, username, file_type="md", save_path=None): if not save_path: + # Generate filename if specific path not given filename = generate_filename(content, username, file_type) save_path = os.path.join(MEDIA_DIR, filename) # Save to base dir by default - - # Ensure directory exists if path includes one - dir_name = os.path.dirname(save_path) - if dir_name: - os.makedirs(dir_name, exist_ok=True) - + ensure_dir(os.path.dirname(save_path)) # Ensure directory exists try: - with open(save_path, 'w', encoding='utf-8') as f: - f.write(content) - print(f"Created file: {save_path}") - return save_path - except Exception as e: - print(f"Error creating file {save_path}: {e}") - return None - -def get_download_link(file, file_type="mp3"): # ... (keep function as is, ensure FILE_EMOJIS updated) ... - if not file or not os.path.exists(file): return f"File not found: {file}" - # Cache based on file path and modification time to handle updates - cache_key = f"dl_{file}_{os.path.getmtime(file)}" - if cache_key not in st.session_state.get('download_link_cache', {}): - with open(file, "rb") as f: b64 = base64.b64encode(f.read()).decode() - mime_types = {"mp3": "audio/mpeg", "png": "image/png", "mp4": "video/mp4", "md": "text/markdown", "zip": "application/zip", "csv": "text/csv", "json": "application/json"} - link_html = f'{FILE_EMOJIS.get(file_type, "๐Ÿ“„")} Download {os.path.basename(file)}' - # Ensure cache dict exists - if 'download_link_cache' not in st.session_state: st.session_state.download_link_cache = {} - st.session_state.download_link_cache[cache_key] = link_html - return st.session_state.download_link_cache[cache_key] - -def save_username(username): # ... (keep function as is) ... - try: - with open(STATE_FILE, 'w') as f: f.write(username) - except Exception as e: print(f"Failed to save username: {e}") - -def load_username(): # ... (keep function as is) ... - if os.path.exists(STATE_FILE): + with open(save_path, 'w', encoding='utf-8') as f: f.write(content) + print(f"Created file: {save_path}"); return save_path + except Exception as e: print(f"Error creating file {save_path}: {e}"); return None + +def get_download_link(file_path, file_type="md"): + """Generates a base64 download link for a given file.""" + if not file_path or not os.path.exists(file_path): + basename = os.path.basename(file_path) if file_path else "N/A" + return f"Not found: {basename}" + try: mtime = os.path.getmtime(file_path) + except OSError: mtime = 0 + cache_key = f"dl_{file_path}_{mtime}" + if 'download_link_cache' not in st.session_state: st.session_state.download_link_cache = {} + if cache_key not in st.session_state.download_link_cache: try: - with open(STATE_FILE, 'r') as f: return f.read().strip() - except Exception as e: print(f"Failed to load username: {e}") - return None + with open(file_path, "rb") as f: b64 = base64.b64encode(f.read()).decode() + mime_types = {"md": "text/markdown", "mp3": "audio/mpeg", "png": "image/png", "mp4": "video/mp4", "zip": "application/zip", "json": "application/json"} + basename = os.path.basename(file_path) + link_html = f'{FILE_EMOJIS.get(file_type, "๐Ÿ“„")} DL' + st.session_state.download_link_cache[cache_key] = link_html + except Exception as e: + print(f"Error generating DL link for {file_path}: {e}") + return f"Err" + return st.session_state.download_link_cache.get(cache_key, "CacheErr") -# --- Audio Processing (Keep from New App) --- -async def async_edge_tts_generate(text, voice, username): # Simplified args +# --- Audio / TTS --- +async def async_edge_tts_generate(text, voice, username): + """Generates TTS audio using EdgeTTS and caches the result.""" if not text: return None - cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest() # Use hash for cache key - # Ensure audio cache dict exists + # Cache key based on text hash and voice + cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest() if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {} cached_path = st.session_state.audio_cache.get(cache_key) - if cached_path and os.path.exists(cached_path): - # print(f"Using cached audio: {cached_path}") - return cached_path - - text_cleaned = clean_text_for_tts(text) - if not text_cleaned or text_cleaned == "No text": - print("Skipping TTS for empty/cleaned text.") - return None - - filename_base = generate_filename(text_cleaned, username, "mp3") - save_path = os.path.join(AUDIO_DIR, filename_base) - print(f"Generating TTS audio for '{text_cleaned[:30]}...' to {save_path}") + if cached_path and os.path.exists(cached_path): return cached_path + + text_cleaned = clean_text_for_tts(text); + if not text_cleaned or text_cleaned == "No text": return None + filename_base = generate_filename(text_cleaned, username, "mp3"); save_path = os.path.join(AUDIO_DIR, filename_base); + ensure_dir(AUDIO_DIR) try: - communicate = edge_tts.Communicate(text_cleaned, voice) - await communicate.save(save_path) + communicate = edge_tts.Communicate(text_cleaned, voice); await communicate.save(save_path); if os.path.exists(save_path) and os.path.getsize(save_path) > 0: - st.session_state.audio_cache[cache_key] = save_path - return save_path - else: - print(f"Audio file {save_path} failed generation or is empty.") - return None - except edge_tts.exceptions.NoAudioReceived: - print(f"Edge TTS returned no audio for voice {voice}.") - return None - except Exception as e: - print(f"Error during Edge TTS generation: {e}") - return None + st.session_state.audio_cache[cache_key] = save_path; return save_path + else: print(f"Audio file {save_path} failed generation."); return None + except Exception as e: print(f"Edge TTS Error: {e}"); return None -def play_and_download_audio(file_path): # ... (keep function as is) ... +def play_and_download_audio(file_path): + """Displays audio player and download link in Streamlit.""" if file_path and os.path.exists(file_path): try: - st.audio(file_path) - file_type = file_path.split('.')[-1] - st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True) - except Exception as e: - st.error(f"Error displaying audio {file_path}: {e}") - else: - st.warning(f"Audio file not found for playback: {file_path}") - + st.audio(file_path) + file_type = file_path.split('.')[-1] + st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True) + except Exception as e: st.error(f"Audio display error: {e}") + else: st.warning(f"Audio file not found: {os.path.basename(file_path) if file_path else 'N/A'}") -# --- Chat Saving/Loading (Keep & Adapt from New App) --- +# --- Chat --- async def save_chat_entry(username, message, voice, is_markdown=False): - """Saves chat entry to a file and potentially generates audio.""" + """Saves chat entry to a file and session state, generates audio.""" if not message.strip(): return None, None - print(f"Saving chat entry from {username}: {message[:50]}...") - central = pytz.timezone('US/Central') # Or use UTC - timestamp = datetime.now(central).strftime("%Y-%m-%d %H:%M:%S") - entry = f"[{timestamp}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp}] {username} ({voice}):\n```markdown\n{message}\n```" - - # Save to individual file in chat_logs - md_filename_base = generate_filename(message, username, "md") - md_file_path = os.path.join(CHAT_DIR, md_filename_base) - md_file = create_file(entry, username, "md", save_path=md_file_path) - - # Append to session state history for immediate display - # Ensure history exists - if 'chat_history' not in st.session_state: st.session_state.chat_history = [] - st.session_state.chat_history.append(entry) - - # Generate audio (only if enabled) - audio_file = None + timestamp_str = get_current_time_str(); + entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```" + md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base); md_file = create_file(entry, username, "md", save_path=md_file_path) + if 'chat_history' not in st.session_state: st.session_state.chat_history = []; + st.session_state.chat_history.append(entry) # Add to live history + audio_file = None; if st.session_state.get('enable_audio', True): - # Use non-markdown message for TTS - tts_message = message if not is_markdown else message - audio_file = await async_edge_tts_generate(tts_message, voice, username) - if audio_file: - print(f"Generated audio: {audio_file}") - else: - print(f"Failed to generate audio for chat message.") - + tts_message = message # Use original message for TTS + audio_file = await async_edge_tts_generate(tts_message, voice, username) return md_file, audio_file async def load_chat_history(): - """Loads chat history from files in CHAT_DIR if session state is empty.""" - if 'chat_history' not in st.session_state: st.session_state.chat_history = [] - - if not st.session_state.chat_history: - print("Loading chat history from files...") - chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime) - loaded_count = 0 - for f_path in chat_files: + """Loads chat history from files if session state is empty.""" + if 'chat_history' not in st.session_state: st.session_state.chat_history = [] + if not st.session_state.chat_history: + ensure_dir(CHAT_DIR) + print("Loading chat history from files...") + chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime); loaded_count = 0 + temp_history = [] + for f_path in chat_files: + try: + with open(f_path, 'r', encoding='utf-8') as file: temp_history.append(file.read().strip()); loaded_count += 1 + except Exception as e: print(f"Err read chat {f_path}: {e}") + st.session_state.chat_history = temp_history + print(f"Loaded {loaded_count} chat entries from files.") + return st.session_state.chat_history + +# --- File Management --- +def create_zip_of_files(files_to_zip, prefix="Archive"): + if not files_to_zip: st.warning("No files provided to zip."); return None + timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip" + try: + print(f"Creating zip: {zip_name}..."); + with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as z: + for f in files_to_zip: + if os.path.exists(f): z.write(f, os.path.basename(f)) + else: print(f"Skip zip missing: {f}") + print("Zip success."); st.success(f"Created {zip_name}"); return zip_name + except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None + +def delete_files(file_patterns, exclude_files=None): + protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"] + # Dynamically protect all currently saved world files unless explicitly targeted + try: + current_worlds = [os.path.basename(w['filename']) for w in get_saved_worlds()] + protected.extend(current_worlds) + except Exception: pass # Ignore if world listing fails + if exclude_files: protected.extend(exclude_files) + + deleted_count = 0; errors = 0 + for pattern in file_patterns: + pattern_path = os.path.join(MEDIA_DIR, pattern) # Assume relative to app dir + try: + files_to_delete = glob.glob(pattern_path) + if not files_to_delete: continue + for f_path in files_to_delete: + basename = os.path.basename(f_path) + # Check if it's a file and NOT protected + if os.path.isfile(f_path) and basename not in protected: + try: os.remove(f_path); print(f"Deleted: {f_path}"); deleted_count += 1 + except Exception as e: print(f"Failed delete {f_path}: {e}"); errors += 1 + elif os.path.isdir(f_path): print(f"Skipping directory: {f_path}") + except Exception as glob_e: print(f"Err matching {pattern}: {glob_e}"); errors += 1 + msg = f"Deleted {deleted_count} files."; + if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg) + elif deleted_count > 0: st.success(msg) + else: st.info("No matching files found to delete.") + # Clear relevant caches + st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {} + +# --- Image Handling --- +async def save_pasted_image(image, username): + if not image: return None + try: + img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]; timestamp = format_timestamp_prefix(username); filename = f"{timestamp}_pasted_{img_hash}.png"; filepath = os.path.join(MEDIA_DIR, filename) + image.save(filepath, "PNG"); print(f"Pasted image saved: {filepath}"); return filepath + except Exception as e: print(f"Failed image save: {e}"); return None + +def paste_image_component(): + pasted_img = None; img_type = None + with st.form(key="paste_form"): + paste_input = st.text_area("Paste Image Data Here (Ctrl+V)", key="paste_input_area", height=50); submit_button = st.form_submit_button("Paste Image ๐Ÿ“‹") + if submit_button and paste_input and paste_input.startswith('data:image'): try: - with open(f_path, 'r', encoding='utf-8') as file: - st.session_state.chat_history.append(file.read().strip()) - loaded_count += 1 - except Exception as e: - print(f"Error reading chat file {f_path}: {e}") - print(f"Loaded {loaded_count} chat entries from files.") - return st.session_state.chat_history + mime_type = paste_input.split(';')[0].split(':')[1]; base64_str = paste_input.split(',')[1]; img_bytes = base64.b64decode(base64_str); pasted_img = Image.open(io.BytesIO(img_bytes)); img_type = mime_type.split('/')[1] + st.image(pasted_img, caption=f"Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str + except ImportError: st.error("Pillow library needed for image pasting.") + except Exception as e: st.error(f"Img decode err: {e}"); st.session_state.paste_image_base64 = "" + elif submit_button: st.warning("No valid img data."); st.session_state.paste_image_base64 = "" + # Return the image object if successfully pasted and submitted + return pasted_img if submit_button and pasted_img else None + + +# --- PDF Processing --- +# Note: Depends on PyPDF2 and potentially AudioProcessor class if generating audio +class AudioProcessor: + def __init__(self): self.cache_dir=AUDIO_CACHE_DIR; ensure_dir(self.cache_dir); self.metadata=json.load(open(f"{self.cache_dir}/metadata.json", 'r')) if os.path.exists(f"{self.cache_dir}/metadata.json") else {} + def _save_metadata(self): + try: + with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f, indent=2) + except Exception as e: print(f"Failed metadata save: {e}") + async def create_audio(self, text, voice='en-US-AriaNeural'): + cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=f"{self.cache_dir}/{cache_key}.mp3" + if cache_key in self.metadata and os.path.exists(cache_path): return cache_path + text_cleaned=clean_text_for_tts(text); + if not text_cleaned: return None + ensure_dir(os.path.dirname(cache_path)) + try: + communicate=edge_tts.Communicate(text_cleaned,voice); await communicate.save(cache_path) + if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0: + self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text_cleaned), 'voice': voice}; self._save_metadata() + return cache_path + else: return None + except Exception as e: print(f"TTS Create Audio Error: {e}"); return None + +def process_pdf_tab(pdf_file, max_pages, voice): + st.subheader("PDF Processing") + if pdf_file is None: + st.info("Upload a PDF file to begin.") + return + audio_processor = AudioProcessor() # Instance for this run + try: + reader=PdfReader(pdf_file); total_pages=min(len(reader.pages),max_pages); + st.write(f"Processing first {total_pages} pages of '{pdf_file.name}'...") + texts, audios={}, {}; page_threads = []; results_lock = threading.Lock() + + def process_page_sync(page_num, page_text): + # Runs async audio generation in a separate thread + async def run_async_audio(): return await audio_processor.create_audio(page_text, voice) + try: audio_path = asyncio.run(run_async_audio()); + if audio_path: + with results_lock: audios[page_num] = audio_path + except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}") + + # Start threads + for i in range(total_pages): + text=reader.pages[i].extract_text(); + if text: texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start() + else: texts[i] = "[No text extracted]" + # Display results as they become available (or after join) + st.progress(0) # Placeholder for progress + # Wait for threads and display - consider using st.empty() for updates + for thread in page_threads: thread.join() + + for i in range(total_pages): + with st.expander(f"Page {i+1}"): + st.markdown(texts.get(i, "[Error getting text]")) + audio_file = audios.get(i) + if audio_file: play_and_download_audio(audio_file) + else: st.caption("Audio generation failed or pending.") -# --- WebSocket Handling (Adapted for 3D State & Thread Safety) --- -# Global set to track connected client IDs for efficient broadcast checks -connected_clients = set() # Holds client_id strings + except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e) + + +# ============================================================================== +# WebSocket Server Logic +# ============================================================================== async def register_client(websocket): - """Adds client to tracking structures.""" - client_id = str(websocket.id) - connected_clients.add(client_id) - st.session_state.active_connections[client_id] = websocket # Store WS object itself - print(f"Client registered: {client_id}. Total: {len(connected_clients)}") + client_id = str(websocket.id); connected_clients.add(client_id); + if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict); + st.session_state.active_connections[client_id] = websocket; print(f"Client registered: {client_id}. Total: {len(connected_clients)}") async def unregister_client(websocket): - """Removes client from tracking structures.""" - client_id = str(websocket.id) - connected_clients.discard(client_id) - st.session_state.active_connections.pop(client_id, None) + client_id = str(websocket.id); connected_clients.discard(client_id); + if 'active_connections' in st.session_state: st.session_state.active_connections.pop(client_id, None); print(f"Client unregistered: {client_id}. Remaining: {len(connected_clients)}") +async def send_safely(websocket, message, client_id): + """Wrapper to send message and handle potential connection errors.""" + try: await websocket.send(message) + except websockets.ConnectionClosed: print(f"WS Send failed (Closed) client {client_id}"); raise + except RuntimeError as e: print(f"WS Send failed (Runtime {e}) client {client_id}"); raise + except Exception as e: print(f"WS Send failed (Other {e}) client {client_id}"); raise -async def websocket_handler(websocket, path): - await register_client(websocket) - client_id = str(websocket.id) - username = st.session_state.get('username', f"User_{client_id[:4]}") # Get username associated with this session - - # Send initial world state to the new client - try: - with world_objects_lock: # Read lock for initial state - initial_state_payload = dict(world_objects) - - initial_state_msg = json.dumps({ - "type": "initial_state", - "payload": initial_state_payload # Send current world state - }) - await websocket.send(initial_state_msg) - print(f"Sent initial state ({len(initial_state_payload)} objects) to {client_id}") - - # Announce join to others - await broadcast_message(json.dumps({ - "type": "user_join", - "payload": {"username": username, "id": client_id} # Send assigned username - }), exclude_id=client_id) - - except Exception as e: - print(f"Error during initial phase for {client_id}: {e}") +async def broadcast_message(message, exclude_id=None): + """Sends a message to all connected clients except the excluded one.""" + if not connected_clients: return + tasks = []; current_client_ids = list(connected_clients); active_connections_copy = st.session_state.active_connections.copy() + for client_id in current_client_ids: + if client_id == exclude_id: continue + websocket = active_connections_copy.get(client_id) + if websocket: tasks.append(asyncio.create_task(send_safely(websocket, message, client_id))) + if tasks: await asyncio.gather(*tasks, return_exceptions=True) # Gather results/exceptions + +async def broadcast_world_update(): + """Broadcasts the current world state to all clients.""" + with world_objects_lock: current_state_payload = dict(world_objects) + update_msg = json.dumps({"type": "initial_state", "payload": current_state_payload}) # Force full refresh + print(f"Broadcasting full world update ({len(current_state_payload)} objects)...") + await broadcast_message(update_msg) - # Main message loop - try: +async def websocket_handler(websocket, path): + """Handles WebSocket connections and messages.""" + await register_client(websocket); client_id = str(websocket.id); + username = st.session_state.get('username', f"User_{client_id[:4]}") # Get username for this session + try: # Send initial state + with world_objects_lock: initial_state_payload = dict(world_objects) + initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload}); await websocket.send(initial_state_msg) + print(f"Sent initial state ({len(initial_state_payload)} objs) to {client_id}") + await broadcast_message(json.dumps({"type": "user_join", "payload": {"username": username, "id": client_id}}), exclude_id=client_id) + except Exception as e: print(f"Error initial phase {client_id}: {e}") + + try: # Message loop async for message in websocket: try: - data = json.loads(message) - msg_type = data.get("type") - payload = data.get("payload", {}) # Ensure payload is a dict - - # Get username from payload (client should send it), fallback to initial session username + data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {}); + # Use username from payload if provided, otherwise session username sender_username = payload.get("username", username) if msg_type == "chat_message": - chat_text = payload.get('message', '') - print(f"Received chat from {sender_username} ({client_id}): {chat_text[:50]}...") - voice = payload.get('voice', FUN_USERNAMES.get(sender_username, "en-US-AriaNeural")) - # Save chat locally (run in background task) - asyncio.create_task(save_chat_entry(sender_username, chat_text, voice)) - # Broadcast chat message (including sender info) to others + chat_text = payload.get('message', ''); voice = payload.get('voice', FUN_USERNAMES.get(sender_username, "en-US-AriaNeural")); + # Schedule save/TTS, but broadcast immediately + run_async(save_chat_entry, sender_username, chat_text, voice) # Fire and forget save await broadcast_message(message, exclude_id=client_id) # Forward original msg elif msg_type == "place_object": - obj_data = payload.get("object_data") + obj_data = payload.get("object_data"); if obj_data and 'obj_id' in obj_data and 'type' in obj_data: - print(f"Received place_object from {sender_username} ({client_id}): {obj_data.get('type')} ({obj_data['obj_id']})") - with world_objects_lock: # Lock for write - world_objects[obj_data['obj_id']] = obj_data # Add/update in memory - # Broadcast placement to others (include who placed it) - broadcast_payload = json.dumps({ - "type": "object_placed", - "payload": {"object_data": obj_data, "username": sender_username} - }) + with world_objects_lock: world_objects[obj_data['obj_id']] = obj_data + broadcast_payload = json.dumps({"type": "object_placed", "payload": {"object_data": obj_data, "username": sender_username}}); await broadcast_message(broadcast_payload, exclude_id=client_id) - # Trigger periodic save maybe? Or add to a "dirty" queue - else: - print(f"Invalid place_object payload from {client_id}: {payload}") + else: print(f"WS Invalid place_object payload: {payload}") elif msg_type == "delete_object": - obj_id = payload.get("obj_id") + obj_id = payload.get("obj_id"); removed = False if obj_id: - print(f"Received delete_object from {sender_username} ({client_id}): {obj_id}") - removed = False - with world_objects_lock: # Lock for write - if obj_id in world_objects: - del world_objects[obj_id] - removed = True + with world_objects_lock: + if obj_id in world_objects: del world_objects[obj_id]; removed = True if removed: - # Broadcast deletion - broadcast_payload = json.dumps({ - "type": "object_deleted", - "payload": {"obj_id": obj_id, "username": sender_username} - }) + broadcast_payload = json.dumps({"type": "object_deleted", "payload": {"obj_id": obj_id, "username": sender_username}}); await broadcast_message(broadcast_payload, exclude_id=client_id) - else: - print(f"Invalid delete_object payload from {client_id}: {payload}") + else: print(f"WS Invalid delete_object payload: {payload}") elif msg_type == "player_position": - # Basic position broadcasting (no server-side validation yet) pos_data = payload.get("position") + rot_data = payload.get("rotation") # Optionally include rotation if pos_data: - broadcast_payload = json.dumps({ - "type": "player_moved", - "payload": {"username": sender_username, "id": client_id, "position": pos_data} - }) + broadcast_payload = json.dumps({"type": "player_moved", "payload": {"username": sender_username, "id": client_id, "position": pos_data, "rotation": rot_data}}); await broadcast_message(broadcast_payload, exclude_id=client_id) - # Add handlers for other types (request_save, etc.) + # Add more handlers here - except json.JSONDecodeError: - print(f"Received invalid JSON from {client_id}: {message[:100]}...") # Log truncated message - except Exception as e: - print(f"Error processing message from {client_id}: {e}") - # Optionally send error back to client? - # await websocket.send(json.dumps({"type": "error", "payload": {"message": str(e)}})) - - except websockets.ConnectionClosedOK: - print(f"Client disconnected normally: {client_id} ({username})") - except websockets.ConnectionClosedError as e: - print(f"Client connection closed with error: {client_id} ({username}) - {e}") - except Exception as e: - print(f"Unexpected error in handler for {client_id}: {e}") # Catch broader errors + except json.JSONDecodeError: print(f"WS Invalid JSON from {client_id}: {message[:100]}...") + except Exception as e: print(f"WS Error processing msg from {client_id}: {e}") + except websockets.ConnectionClosed: print(f"WS Client disconnected: {client_id} ({username})") + except Exception as e: print(f"WS Unexpected handler error {client_id}: {e}") finally: - # Announce leave to others - await broadcast_message(json.dumps({ - "type": "user_leave", - "payload": {"username": username, "id": client_id} - }), exclude_id=client_id) # Exclude self just in case - await unregister_client(websocket) # Ensure cleanup - - -# Modified broadcast to use the global set and skip sender -async def broadcast_message(message, exclude_id=None): - """Sends a message to all connected clients except the excluded one.""" - if not connected_clients: - return # No one to send to - - # Create list of tasks for sending concurrently - tasks = [] - # Iterate over client IDs currently known - current_client_ids = list(connected_clients) # Copy to avoid modification issues during iteration - - for client_id in current_client_ids: - if client_id == exclude_id: - continue - - websocket = st.session_state.active_connections.get(client_id) # Get WS object from session state dict - if websocket: - # Schedule the send operation as a task - tasks.append(asyncio.create_task(send_safely(websocket, message, client_id))) - else: - # If websocket object not found, mark client for potential cleanup - print(f"Websocket object not found for client {client_id} during broadcast.") - # No immediate cleanup here, rely on handler's finally block or periodic checks - - # Wait for all send tasks to complete (or fail) - if tasks: - results = await asyncio.gather(*tasks, return_exceptions=True) - # Process results to find failed sends and potentially clean up - disconnected_after_send = set() - for i, result in enumerate(results): - if isinstance(result, Exception): - # Identify which client failed based on task order (assuming order is preserved) - failed_client_id = current_client_ids[i] # This assumes no skips matched exclude_id - needs refinement if exclude_id used - # To be robust, the task itself should return the client_id on failure - print(f"Error sending message during broadcast: {result}") - # Mark for potential cleanup (or let handler handle it) - # disconnected_after_send.add(failed_client_id) - - # Perform cleanup based on failed sends if needed - # if disconnected_after_send: - # for client_id in disconnected_after_send: - # connected_clients.discard(client_id) - # st.session_state.active_connections.pop(client_id, None) - - -async def send_safely(websocket, message, client_id): - """Wrapper to send message and handle potential connection errors.""" - try: - await websocket.send(message) - except websockets.ConnectionClosed: - print(f"Send failed: Connection closed for client {client_id}") - # Don't unregister here, let the main handler loop do it - raise # Re-raise exception for gather to catch - except RuntimeError as e: # Handle loop closed errors - print(f"Send failed: RuntimeError for client {client_id}: {e}") - raise - except Exception as e: - print(f"Send failed: Unexpected error for client {client_id}: {e}") - raise + await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id); + await unregister_client(websocket) async def run_websocket_server(): - # Check if already running - basic flag protection - if st.session_state.get('server_running_flag', False): - print("Server flag indicates already running or starting.") - return - st.session_state['server_running_flag'] = True - print("Attempting to start WebSocket server on 0.0.0.0:8765...") - stop_event = asyncio.Event() # For potential graceful shutdown later - st.session_state['websocket_stop_event'] = stop_event + """Coroutine to run the WebSocket server.""" + if st.session_state.get('server_running_flag', False): return + st.session_state['server_running_flag'] = True; print("Attempting start WS server 0.0.0.0:8765...") + stop_event = asyncio.Event(); st.session_state['websocket_stop_event'] = stop_event + server = None try: - # Use 0.0.0.0 for broader access (requires firewall config) - server = await websockets.serve(websocket_handler, "0.0.0.0", 8765) - st.session_state['server_instance'] = server # Store server instance - print(f"WebSocket server started successfully on {server.sockets[0].getsockname()}.") - await stop_event.wait() # Keep server running until stop event is set - except OSError as e: - print(f"### FAILED TO START WEBSOCKET SERVER: {e}") - st.error(f"Failed start WebSocket: {e}. Port 8765 busy?") - except Exception as e: - print(f"### UNEXPECTED ERROR IN WEBSOCKET SERVER: {e}") - st.error(f"WebSocket server error: {e}") + server = await websockets.serve(websocket_handler, "0.0.0.0", 8765); st.session_state['server_instance'] = server + print(f"WS server started: {server.sockets[0].getsockname()}. Waiting for stop signal...") + await stop_event.wait() # Keep running + except OSError as e: print(f"### FAILED START WS SERVER: {e}"); st.session_state['server_running_flag'] = False; + except Exception as e: print(f"### UNEXPECTED WS SERVER ERROR: {e}"); st.session_state['server_running_flag'] = False; finally: - print("WebSocket server task loop finished.") - if 'server_instance' in st.session_state and st.session_state.server_instance: - st.session_state.server_instance.close() - await st.session_state.server_instance.wait_closed() - print("WebSocket server closed.") - st.session_state['server_running_flag'] = False - st.session_state['server_instance'] = None - st.session_state['websocket_stop_event'] = None - + print("WS server task finishing..."); + if server: server.close(); await server.wait_closed(); print("WS server closed.") + st.session_state['server_running_flag'] = False; st.session_state['server_instance'] = None; st.session_state['websocket_stop_event'] = None def start_websocket_server_thread(): - """Starts the WebSocket server in a separate thread.""" - if st.session_state.get('server_task') and st.session_state.server_task.is_alive(): - print("Server thread check: Already running.") - return - if st.session_state.get('server_running_flag', False): - print("Server flag check: Already running.") - return - - print("Creating and starting new server thread.") - # Ensure a new loop is created and set for this thread - def run_server_loop(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(run_websocket_server()) - finally: - loop.close() - print("Server thread asyncio loop closed.") - - st.session_state.server_task = threading.Thread(target=run_server_loop, daemon=True) - st.session_state.server_task.start() - time.sleep(1) # Give thread a moment to initialize - print(f"Server thread started. Alive: {st.session_state.server_task.is_alive()}") - - -# --- PDF to Audio (Keep if desired, maybe in a separate tab?) --- -class AudioProcessor: # ... (keep class as is) ... - def __init__(self): self.cache_dir=AUDIO_CACHE_DIR; os.makedirs(self.cache_dir,exist_ok=True); self.metadata=json.load(open(f"{self.cache_dir}/metadata.json", 'r')) if os.path.exists(f"{self.cache_dir}/metadata.json") else {} - def _save_metadata(self): #... (save logic) ... - try: - with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f, indent=2) - except Exception as e: print(f"Failed metadata save: {e}") - async def create_audio(self, text, voice='en-US-AriaNeural'): # ... (audio creation logic) ... - cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=f"{self.cache_dir}/{cache_key}.mp3" - if cache_key in self.metadata and os.path.exists(cache_path): return cache_path - text_cleaned=clean_text_for_tts(text); - if not text_cleaned: return None - # Ensure dir exists before saving - os.makedirs(os.path.dirname(cache_path), exist_ok=True) - try: - communicate=edge_tts.Communicate(text_cleaned,voice); await communicate.save(cache_path) - self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text_cleaned), 'voice': voice}; self._save_metadata() - return cache_path - except Exception as e: - print(f"TTS Create Audio Error: {e}") - return None - - -def process_pdf(pdf_file, max_pages, voice, audio_processor): # ... (keep function as is) ... - try: - reader=PdfReader(pdf_file); total_pages=min(len(reader.pages),max_pages); texts,audios={}, {} - page_threads = [] - results_lock = threading.Lock() # Lock for updating shared audios dict - - def process_page_sync(page_num, page_text): - # Run the async function in a new event loop for this thread - async def run_async_audio(): - return await audio_processor.create_audio(page_text, voice) - try: - audio_path = asyncio.run(run_async_audio()) - if audio_path: - with results_lock: - audios[page_num] = audio_path - except Exception as page_e: - print(f"Error processing page {page_num+1} audio: {page_e}") - - for i in range(total_pages): - text=reader.pages[i].extract_text() - if text: # Only process pages with text - texts[i]=text - thread = threading.Thread(target=process_page_sync, args=(i, text)) - page_threads.append(thread) - thread.start() - else: texts[i] = "[No text extracted]" + """Starts the WebSocket server in a separate thread if not already running.""" + if st.session_state.get('server_task') and st.session_state.server_task.is_alive(): return + if st.session_state.get('server_running_flag', False): return + print("Creating/starting new server thread."); + def run_loop(): + loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop) + try: loop.run_until_complete(run_websocket_server()) + finally: loop.close(); print("Server thread loop closed.") + st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5) + if not st.session_state.server_task.is_alive(): print("### Server thread failed to stay alive!") + + +# ============================================================================== +# Streamlit UI Layout +# ============================================================================== + +def render_sidebar(): + """Renders the Streamlit sidebar contents.""" + with st.sidebar: + st.header("๐Ÿ’พ World Versions") + st.caption("Load or save named world states.") + + # World Selector + saved_worlds = get_saved_worlds() + # Create display text mapping filename to formatted string + world_options = {w['filename']: f"{w['name']} ({w['timestamp']})" for w in saved_worlds} + # Get the currently selected filename (basename) from session state + current_selection_basename = st.session_state.get('current_world_file', None) + + # Prepare options for radio button: None first, then filenames + radio_options_list = [None] + [w['filename'] for w in saved_worlds] # Store full path initially? No, use basename. + radio_options_basenames = [None] + [os.path.basename(w['filename']) for w in saved_worlds] + + # Find the index of the current selection in the options list + current_radio_index = 0 # Default to "Live State" + if current_selection_basename and current_selection_basename in radio_options_basenames: + try: + current_radio_index = radio_options_basenames.index(current_selection_basename) + except ValueError: + current_radio_index = 0 # Fallback if filename somehow not in list + + # Display radio buttons + selected_basename = st.radio( + "Load World:", + options=radio_options_basenames, + index=current_radio_index, + format_func=lambda x: "Live State (Unsaved)" if x is None else world_options.get(os.path.join(SAVED_WORLDS_DIR, x), x), # Format using full path to get name/time? complex + key="world_selector_radio" + ) - # Wait for all threads to complete - for thread in page_threads: - thread.join() - - return texts, audios, total_pages - except Exception as pdf_e: - st.error(f"Error reading PDF: {pdf_e}") - return {}, {}, 0 - - -# --- ArXiv/AI Lookup (Commented out for focus) --- -# def parse_arxiv_refs(...): pass -# def generate_5min_feature_markdown(...): pass -# async def create_paper_audio_files(...): pass -# async def perform_ai_lookup(...): pass -# async def perform_claude_search(...): pass -# async def perform_arxiv_search(...): pass - -# --- Image Handling (Keep basic save, comment out Claude processing) --- -async def save_pasted_image(image, username): # Simplified - img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8] - # Add check against existing hashes if needed: if img_hash in st.session_state.image_hashes: return None - timestamp = format_timestamp_prefix(username) - filename = f"{timestamp}_pasted_{img_hash}.png" - filepath = os.path.join(MEDIA_DIR, filename) # Save in base dir - try: - image.save(filepath, "PNG") - print(f"Pasted image saved: {filepath}") - # Optionally announce image paste via chat? - # await save_chat_entry(username, f"Pasted an image: {filename}", FUN_USERNAMES.get(username, "en-US-AriaNeural")) - return filepath - except Exception as e: - print(f"Failed image save: {e}") - return None - -# --- Zip and Delete Files (Keep from New App) --- -def create_zip_of_files(files, prefix="Archive", query=""): # Simplified args - if not files: - st.warning("No files selected to zip.") - return None - timestamp = format_timestamp_prefix("Zip") # Generic timestamp - zip_name = f"{prefix}_{timestamp}.zip" - try: - print(f"Creating zip: {zip_name} with {len(files)} files...") - with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as z: # Use compression - for f in files: - if os.path.exists(f): - z.write(f, os.path.basename(f)) # Use basename in archive - else: - print(f"Skipping non-existent file for zipping: {f}") - print("Zip creation successful.") - st.success(f"Created {zip_name}") - return zip_name - except Exception as e: - print(f"Zip creation failed: {e}") - st.error(f"Zip creation failed: {e}") - return None - -def delete_files(file_patterns, exclude_files=None): # Takes list of patterns - # Define core protected files - protected = [STATE_FILE, WORLD_STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"] - # Add user-provided exclusions - if exclude_files: - protected.extend(exclude_files) - - deleted_count = 0 - errors = 0 - for pattern in file_patterns: - # Expand pattern relative to current directory - pattern_path = os.path.join(MEDIA_DIR, pattern) # Assume MEDIA_DIR is current dir '.' - print(f"Attempting to delete files matching: {pattern_path}") - try: - files_to_delete = glob.glob(pattern_path) - if not files_to_delete: - print(f"No files found for pattern: {pattern}") - continue + # Handle selection change + if selected_basename != current_selection_basename: + st.session_state.current_world_file = selected_basename # Store basename + if selected_basename: + with st.spinner(f"Loading {selected_basename}..."): + if load_world_state_from_md(selected_basename): + run_async(broadcast_world_update) # Broadcast new state + st.toast("World loaded!", icon="โœ…") + else: + st.error("Failed to load world."); st.session_state.current_world_file = None # Reset on failure + else: + print("Switched to live state.") + # Optionally clear world or just stop tracking file? Stop tracking. + # Maybe broadcast current live state if switching TO live? + # run_async(broadcast_world_update) # Broadcast current live state + st.toast("Switched to Live State.") + st.rerun() + + # Display download links + st.caption("Download:") + cols = st.columns([3, 1]) # Columns for name and download button + with cols[0]: st.write("**Name**") + with cols[1]: st.write("**Link**") + for world_info in saved_worlds: + f_basename = os.path.basename(world_info['filename']) + f_fullpath = world_info['filename'] # Full path needed for reading file + display_name = world_info.get('name', f_basename) + timestamp = world_info.get('timestamp', 'N/A') + col1, col2 = st.columns([3, 1]) + with col1: st.write(f"{display_name} ({timestamp})", unsafe_allow_html=True) + with col2: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True) - for f_path in files_to_delete: - basename = os.path.basename(f_path) - if basename not in protected and os.path.isfile(f_path): # Ensure it's a file and not protected - try: - os.remove(f_path) - print(f"Deleted: {f_path}") - deleted_count += 1 - except Exception as e: - print(f"Failed delete {f_path}: {e}") - errors += 1 - elif os.path.isdir(f_path): - print(f"Skipping directory: {f_path}") - #else: - # print(f"Skipping protected/non-file: {f_path}") - except Exception as glob_e: - print(f"Error matching pattern {pattern}: {glob_e}") - errors += 1 - - msg = f"Deleted {deleted_count} files." - if errors > 0: - msg += f" Encountered {errors} errors." - st.warning(msg) - else: - st.success(msg) - # Clear relevant caches - st.session_state['download_link_cache'] = {} - st.session_state['audio_cache'] = {} # Clear audio cache if MP3s deleted + st.markdown("---") + # Save New Version moved to Files Tab -# --- Custom Paste Component (Keep from New App) --- -def paste_image_component(): # Returns Image object, type string - # If PIL.Image not imported, this will fail. Ensure it is. - pasted_img = None - img_type = None - with st.form(key="paste_form"): - paste_input = st.text_area("Paste Image Data Here (Ctrl+V)", key="paste_input_area", height=50) - submit_button = st.form_submit_button("Paste Image ๐Ÿ“‹") - if submit_button and paste_input and paste_input.startswith('data:image'): - try: - mime_type = paste_input.split(';')[0].split(':')[1] - base64_str = paste_input.split(',')[1] - img_bytes = base64.b64decode(base64_str) - pasted_img = Image.open(io.BytesIO(img_bytes)) - img_type = mime_type.split('/')[1] # e.g., png, jpeg - # Show preview immediately - st.image(pasted_img, caption=f"Pasted Image ({img_type.upper()})", width=150) - # Store base64 temporarily to avoid reprocessing on rerun if only text changed - st.session_state.paste_image_base64 = base64_str - except ImportError: - st.error("Pillow library not installed. Cannot process pasted images.") - except Exception as e: - st.error(f"Image decode error: {e}") - st.session_state.paste_image_base64 = "" # Clear on error - elif submit_button: - st.warning("No valid image data pasted.") - st.session_state.paste_image_base64 = "" # Clear if invalid submit - - return pasted_img, img_type - - -# --- Mapping Emojis to Primitive Types --- -# Ensure these types match the createPrimitiveMesh function keys in index.html -PRIMITIVE_MAP = { - "๐ŸŒณ": "Tree", "๐Ÿ—ฟ": "Rock", "๐Ÿ›๏ธ": "Simple House", "๐ŸŒฒ": "Pine Tree", "๐Ÿงฑ": "Brick Wall", - "๐Ÿ”ต": "Sphere", "๐Ÿ“ฆ": "Cube", "๐Ÿงด": "Cylinder", "๐Ÿฆ": "Cone", "๐Ÿฉ": "Torus", # cylinder emoji changed - "๐Ÿ„": "Mushroom", "๐ŸŒต": "Cactus", "๐Ÿ”ฅ": "Campfire", "โญ": "Star", "๐Ÿ’Ž": "Gem", - "๐Ÿ—ผ": "Tower", "๐Ÿšง": "Barrier", "โ›ฒ": "Fountain", "๐Ÿฎ": "Lantern", "ํŒป": "Sign Post" # sign post emoji changed - # Add more pairs up to ~20 -} + # Build Tools section + st.header("๐Ÿ—๏ธ Build Tools") + st.caption("Select an object to place.") + cols = st.columns(5) + col_idx = 0 + current_tool = st.session_state.get('selected_object', 'None') + for emoji, name in PRIMITIVE_MAP.items(): + button_key = f"primitive_{name}"; button_type = "primary" if current_tool == name else "secondary" + if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type, use_container_width=True): + if st.session_state.get('selected_object', 'None') != name: + st.session_state.selected_object = name + run_async(lambda: streamlit_js_eval(f"updateSelectedObjectType({json.dumps(name)});", key=f"update_tool_js_{name}")) # Fire-and-forget JS update + st.rerun() + col_idx += 1 + st.markdown("---") + if st.button("๐Ÿšซ Clear Tool", key="clear_tool", use_container_width=True): + if st.session_state.get('selected_object', 'None') != 'None': + st.session_state.selected_object = 'None' + run_async(lambda: streamlit_js_eval("updateSelectedObjectType('None');", key="update_tool_js_none")) + st.rerun() -# --- Main Streamlit Interface --- -def main_interface(): - # init_session_state() # Called before main_interface + # Voice/User section + st.markdown("---") + st.header("๐Ÿ—ฃ๏ธ Voice & User") + current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0]) + username_options = list(FUN_USERNAMES.keys()); current_index = 0 + try: current_index = username_options.index(current_username) + except ValueError: current_index = 0 + new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0]) + if new_username != st.session_state.username: + old_username = st.session_state.username + change_msg = json.dumps({"type":"user_rename", "payload": {"old_username": old_username, "new_username": new_username}}) + run_async(broadcast_message, change_msg) + st.session_state.username = new_username; st.session_state.tts_voice = FUN_USERNAMES[new_username]; save_username(st.session_state.username) + st.rerun() + st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True)) - # --- Load initial world state ONCE per session --- - if not st.session_state.get('initial_world_state_loaded', False): - with st.spinner("Loading initial world state..."): - load_world_state_from_disk() - st.session_state.initial_world_state_loaded = True - - # --- Username Setup --- - saved_username = load_username() - # Check if saved username is valid, otherwise pick random - if saved_username and saved_username in FUN_USERNAMES: - st.session_state.username = saved_username - st.session_state.tts_voice = FUN_USERNAMES[saved_username] # Set voice too - if not st.session_state.username: - # Pick a random available name if possible - # This check might be complex if server restarts often, rely on WS join/leave? - # For simplicity, just pick random if none saved/valid - st.session_state.username = random.choice(list(FUN_USERNAMES.keys())) - st.session_state.tts_voice = FUN_USERNAMES[st.session_state.username] - save_username(st.session_state.username) - # Announce join happens via WebSocket handler when client connects +def render_main_content(): + """Renders the main content area with tabs.""" st.title(f"{Site_Name} - User: {st.session_state.username}") - # --- Main Content Area --- - tab_world, tab_chat, tab_files = st.tabs(["๐Ÿ—๏ธ World Builder", "๐Ÿ—ฃ๏ธ Chat", "๐Ÿ“‚ Files & Settings"]) + tab_world, tab_chat, tab_pdf, tab_files = st.tabs(["๐Ÿ—๏ธ World Builder", "๐Ÿ—ฃ๏ธ Chat", "๐Ÿ“š PDF Tools", "๐Ÿ“‚ Files & Settings"]) + # --- World Builder Tab --- with tab_world: st.header("Shared 3D World") st.caption("Place objects using the sidebar tools. Changes are shared live!") + current_file = st.session_state.get('current_world_file', None) + if current_file: parsed = parse_world_filename(current_file); st.info(f"Current World: **{parsed['name']}** (`{os.path.basename(current_file)}`)") + else: st.info("Live State Active (Unsaved changes will be lost unless saved as new version)") - # --- Embed HTML Component for Three.js --- + # Embed HTML Component html_file_path = 'index.html' try: - with open(html_file_path, 'r', encoding='utf-8') as f: - html_template = f.read() - - # Determine WebSocket URL based on Streamlit server address if possible - # Fallback to localhost for local dev - # This part is tricky and might need manual configuration depending on deployment - try: - # Attempt to get server address (might not work reliably in all deployments) + with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read() + try: # Get WS URL from streamlit.web.server.server import Server - session_info = Server.get_current().get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id) - server_host = session_info.ws.stream.request.host.split(':')[0] # Get host without port + session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id) + server_host = session_info.ws.stream.request.host.split(':')[0] ws_url = f"ws://{server_host}:8765" - print(f"Determined WS URL: {ws_url}") - except Exception as e: - print(f"Could not determine server host ({e}), defaulting WS URL to localhost.") - ws_url = "ws://localhost:8765" - + except Exception: ws_url = "ws://localhost:8765" - js_injection_script = f""" - -""" + console.log("Streamlit State Injected:", {{ username: window.USERNAME, websocketUrl: window.WEBSOCKET_URL, selectedObject: window.SELECTED_OBJECT_TYPE }}); +""" html_content_with_state = html_template.replace('', js_injection_script + '\n', 1) - components.html(html_content_with_state, height=700, scrolling=False) + except FileNotFoundError: st.error(f"CRITICAL ERROR: Could not find '{html_file_path}'.") + except Exception as e: st.error(f"Error loading 3D component: {e}"); st.exception(e) - except FileNotFoundError: - st.error(f"CRITICAL ERROR: Could not find '{html_file_path}'. Ensure it's in the same directory.") - except Exception as e: - st.error(f"Error loading 3D component: {e}") - st.exception(e) # Show traceback - + # --- Chat Tab --- with tab_chat: st.header(f"{START_ROOM} Chat") - chat_history = asyncio.run(load_chat_history()) # Load history at start of tab render - chat_container = st.container(height=500) # Scrollable chat area + chat_history = run_async(load_chat_history).result() if 'chat_history' not in st.session_state else st.session_state.chat_history # Load sync if needed + chat_container = st.container(height=500) with chat_container: - # Display chat history (most recent at bottom) - st.markdown("----\n".join(reversed(chat_history[-50:]))) # Show last 50, use markdown, reversed + if chat_history: st.markdown("----\n".join(reversed(chat_history[-50:]))) + else: st.caption("No chat messages yet.") # Chat Input Area - message_value = st.text_input( - "Your Message:", - key="message_input", # Key links to st.session_state.message_input - label_visibility="collapsed" - ) + message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed") send_button_clicked = st.button("Send Chat ๐Ÿ’ฌ", key="send_chat_button") - should_autosend = st.session_state.get('autosend', False) and message_value # Check flag and value + should_autosend = st.session_state.get('autosend', False) and message_value - # Process if button clicked OR autosend triggered with a valid message if send_button_clicked or should_autosend: - message_to_send = message_value # Capture the value from this run - + message_to_send = message_value if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''): - # Update last message tracker *before* sending/clearing st.session_state.last_message = message_to_send voice = FUN_USERNAMES.get(st.session_state.username, "en-US-AriaNeural") - - # Send via WebSocket - ws_message = json.dumps({ - "type": "chat_message", - "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice} - }) - # Use asyncio.run correctly for async functions called from sync context - try: - # Ensure loop is available - get current or run in new one if needed - loop = asyncio.get_running_loop() - loop.create_task(broadcast_message(ws_message)) # Schedule broadcast - except RuntimeError: # No running loop - asyncio.run(broadcast_message(ws_message)) # Run in new loop (less efficient) - except Exception as e: - st.error(f"WebSocket broadcast error: {e}") - - - # Save locally (run in background task to avoid blocking UI much) - try: - loop = asyncio.get_running_loop() - loop.create_task(save_chat_entry(st.session_state.username, message_to_send, voice)) - except RuntimeError: - asyncio.run(save_chat_entry(st.session_state.username, message_to_send, voice)) - except Exception as e: - st.error(f"Chat save error: {e}") - - - # --- CORRECT WAY TO CLEAR --- - st.session_state.message_input = "" - - # Rerun to clear the input field visually and update the chat display - # Short delay might help ensure background tasks started? Unlikely needed. - # time.sleep(0.05) + ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}}) + run_async(broadcast_message, ws_message) # Send via WS + run_async(save_chat_entry, st.session_state.username, message_to_send, voice) # Save locally + st.session_state.message_input = "" # Clear state for next run st.rerun() + elif send_button_clicked: st.toast("Message empty or same as last.") - # Handle cases where button was clicked but message was empty/repeated - elif send_button_clicked and (not message_to_send.strip() or message_to_send == st.session_state.get('last_message', '')): - st.toast("Message empty or same as last.") # Give feedback - - with tab_files: - st.header("File Management & Settings") + st.checkbox("Autosend Chat", key="autosend") # Toggle autosend - st.subheader("Server & World State") - col_ws, col_save = st.columns(2) - with col_ws: - # Check thread status if task exists - server_alive = st.session_state.get('server_task') and st.session_state.server_task.is_alive() - ws_status = "Running" if server_alive else "Stopped" - st.metric("WebSocket Server", ws_status) - st.metric("Connected Clients", len(connected_clients)) # Use global set length - if not server_alive and st.button("Restart Server Thread", key="restart_ws"): - start_websocket_server_thread() - st.rerun() + # --- PDF Tab --- + with tab_pdf: + st.header("๐Ÿ“š PDF Tools") + pdf_file = st.file_uploader("Upload PDF for Audio Conversion", type="pdf", key="pdf_upload") + max_pages = st.slider('Max Pages to Process', 1, 50, 10, key="pdf_pages") # Limit pages + if pdf_file: + process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice) - with col_save: - if st.button("๐Ÿ’พ Save World State to Disk", key="save_world_disk", help="Saves the current live world state to world_state.json"): - with st.spinner("Saving..."): - if save_world_state_to_disk(): - st.success("World state saved!") - else: - st.error("Failed to save world state.") - st.markdown(get_download_link(WORLD_STATE_FILE, "json"), unsafe_allow_html=True) + # --- Files & Settings Tab --- + with tab_files: + st.header("๐Ÿ“‚ Files & Settings") + st.subheader("๐Ÿ’พ World State Management") + current_file = st.session_state.get('current_world_file', None) + + # Save Current Version Button + if current_file: + parsed = parse_world_filename(current_file); save_label = f"Save Changes to '{parsed['name']}'" + if st.button(save_label, key="save_current_world", help=f"Overwrite '{os.path.basename(current_file)}' with current live state."): + with st.spinner(f"Overwriting {os.path.basename(current_file)}..."): + if save_world_state_to_md(current_file): st.success("Current world version saved!") + else: st.error("Failed to save world state.") + else: + st.info("Load a world version from the sidebar or use 'Save New Version' below to save the current live state.") + + # Save As New Version Section + st.subheader("Save As New Version") + new_name_files = st.text_input("New World Name:", key="new_world_name_files") # Use different key from sidebar one if kept there + if st.button("๐Ÿ’พ Save Live State as New Version", key="save_new_version_files"): + if new_name_files.strip(): + new_filename = generate_world_save_filename(new_name_files) # Generates basename + with st.spinner(f"Saving new version '{new_name_files}'..."): + if save_world_state_to_md(new_filename): # Pass basename + st.success(f"Saved as {new_filename}") + st.session_state.current_world_file = new_filename # Switch to new file automatically + st.session_state.new_world_name_files = "" # Reset input + st.rerun() + else: st.error("Failed to save new version.") + else: st.warning("Please enter a name for the new world version.") + + st.subheader("โš™๏ธ Server Status") + col_ws, col_clients = st.columns(2) + with col_ws: + server_alive = st.session_state.get('server_task') and st.session_state.server_task.is_alive(); ws_status = "Running" if server_alive else "Stopped"; st.metric("WebSocket Server", ws_status) + if not server_alive and st.button("Restart Server Thread", key="restart_ws"): start_websocket_server_thread(); st.rerun() + with col_clients: st.metric("Connected Clients", len(connected_clients)) - # File deletion buttons - st.subheader("Delete Files") + # File Deletion + st.subheader("๐Ÿ—‘๏ธ Delete Files") st.warning("Deletion is permanent!", icon="โš ๏ธ") col_del1, col_del2, col_del3, col_del4 = st.columns(4) with col_del1: - if st.button("๐Ÿ—‘๏ธ Chats (.md)", key="del_chat_md"): - delete_files([os.path.join(CHAT_DIR, "*.md")]) - st.session_state.chat_history = [] # Clear session history too - st.rerun() + if st.button("๐Ÿ—‘๏ธ Chats", key="del_chat_md"): delete_files([os.path.join(CHAT_DIR, "*.md")]); st.session_state.chat_history = []; st.rerun() with col_del2: - if st.button("๐Ÿ—‘๏ธ Audio (.mp3)", key="del_audio_mp3"): - delete_files([os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3")]) - st.session_state.audio_cache = {} - st.rerun() + if st.button("๐Ÿ—‘๏ธ Audio", key="del_audio_mp3"): delete_files([os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3")]); st.session_state.audio_cache = {}; st.rerun() with col_del3: - if st.button("๐Ÿ—‘๏ธ Zips (.zip)", key="del_zips"): - delete_files(["*.zip"]) - st.rerun() + if st.button("๐Ÿ—‘๏ธ Worlds", key="del_worlds_md", help="Deletes world save files (.md) in saved_worlds"): delete_files([os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")]); st.session_state.current_world_file = None; st.rerun() with col_del4: - if st.button("๐Ÿ—‘๏ธ All Generated", key="del_all_gen", help="Deletes Chats, Audio, Zips"): - delete_files([os.path.join(CHAT_DIR, "*.md"), - os.path.join(AUDIO_DIR, "*.mp3"), - os.path.join(AUDIO_CACHE_DIR, "*.mp3"), - "*.zip"]) - st.session_state.chat_history = [] - st.session_state.audio_cache = {} - st.rerun() + if st.button("๐Ÿ—‘๏ธ All Gen", key="del_all_gen", help="Deletes Chats, Audio, Worlds, Zips"): delete_files([os.path.join(CHAT_DIR, "*.md"), os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3"), os.path.join(SAVED_WORLDS_DIR, "*.md"), "*.zip"]); st.session_state.chat_history = []; st.session_state.audio_cache = {}; st.session_state.current_world_file = None; st.rerun() - # Display Zips + # Download Archives st.subheader("๐Ÿ“ฆ Download Archives") zip_files = sorted(glob.glob("*.zip"), key=os.path.getmtime, reverse=True) - for zip_file in zip_files: - st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True) + if zip_files: + for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True) + else: st.caption("No zip archives found.") - # --- Sidebar Controls --- - with st.sidebar: - st.header("๐Ÿ—๏ธ Build Tools") - st.caption("Select an object to place.") - - # --- Emoji Buttons for Primitives --- - cols = st.columns(5) # 5 columns for buttons - col_idx = 0 - current_tool = st.session_state.get('selected_object', 'None') +# ============================================================================== +# Main Execution Logic +# ============================================================================== - for emoji, name in PRIMITIVE_MAP.items(): - button_key = f"primitive_{name}" - # Use primary styling for the selected button - button_type = "primary" if current_tool == name else "secondary" - if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type, use_container_width=True): - st.session_state.selected_object = name - # Update JS immediately without full rerun if possible - try: - js_update_selection = f"updateSelectedObjectType({json.dumps(name)});" - streamlit_js_eval(js_code=js_update_selection, key=f"update_tool_js_{name}") # Unique key per button might help - except Exception as e: - print(f"Could not push tool update to JS: {e}") - # Force a rerun to update button styles immediately if JS update fails or isn't enough - st.rerun() - col_idx += 1 - - # Button to clear selection - st.markdown("---") # Separator - if st.button("๐Ÿšซ Clear Tool", key="clear_tool", use_container_width=True): - if st.session_state.selected_object != 'None': - st.session_state.selected_object = 'None' - try: # Update JS too - streamlit_js_eval(js_code=f"updateSelectedObjectType('None');", key="update_tool_js_none") - except Exception: pass - st.rerun() # Rerun to update UI - - st.markdown("---") - st.header("๐Ÿ—ฃ๏ธ Voice & User") - # Username/Voice Selection - # Use format_func to display only the name part - current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0]) - username_options = list(FUN_USERNAMES.keys()) - try: - current_index = username_options.index(current_username) - except ValueError: - current_index = 0 # Default to first if saved name invalid - - new_username = st.selectbox( - "Change Name/Voice", - options=username_options, - index=current_index, - key="username_select", - format_func=lambda x: x.split(" ")[0] # Show only name before emoji - ) - - if new_username != st.session_state.username: - old_username = st.session_state.username - # Announce name change via WebSocket - change_msg = json.dumps({ - "type":"user_rename", - "payload": {"old_username": old_username, "new_username": new_username} - }) - try: - loop = asyncio.get_running_loop() - loop.create_task(broadcast_message(change_msg)) - except RuntimeError: asyncio.run(broadcast_message(change_msg)) - except Exception as e: st.error(f"Rename broadcast error: {e}") - - st.session_state.username = new_username - st.session_state.tts_voice = FUN_USERNAMES[new_username] - save_username(st.session_state.username) # Save new username - st.rerun() - - # Enable/Disable Audio Toggle - st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True)) - - st.markdown("---") - st.info("Chat and File management in main tabs.") +def initialize_world(): + """Loads initial world state (most recent) if not already loaded.""" + if not st.session_state.get('initial_world_state_loaded', False): + print("Performing initial world load...") + saved_worlds = get_saved_worlds() + if saved_worlds: + latest_world_file_basename = os.path.basename(saved_worlds[0]['filename']) + print(f"Loading most recent world on startup: {latest_world_file_basename}") + load_world_state_from_md(latest_world_file_basename) # This updates global state and sets session state 'current_world_file' + else: + print("No saved worlds found, starting with empty state.") + with world_objects_lock: world_objects.clear() # Ensure empty state + st.session_state.current_world_file = None # Ensure no file is marked as loaded + st.session_state.initial_world_state_loaded = True + print("Initial world load complete.") -# --- Main Execution --- if __name__ == "__main__": - # Initialize session state variables first + # 1. Initialize session state first init_session_state() - # Start WebSocket server in a thread IF it's not already running - # This check needs to be robust across reruns - if 'server_task' not in st.session_state or not st.session_state.server_task.is_alive(): - print("Main thread: Starting WebSocket server thread...") - start_websocket_server_thread() - # Wait briefly to allow the server thread to initialize. - # This might not be strictly necessary but can help avoid race conditions on first load. - time.sleep(1.5) - else: - print("Main thread: Server thread already exists.") + # 2. Start WebSocket server thread if needed + # Use server_running_flag to prevent multiple start attempts within one session + if not st.session_state.get('server_running_flag', False): + if 'server_task' not in st.session_state or not st.session_state.server_task.is_alive(): + start_websocket_server_thread() + else: + # If task exists but flag is false, maybe update flag? + if st.session_state.server_task.is_alive(): + st.session_state.server_running_flag = True + print("Corrected server_running_flag based on alive thread.") - # Load world state from disk if not already loaded this session - if not st.session_state.get('initial_world_state_loaded', False): - load_world_state_from_disk() - st.session_state.initial_world_state_loaded = True - - # Run the main UI rendering function - main_interface() - - # Optional: Add a periodic save task? - # Example: Save every 5 minutes if needed - # last_save_time = st.session_state.get('last_world_save_time', 0) - # if time.time() - last_save_time > 300: # 300 seconds = 5 minutes - # print("Periodic save triggered...") - # if save_world_state_to_disk(): - # st.session_state.last_world_save_time = time.time() \ No newline at end of file + # 3. Load initial world state from disk if needed + initialize_world() + + # 4. Render the UI (Sidebar and Main Content) + render_sidebar() + render_main_content() \ No newline at end of file