awacke1's picture
Update app.py
a92e352 verified
raw
history blame
43.2 kB
# app.py (Simplified - No WebSockets, Direct JS Communication)
import streamlit as st
import asyncio # Keep for TTS
import uuid
from datetime import datetime
import os
import random
import time
import hashlib
import glob
import base64
import io
import streamlit.components.v1 as components
import edge_tts # Keep for TTS
import nest_asyncio
import re
import pytz
import shutil
from PyPDF2 import PdfReader # Keep if PDF tab is kept
import threading
import json
import zipfile
from dotenv import load_dotenv
from streamlit_marquee import streamlit_marquee # Keep if used
from collections import defaultdict, Counter
# import pandas as pd # Only needed if CSV fallback kept, can likely remove
from streamlit_js_eval import streamlit_js_eval, sync # Use sync for direct calls
from PIL import Image # Needed for paste_image_component
# ==============================================================================
# Configuration & Constants
# ==============================================================================
# ๐Ÿ› ๏ธ Patch asyncio for nesting (May still be needed for TTS)
nest_asyncio.apply()
# ๐ŸŽจ Page Config
st.set_page_config(page_title="๐Ÿ—๏ธ Simpler World Builder ๐Ÿ†", page_icon="๐Ÿ—๏ธ", layout="wide", initial_sidebar_state="expanded")
# General Constants
Site_Name = '๐Ÿ—๏ธ Simpler World Builder'
MEDIA_DIR = "."
STATE_FILE = "user_state.txt" # For remembering username
# User/Chat Constants (Simplified - No longer needed for WS identity)
# FUN_USERNAMES = { ... } # Can remove if not used elsewhere
# EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values())) # Define default voice directly if needed
DEFAULT_TTS_VOICE = "en-US-AriaNeural"
CHAT_DIR = "chat_logs" # Keep if chat tab remains
# Audio Constants
AUDIO_CACHE_DIR = "audio_cache"
AUDIO_DIR = "audio_logs"
# World Builder Constants
SAVED_WORLDS_DIR = "saved_worlds"
PLOT_WIDTH = 50.0 # Needed for JS injection
PLOT_DEPTH = 50.0 # Needed for JS injection
WORLD_STATE_FILE_MD_PREFIX = "๐ŸŒ_"
# File Emojis
FILE_EMOJIS = {"md": "๐Ÿ“", "mp3": "๐ŸŽต", "png": "๐Ÿ–ผ๏ธ", "mp4": "๐ŸŽฅ", "zip": "๐Ÿ“ฆ", "json": "๐Ÿ“„"}
# 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"
}
# --- Directories ---
for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
os.makedirs(d, exist_ok=True)
# --- API Keys (Placeholder) ---
load_dotenv()
# --- Global State & Lock ---
# Lock remains important for modifying the shared dictionary from potentially different Streamlit sessions/reruns
world_objects_lock = threading.Lock()
# THIS NOW REPRESENTS THE STATE LOADED FROM A FILE OR BUILT UP LOCALLY BEFORE SAVING
# IT IS *NOT* A LIVE REPRESENTATION OF ALL USERS' ACTIONS
world_objects = defaultdict(dict)
# ==============================================================================
# Utility Functions (Keep relevant ones)
# ==============================================================================
def get_current_time_str(tz='UTC'):
try: timezone = pytz.timezone(tz); now_aware = datetime.now(timezone)
except Exception: now_aware = datetime.now(pytz.utc)
return now_aware.strftime('%Y%m%d_%H%M%S')
def clean_filename_part(text, max_len=30):
if not isinstance(text, str): text = "invalid_name"
text = re.sub(r'\s+', '_', text); text = re.sub(r'[^\w\-.]', '', text)
return text[:max_len]
def ensure_dir(dir_path): os.makedirs(dir_path, exist_ok=True)
# ==============================================================================
# World State File Handling (Markdown + JSON) - Keep as is
# ==============================================================================
def generate_world_save_filename(name="World"):
timestamp = get_current_time_str(); clean_name = clean_filename_part(name);
rand_hash = hashlib.md5(str(time.time()).encode() + name.encode()).hexdigest()[:6]
return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_name}_{timestamp}_{rand_hash}.md"
def parse_world_filename(filename):
basename = os.path.basename(filename)
if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
core_name = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]; parts = core_name.split('_')
if len(parts) >= 3:
timestamp_str = parts[-2]; name_parts = parts[:-2]; name = "_".join(name_parts) if name_parts else "Untitled"; dt_obj = None
try: dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
except Exception: dt_obj = None
return {"name": name.replace('_', ' '), "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
# Fallback
dt_fallback = None; try: mtime = os.path.getmtime(filename); dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
except Exception: pass
return {"name": basename.replace('.md',''), "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
def save_world_state_to_md(target_filename_base):
"""Saves the current 'world_objects' state to a specific MD file."""
global world_objects
save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
print(f"Acquiring lock to save world state to: {save_path}...")
success = False
with world_objects_lock:
world_data_dict = dict(world_objects); print(f"Saving {len(world_data_dict)} objects...")
parsed_info = parse_world_filename(save_path); timestamp_save = get_current_time_str()
md_content = f"""# World State: {parsed_info['name']}
* **File Saved:** {timestamp_save} (UTC)
* **Source Timestamp:** {parsed_info['timestamp']}
* **Objects:** {len(world_data_dict)}
```json
{json.dumps(world_data_dict, indent=2)}
```"""
try:
ensure_dir(SAVED_WORLDS_DIR);
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"Error saving world state to {save_path}: {e}")
return success
def load_world_state_from_md(filename_base):
"""Loads world state from MD, updates global 'world_objects', 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:
with open(load_path, 'r', encoding='utf-8') as f: content = f.read()
json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE)
if not json_match: st.error(f"Could not find valid JSON block in {filename_base}"); return False
world_data_dict = json.loads(json_match.group(1))
print(f"Acquiring lock to update world state from {filename_base}...")
with world_objects_lock:
world_objects.clear()
for k, v in world_data_dict.items(): world_objects[str(k)] = v
loaded_count = len(world_objects)
print(f"Loaded {loaded_count} objects from {filename_base}. Lock released.")
st.session_state.current_world_file = filename_base
return True
except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return False
def get_saved_worlds():
"""Scans the saved worlds directory for world 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]
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 []
# ==============================================================================
# User State & Session Init
# ==============================================================================
def save_username(username): # Keep this simple utility
try:
with open(STATE_FILE, 'w') as f: f.write(username)
except Exception as e: print(f"Failed save username: {e}")
def load_username(): # Keep this
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
def init_session_state():
"""Initializes Streamlit session state variables."""
defaults = {
# Removed WS state: server_running_flag, server_instance, server_task, active_connections
'last_chat_update': 0, 'message_input': "",
'audio_cache': {}, 'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [],
'enable_audio': True, 'download_link_cache': {}, 'username': None, 'autosend': False,
'last_message': "",
'selected_object': 'None', # Current building tool
'initial_world_state_loaded': False, # Flag to load state only once per session
'current_world_file': None, # Track loaded world filename (basename)
'paste_image_base64': "", 'new_world_name': "MyWorld",
'js_event_data': None # NEW: To store data pushed from JS
}
for k, v in defaults.items():
if k not in st.session_state: st.session_state[k] = v
# Ensure complex types initialized correctly
if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
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 (Largely unchanged, remove WS calls)
# ==============================================================================
async def async_edge_tts_generate(text, voice, username): # No change needed
if not text: return None
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): 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);
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."); return None
except Exception as e: print(f"Edge TTS Error: {e}"); return None
def play_and_download_audio(file_path): # No change needed
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"Audio display error for {os.path.basename(file_path)}: {e}")
async def save_chat_entry(username, message, voice, is_markdown=False): # No change needed
if not message.strip(): return None, 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) # Save to file
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):
tts_message = message
audio_file = await async_edge_tts_generate(tts_message, voice, username)
return md_file, audio_file
async def load_chat_history(): # No change needed
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 (Keep Create Zip, Delete Files) ---
def create_zip_of_files(files_to_zip, prefix="Archive"): # Keep as is
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): # Keep as is
protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
current_world_base = st.session_state.get('current_world_file')
if current_world_base: protected.append(current_world_base)
if exclude_files: protected.extend(exclude_files)
deleted_count = 0; errors = 0
for pattern in file_patterns:
pattern_path = pattern
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
for f_path in files_to_delete:
basename = os.path.basename(f_path)
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"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)
elif deleted_count > 0: st.success(msg)
else: st.info("No matching files found to delete.")
st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
# --- Image Handling (Keep paste component logic) ---
async def save_pasted_image(image, username): # Keep as is
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(): # Keep as is
pasted_img = None; img_type = None
paste_input_value = st.text_area("Paste Image Data Here", key="paste_input_area", height=50, value=st.session_state.get('paste_image_base64_input', ""))
if st.button("Process Pasted Image ๐Ÿ“‹", key="process_paste_button"):
st.session_state.paste_image_base64_input = paste_input_value
if paste_input_value and paste_input_value.startswith('data:image'):
try:
mime_type = paste_input_value.split(';')[0].split(':')[1]; base64_str = paste_input_value.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
st.session_state.paste_image_base64_input = "" # Clear input state on success
st.rerun()
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 = ""; st.session_state.paste_image_base64_input = paste_input_value # Keep invalid data
else:
st.warning("No valid image data pasted."); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = paste_input_value
# Return image if processed in previous run
processed_b64 = st.session_state.get('paste_image_base64', '')
if processed_b64:
try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
except Exception: return None
return None
# --- PDF Processing (Keep if desired) ---
class AudioProcessor: # Keep as is
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=DEFAULT_TTS_VOICE):
cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=os.path.join(self.cache_dir, f"{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): # Keep as is
st.subheader("PDF Processing Results")
if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
audio_processor = AudioProcessor()
try:
reader=PdfReader(pdf_file);
if reader.is_encrypted: st.warning("PDF is encrypted."); return
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):
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}")
for i in range(total_pages):
try:
page = reader.pages[i]; text = page.extract_text();
if text and text.strip(): 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]"
except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
progress_bar = st.progress(0.0, text="Processing pages...")
total_threads = len(page_threads); start_join_time = time.time()
while any(t.is_alive() for t in page_threads):
completed_threads = total_threads - sum(t.is_alive() for t in page_threads); progress = completed_threads / total_threads if total_threads > 0 else 1.0
progress_bar.progress(min(progress, 1.0), text=f"Processed {completed_threads}/{total_threads} pages...")
if time.time() - start_join_time > 600: print("PDF processing timed out."); break
time.sleep(0.5)
progress_bar.progress(1.0, text="Processing complete.")
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 was skipped.")
except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
# ==============================================================================
# Streamlit UI Layout Functions
# ==============================================================================
def render_sidebar():
"""Renders the Streamlit sidebar contents."""
with st.sidebar:
st.header("๐Ÿ’พ World Versions")
st.caption("Load or save named world states.")
saved_worlds = get_saved_worlds()
world_options_display = {os.path.basename(w['filename']): f"{w['name']} ({w['timestamp']})" for w in saved_worlds}
radio_options_basenames = [None] + [os.path.basename(w['filename']) for w in saved_worlds]
current_selection_basename = st.session_state.get('current_world_file', None)
current_radio_index = 0
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
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_display.get(x, x),
key="world_selector_radio"
)
# Handle selection change
if selected_basename != current_selection_basename:
st.session_state.current_world_file = selected_basename
if selected_basename:
with st.spinner(f"Loading {selected_basename}..."):
if load_world_state_from_md(selected_basename):
st.toast("World loaded!", icon="โœ…")
# Rerun will cause component to reload with injected state from world_objects
else: st.error("Failed to load world."); st.session_state.current_world_file = None
else: print("Switched to live state."); st.toast("Switched to Live State.")
st.rerun() # Rerun needed to update main view and component state
# Download Links for Worlds
st.caption("Download:")
cols = st.columns([4, 1])
with cols[0]: st.write("**Name** (Timestamp)")
with cols[1]: st.write("**DL**", help="Download")
display_limit = 10
for i, world_info in enumerate(saved_worlds):
f_basename = os.path.basename(world_info['filename']); f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename);
display_name = world_info.get('name', f_basename); timestamp = world_info.get('timestamp', 'N/A')
is_last_displayed = i == display_limit -1
show_expander_trigger = len(saved_worlds) > display_limit and is_last_displayed
# Display logic adjusted for expander
if i < display_limit or (show_expander_trigger and i == display_limit): # Display items up to limit, and first item in expander
container = st if i < display_limit else st.expander(f"Show {len(saved_worlds)-display_limit} more...")
with container:
# If it's the trigger for expander, display current item first
if show_expander_trigger and i == display_limit:
col1_exp, col2_exp = st.columns([4, 1]);
with col1_exp: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
with col2_exp: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
# Display items before the limit, or items after limit within expander
elif i < display_limit:
col1, col2 = st.columns([4, 1]);
with col1: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
with col2: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
# Now display remaining items inside the expander if triggered
if show_expander_trigger and i == display_limit:
for world_info_more in saved_worlds[display_limit+1:]:
f_bn_more=os.path.basename(world_info_more['filename']); f_fp_more=os.path.join(SAVED_WORLDS_DIR, f_bn_more); dn_more=world_info_more.get('name',f_bn_more); ts_more=world_info_more.get('timestamp','N/A')
colA, colB = st.columns([4, 1]);
with colA: st.write(f"<small>{dn_more} ({ts_more})</small>", unsafe_allow_html=True)
with colB: st.markdown(get_download_link(f_fp_more, "md"), unsafe_allow_html=True)
if show_expander_trigger: break # Stop outer loop after processing expander
st.markdown("---")
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
# Fire and forget JS update
sync(js_code=f"updateSelectedObjectType({json.dumps(name)});", key=f"update_tool_js_{name}")
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';
sync(js_code="updateSelectedObjectType('None');", key="update_tool_js_none")
st.rerun()
st.markdown("---")
st.header("๐Ÿ—ฃ๏ธ Voice & User")
current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0]) if FUN_USERNAMES else "DefaultUser"
username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
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:
st.session_state.username = new_username;
st.session_state.tts_voice = FUN_USERNAMES.get(new_username, DEFAULT_TTS_VOICE); # Use get with default
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))
def render_main_content():
"""Renders the main content area with tabs."""
st.title(f"{Site_Name} - User: {st.session_state.username}")
# Check for JS event data pushed from the component
# Use a unique key for the event listener setup
js_event = streamlit_js_eval(
js_code="""
// Ensure the listener is attached only once
if (!window.streamlitComponentEventAttached) {
window.addEventListener('message', event => {
// Basic check for component message structure
if (event.data.type === 'streamlit:componentChanged' && event.data.data) {
// Send the component's value back to Python under a specific key
Streamlit.setComponentValue({ type: 'js_event', payload: event.data.data });
}
});
window.streamlitComponentEventAttached = true; // Flag it
}
// Return null initially or on standard runs
null;
""",
key="component_event_listener" # Unique key for this setup code
)
# Process event data if received
if js_event and isinstance(js_event, dict) and js_event.get("type") == "js_event":
event_payload = js_event.get("payload")
print(f"Received JS Event Payload: {event_payload}") # Debug log
if isinstance(event_payload, dict):
action = event_payload.get("action")
data = event_payload.get("data")
if action == "object_placed" and data:
print(f"Processing object placed event for {data.get('obj_id')}")
# Add object to in-memory state (use lock)
with world_objects_lock:
world_objects[data['obj_id']] = data
# Clear the event from state to prevent reprocessing
# This might require careful handling if rerun happens before processing fully
# Maybe process directly instead of setting session state? Let's try direct.
# st.session_state.js_event_data = None # Clear trigger? No, js_event is the trigger value itself.
st.toast(f"Placed {data.get('type', 'object')}!", icon="โž•")
# No rerun needed here, object added to server memory. User sees local obj.
# Save happens manually via button.
elif action == "object_deleted" and data:
obj_id = data.get("obj_id")
if obj_id:
print(f"Processing object deleted event for {obj_id}")
with world_objects_lock:
if obj_id in world_objects:
del world_objects[obj_id]
st.toast(f"Deleted object {obj_id[:6]}...", icon="โž–")
else:
st.toast(f"Object {obj_id[:6]}... already deleted.", icon="โ“")
# st.session_state.js_event_data = None
# Add more event types as needed
# Define Tabs
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 sidebar tools. Use Files tab to Save/Load.")
current_file_basename = st.session_state.get('current_world_file', None)
if current_file_basename:
full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
if os.path.exists(full_path_for_parse): parsed = parse_world_filename(full_path_for_parse); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
else: st.info("Live State Active (Save As New Version to persist)")
# Embed HTML Component
html_file_path = 'index.html'
try:
with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
ws_url = "ws://localhost:8765" # Default (WebSockets not used for state sync now)
# Inject state needed by JS (like current tool)
js_injection_script = f"""<script>
window.USERNAME = {json.dumps(st.session_state.username)};
// window.WEBSOCKET_URL = {json.dumps(ws_url)}; // No longer needed for state sync
window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
// Send the current world objects for initial draw
window.INITIAL_WORLD_OBJECTS = {json.dumps(dict(world_objects))};
console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjects: {len(world_objects)} }});
</script>"""
html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
# Use a key for the component to potentially receive data via setComponentValue
# component_key = "three_world_component" # Define a key
# html_component_value = components.html(html_content_with_state, height=700, scrolling=False, key=component_key)
# Using streamlit_js_eval to receive events is more explicit
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)
# --- Chat Tab ---
with tab_chat:
st.header(f"{START_ROOM} Chat")
if 'chat_history' not in st.session_state or not st.session_state.chat_history:
chat_history_list = asyncio.run(load_chat_history())
else: chat_history_list = st.session_state.chat_history
chat_container = st.container(height=500)
with chat_container:
if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:])))
else: st.caption("No chat messages yet.")
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
if send_button_clicked or should_autosend:
message_to_send = message_value
if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
st.session_state.last_message = message_to_send
voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
# Directly save and update history, no WS needed for chat display?
# If chat needs to be real-time, WS is still required for that part.
# Let's assume chat is NOT real-time for now to simplify fully.
run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
st.session_state.message_input = ""
st.rerun()
elif send_button_clicked: st.toast("Message empty or same as last.")
st.checkbox("Autosend Chat", key="autosend")
# --- 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")
if pdf_file:
if st.button("Process PDF to Audio", key="process_pdf_button"):
with st.spinner("Processing PDF... This may take time."):
process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice)
# --- Files & Settings Tab ---
with tab_files:
st.header("๐Ÿ“‚ Files & Settings")
st.subheader("๐Ÿ’พ World State Management")
current_file_basename = st.session_state.get('current_world_file', None)
if current_file_basename:
full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
if os.path.exists(full_path_for_parse): parsed = parse_world_filename(full_path_for_parse); save_label = f"Save Changes to '{parsed['name']}'"
else: parsed = None; save_label = f"Save Changes (File '{current_file_basename}' missing)"
if st.button(save_label, key="save_current_world", help=f"Overwrite '{current_file_basename}'"):
if not os.path.exists(full_path_for_parse):
st.error(f"Cannot save, file '{current_file_basename}' no longer exists.")
else:
with st.spinner(f"Overwriting {current_file_basename}..."):
if save_world_state_to_md(current_file_basename): st.success("Current world saved!")
else: st.error("Failed to save world state.")
else: st.info("Load a world from the sidebar or use 'Save As New Version' below.")
st.subheader("Save As New Version")
new_name_files = st.text_input("New World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
if st.button("๐Ÿ’พ Save Live State as New Version", key="save_new_version_files"):
if new_name_files.strip():
new_filename_base = generate_world_save_filename(new_name_files)
with st.spinner(f"Saving new version '{new_name_files}'..."):
if save_world_state_to_md(new_filename_base):
st.success(f"Saved as {new_filename_base}")
st.session_state.current_world_file = new_filename_base; st.session_state.new_world_name = "MyWorld"; st.rerun()
else: st.error("Failed to save new version.")
else: st.warning("Please enter a name.")
# --- Removed Server Status Section ---
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", 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", 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("๐Ÿ—‘๏ธ Worlds", key="del_worlds_md"): 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 Gen", key="del_all_gen"): 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"), os.path.join(MEDIA_DIR, "*.zip")]); st.session_state.chat_history = []; st.session_state.audio_cache = {}; st.session_state.current_world_file = None; st.rerun()
st.subheader("๐Ÿ“ฆ Download Archives")
# Zip Buttons
col_zip1, col_zip2, col_zip3 = st.columns(3)
with col_zip1:
if st.button("Zip Worlds"): create_zip_of_files(glob.glob(os.path.join(SAVED_WORLDS_DIR, "*.md")), "Worlds")
with col_zip2:
if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
with col_zip3:
if st.button("Zip Audio"): create_zip_of_files(glob.glob(os.path.join(AUDIO_DIR, "*.mp3")) + glob.glob(os.path.join(AUDIO_CACHE_DIR, "*.mp3")), "Audio")
# List existing zips
zip_files = sorted(glob.glob(os.path.join(MEDIA_DIR,"*.zip")), key=os.path.getmtime, reverse=True)
if zip_files:
st.caption("Existing 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.")
# ==============================================================================
# Main Execution Logic
# ==============================================================================
def initialize_world():
"""Loads initial world state (most recent) if not already done for this session."""
if not st.session_state.get('initial_world_state_loaded', False):
print("Performing initial world load for session...")
saved_worlds = get_saved_worlds()
loaded_successfully = False
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}")
if load_world_state_from_md(latest_world_file_basename): loaded_successfully = True
else: print("Failed to load most recent world, starting empty.")
else: print("No saved worlds found, starting with empty state.")
if not loaded_successfully:
with world_objects_lock: world_objects.clear();
st.session_state.current_world_file = None
st.session_state.initial_world_state_loaded = True
print("Initial world load process complete.")
if __name__ == "__main__":
# 1. Initialize session state
init_session_state()
# --- Removed WebSocket Server Start ---
# 3. Load initial world state (once per session)
initialize_world()
# 4. Render the UI
render_sidebar()
render_main_content()
# --- Removed Periodic Save ---