awacke1 commited on
Commit
a92e352
·
verified ·
1 Parent(s): e3895ed

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +247 -469
app.py CHANGED
@@ -1,7 +1,6 @@
1
- # app.py (Refactored & Consolidated - Corrected Indentation)
2
  import streamlit as st
3
- import asyncio
4
- import websockets
5
  import uuid
6
  from datetime import datetime
7
  import os
@@ -12,76 +11,62 @@ import glob
12
  import base64
13
  import io
14
  import streamlit.components.v1 as components
15
- import edge_tts
16
  import nest_asyncio
17
  import re
18
  import pytz
19
  import shutil
20
- from PyPDF2 import PdfReader
21
  import threading
22
  import json
23
  import zipfile
24
  from dotenv import load_dotenv
25
- from streamlit_marquee import streamlit_marquee # Keep import if used
26
  from collections import defaultdict, Counter
27
- import pandas as pd # Keep for potential fallback logic if needed
28
- from streamlit_js_eval import streamlit_js_eval
29
  from PIL import Image # Needed for paste_image_component
30
 
31
  # ==============================================================================
32
  # Configuration & Constants
33
  # ==============================================================================
34
 
35
- # 🛠️ Patch asyncio for nesting
36
  nest_asyncio.apply()
37
 
38
  # 🎨 Page Config
39
- st.set_page_config(
40
- page_title="🤖🏗️ Shared World Builder 🏆",
41
- page_icon="🏗️",
42
- layout="wide",
43
- initial_sidebar_state="expanded"
44
- )
45
 
46
  # General Constants
47
- icons = '🤖🏗️🗣️💾'
48
- Site_Name = '🤖🏗️ Shared World Builder 🗣️'
49
- START_ROOM = "World Lobby 🌍"
50
- MEDIA_DIR = "." # Base directory for general files
51
  STATE_FILE = "user_state.txt" # For remembering username
52
 
53
- # User/Chat Constants
54
- FUN_USERNAMES = {
55
- "BuilderBot 🤖": "en-US-AriaNeural", "WorldWeaver 🕸️": "en-US-JennyNeural",
56
- "Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
57
- "PixelPainter 🎨": "en-CA-ClaraNeural", "VoxelVortex 🌪️": "en-US-GuyNeural",
58
- "CosmicCrafter ✨": "en-GB-RyanNeural", "GeoGuru 🗺️": "en-AU-WilliamNeural",
59
- "BlockBard 🧱": "en-CA-LiamNeural", "SoundSculptor 🔊": "en-US-AnaNeural",
60
- }
61
- EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values()))
62
- CHAT_DIR = "chat_logs"
63
 
64
  # Audio Constants
65
  AUDIO_CACHE_DIR = "audio_cache"
66
  AUDIO_DIR = "audio_logs"
67
 
68
  # World Builder Constants
69
- SAVED_WORLDS_DIR = "saved_worlds" # Directory for MD world files
70
  PLOT_WIDTH = 50.0 # Needed for JS injection
71
  PLOT_DEPTH = 50.0 # Needed for JS injection
72
- WORLD_STATE_FILE_MD_PREFIX = "🌍_" # Prefix for world save files
73
 
74
  # File Emojis
75
  FILE_EMOJIS = {"md": "📝", "mp3": "🎵", "png": "🖼️", "mp4": "🎥", "zip": "📦", "json": "📄"}
76
 
77
- # --- Mapping Emojis to Primitive Types ---
78
- # Ensure these types match the createPrimitiveMesh function keys in index.html
79
  PRIMITIVE_MAP = {
80
  "🌳": "Tree", "🗿": "Rock", "🏛️": "Simple House", "🌲": "Pine Tree", "🧱": "Brick Wall",
81
  "🔵": "Sphere", "📦": "Cube", "🧴": "Cylinder", "🍦": "Cone", "🍩": "Torus",
82
  "🍄": "Mushroom", "🌵": "Cactus", "🔥": "Campfire", "⭐": "Star", "💎": "Gem",
83
  "🗼": "Tower", "🚧": "Barrier", "⛲": "Fountain", "🏮": "Lantern", "팻": "Sign Post"
84
- # Add more pairs up to ~20 if desired
85
  }
86
 
87
  # --- Directories ---
@@ -90,107 +75,60 @@ for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
90
 
91
  # --- API Keys (Placeholder) ---
92
  load_dotenv()
93
- # ANTHROPIC_KEY = os.getenv('ANTHROPIC_API_KEY', st.secrets.get('ANTHROPIC_API_KEY', ""))
94
- # OPENAI_KEY = os.getenv('OPENAI_API_KEY', st.secrets.get('OPENAI_API_KEY', ""))
95
 
96
- # --- Global State & Locks ---
 
97
  world_objects_lock = threading.Lock()
98
- world_objects = defaultdict(dict) # In-memory world state {obj_id: data}
99
- connected_clients = set() # Holds client_id strings
 
100
 
101
  # ==============================================================================
102
- # Utility Functions
103
  # ==============================================================================
104
-
105
  def get_current_time_str(tz='UTC'):
106
- """Gets formatted timestamp string in specified timezone (default UTC)."""
107
- try:
108
- timezone = pytz.timezone(tz)
109
- now_aware = datetime.now(timezone)
110
- except pytz.UnknownTimeZoneError:
111
- now_aware = datetime.now(pytz.utc)
112
- except Exception as e:
113
- print(f"Timezone error ({tz}), using UTC. Error: {e}")
114
- now_aware = datetime.now(pytz.utc)
115
  return now_aware.strftime('%Y%m%d_%H%M%S')
116
 
117
-
118
  def clean_filename_part(text, max_len=30):
119
- """Cleans a string part for use in a filename."""
120
  if not isinstance(text, str): text = "invalid_name"
121
- text = re.sub(r'\s+', '_', text) # Replace spaces
122
- text = re.sub(r'[^\w\-.]', '', text) # Keep word chars, hyphen, period
123
  return text[:max_len]
124
 
125
- def run_async(async_func, *args, **kwargs):
126
- """Runs an async function safely from a sync context using create_task."""
127
- try:
128
- loop = asyncio.get_running_loop()
129
- return loop.create_task(async_func(*args, **kwargs))
130
- except RuntimeError: # No running loop in this thread
131
- # print(f"Warning: Running async func {async_func.__name__} in new event loop.")
132
- try:
133
- return asyncio.run(async_func(*args, **kwargs))
134
- except Exception as e:
135
- print(f"Error running async func {async_func.__name__} in new loop: {e}")
136
- return None
137
- except Exception as e:
138
- print(f"Error scheduling async task {async_func.__name__}: {e}")
139
- return None
140
-
141
- def ensure_dir(dir_path):
142
- """Creates directory if it doesn't exist."""
143
- os.makedirs(dir_path, exist_ok=True)
144
-
145
- # Ensure directories exist on startup
146
- for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
147
- ensure_dir(d)
148
 
149
  # ==============================================================================
150
- # World State File Handling (Markdown + JSON)
151
  # ==============================================================================
152
-
153
  def generate_world_save_filename(name="World"):
154
- """Generates a filename for saving world state MD files."""
155
- timestamp = get_current_time_str() # Use UTC for consistency
156
- clean_name = clean_filename_part(name)
157
- rand_hash = hashlib.md5(str(time.time()).encode() + name.encode()).hexdigest()[:6] # Seed hash
158
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_name}_{timestamp}_{rand_hash}.md"
159
 
160
  def parse_world_filename(filename):
161
- """Extracts info from filename if possible, otherwise returns defaults."""
162
  basename = os.path.basename(filename)
163
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
164
- core_name = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
165
- parts = core_name.split('_')
166
- if len(parts) >= 3: # Expecting Name_Timestamp_Hash
167
- timestamp_str = parts[-2]
168
- name_parts = parts[:-2]; name = "_".join(name_parts) if name_parts else "Untitled"
169
- dt_obj = None
170
- try:
171
- dt_obj = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S')
172
- dt_obj = pytz.utc.localize(dt_obj)
173
- except (ValueError, pytz.exceptions.AmbiguousTimeError, pytz.exceptions.NonExistentTimeError): dt_obj = None
174
  return {"name": name.replace('_', ' '), "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
175
-
176
  # Fallback
177
- dt_fallback = None
178
- try: mtime = os.path.getmtime(filename); dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
179
  except Exception: pass
180
  return {"name": basename.replace('.md',''), "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
181
 
182
-
183
  def save_world_state_to_md(target_filename_base):
184
- """Saves the current in-memory world state to a specific MD file (basename)."""
185
  global world_objects
186
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
187
  print(f"Acquiring lock to save world state to: {save_path}...")
188
  success = False
189
  with world_objects_lock:
190
- world_data_dict = dict(world_objects)
191
- print(f"Saving {len(world_data_dict)} objects...")
192
- parsed_info = parse_world_filename(save_path)
193
- timestamp_save = get_current_time_str()
194
  md_content = f"""# World State: {parsed_info['name']}
195
  * **File Saved:** {timestamp_save} (UTC)
196
  * **Source Timestamp:** {parsed_info['timestamp']}
@@ -200,29 +138,23 @@ def save_world_state_to_md(target_filename_base):
200
  {json.dumps(world_data_dict, indent=2)}
201
  ```"""
202
  try:
203
- ensure_dir(SAVED_WORLDS_DIR)
204
  with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
205
- print(f"World state saved successfully to {target_filename_base}")
206
- success = True
207
- except Exception as e:
208
- print(f"Error saving world state to {save_path}: {e}")
209
  return success
210
 
211
-
212
  def load_world_state_from_md(filename_base):
213
- """Loads world state from an MD file (basename), updates global state, returns success bool."""
214
  global world_objects
215
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
216
  print(f"Loading world state from MD file: {load_path}...")
217
  if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return False
218
-
219
  try:
220
  with open(load_path, 'r', encoding='utf-8') as f: content = f.read()
221
- json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE) # More robust regex
222
  if not json_match: st.error(f"Could not find valid JSON block in {filename_base}"); return False
223
-
224
  world_data_dict = json.loads(json_match.group(1))
225
-
226
  print(f"Acquiring lock to update world state from {filename_base}...")
227
  with world_objects_lock:
228
  world_objects.clear()
@@ -231,31 +163,28 @@ def load_world_state_from_md(filename_base):
231
  print(f"Loaded {loaded_count} objects from {filename_base}. Lock released.")
232
  st.session_state.current_world_file = filename_base
233
  return True
234
-
235
- except json.JSONDecodeError as e: st.error(f"Invalid JSON found in {filename_base}: {e}"); return False
236
  except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return False
237
 
238
  def get_saved_worlds():
239
  """Scans the saved worlds directory for world MD files and parses them."""
240
  try:
241
- ensure_dir(SAVED_WORLDS_DIR)
242
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
243
  parsed_worlds = [parse_world_filename(f) for f in world_files]
244
  parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
245
  return parsed_worlds
246
- except Exception as e:
247
- print(f"Error scanning saved worlds: {e}"); st.error(f"Could not scan saved worlds: {e}"); return []
248
 
249
  # ==============================================================================
250
  # User State & Session Init
251
  # ==============================================================================
252
 
253
- def save_username(username):
254
  try:
255
  with open(STATE_FILE, 'w') as f: f.write(username)
256
  except Exception as e: print(f"Failed save username: {e}")
257
 
258
- def load_username():
259
  if os.path.exists(STATE_FILE):
260
  try:
261
  with open(STATE_FILE, 'r') as f: return f.read().strip()
@@ -265,66 +194,29 @@ def load_username():
265
  def init_session_state():
266
  """Initializes Streamlit session state variables."""
267
  defaults = {
268
- 'server_running_flag': False, 'server_instance': None, 'server_task': None,
269
- 'active_connections': defaultdict(dict), 'last_chat_update': 0, 'message_input': "",
270
- 'audio_cache': {}, 'tts_voice': "en-US-AriaNeural", 'chat_history': [],
271
- 'marquee_settings': {"background": "#1E1E1E", "color": "#FFFFFF", "font-size": "14px", "animationDuration": "20s", "width": "100%", "lineHeight": "35px"},
272
  'enable_audio': True, 'download_link_cache': {}, 'username': None, 'autosend': False,
273
- 'last_message': "", 'timer_start': time.time(), 'last_sent_transcript': "",
274
- 'last_refresh': time.time(), 'auto_refresh': False, 'refresh_rate': 30,
275
- 'selected_object': 'None', 'initial_world_state_loaded': False,
276
  'current_world_file': None, # Track loaded world filename (basename)
277
- 'operation_timings': {}, 'performance_metrics': defaultdict(list),
278
- 'paste_image_base64': "", 'new_world_name': "MyWorld"
279
  }
280
  for k, v in defaults.items():
281
  if k not in st.session_state: st.session_state[k] = v
282
  # Ensure complex types initialized correctly
283
- if not isinstance(st.session_state.active_connections, defaultdict): st.session_state.active_connections = defaultdict(dict)
284
  if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
285
- if not isinstance(st.session_state.marquee_settings, dict): st.session_state.marquee_settings = defaults['marquee_settings']
286
  if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
287
  if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
288
 
 
289
  # ==============================================================================
290
- # Audio / TTS / Chat / File Handling Helpers
291
  # ==============================================================================
292
-
293
- # --- Text & File Helpers ---
294
- def clean_text_for_tts(text):
295
- if not isinstance(text, str): return "No text"
296
- text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
297
- text = ' '.join(text.split()); return text[:250] or "No text"
298
-
299
- def create_file(content, username, file_type="md", save_path=None):
300
- if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
301
- ensure_dir(os.path.dirname(save_path))
302
- try:
303
- with open(save_path, 'w', encoding='utf-8') as f: f.write(content)
304
- # print(f"Created file: {save_path}"); # Verbose
305
- return save_path
306
- except Exception as e: print(f"Error creating file {save_path}: {e}"); return None
307
-
308
- def get_download_link(file_path, file_type="md"):
309
- """Generates a base64 download link for a given file."""
310
- 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"<small>Not found: {basename}</small>"
311
- try: mtime = os.path.getmtime(file_path)
312
- except OSError: mtime = 0
313
- cache_key = f"dl_{file_path}_{mtime}";
314
- if 'download_link_cache' not in st.session_state: st.session_state.download_link_cache = {}
315
- if cache_key not in st.session_state.download_link_cache:
316
- try:
317
- with open(file_path, "rb") as f: b64 = base64.b64encode(f.read()).decode()
318
- mime_types = {"md": "text/markdown", "mp3": "audio/mpeg", "png": "image/png", "mp4": "video/mp4", "zip": "application/zip", "json": "application/json"}
319
- basename = os.path.basename(file_path)
320
- link_html = f'<a href="data:{mime_types.get(file_type, "application/octet-stream")};base64,{b64}" download="{basename}" title="Download {basename}">{FILE_EMOJIS.get(file_type, "📄")}</a>'
321
- st.session_state.download_link_cache[cache_key] = link_html
322
- except Exception as e: print(f"Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
323
- return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
324
-
325
- # --- Audio / TTS ---
326
- async def async_edge_tts_generate(text, voice, username):
327
- """Generates TTS audio using EdgeTTS and caches the result."""
328
  if not text: return None
329
  cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
330
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
@@ -340,8 +232,7 @@ async def async_edge_tts_generate(text, voice, username):
340
  else: print(f"Audio file {save_path} failed generation."); return None
341
  except Exception as e: print(f"Edge TTS Error: {e}"); return None
342
 
343
- def play_and_download_audio(file_path):
344
- """Displays audio player and download link in Streamlit."""
345
  if file_path and os.path.exists(file_path):
346
  try:
347
  st.audio(file_path)
@@ -349,9 +240,7 @@ def play_and_download_audio(file_path):
349
  st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
350
  except Exception as e: st.error(f"Audio display error for {os.path.basename(file_path)}: {e}")
351
 
352
- # --- Chat ---
353
- async def save_chat_entry(username, message, voice, is_markdown=False):
354
- """Saves chat entry to a file and session state, generates audio."""
355
  if not message.strip(): return None, None
356
  timestamp_str = get_current_time_str();
357
  entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
@@ -361,16 +250,14 @@ async def save_chat_entry(username, message, voice, is_markdown=False):
361
  st.session_state.chat_history.append(entry) # Add to live history
362
  audio_file = None;
363
  if st.session_state.get('enable_audio', True):
364
- tts_message = message # Use original message for TTS
365
  audio_file = await async_edge_tts_generate(tts_message, voice, username)
366
  return md_file, audio_file
367
 
368
- async def load_chat_history():
369
- """Loads chat history from files if session state is empty."""
370
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
371
  if not st.session_state.chat_history:
372
- ensure_dir(CHAT_DIR)
373
- print("Loading chat history from files...")
374
  chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime); loaded_count = 0
375
  temp_history = []
376
  for f_path in chat_files:
@@ -381,8 +268,8 @@ async def load_chat_history():
381
  print(f"Loaded {loaded_count} chat entries from files.")
382
  return st.session_state.chat_history
383
 
384
- # --- File Management ---
385
- def create_zip_of_files(files_to_zip, prefix="Archive"):
386
  if not files_to_zip: st.warning("No files provided to zip."); return None
387
  timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
388
  try:
@@ -394,16 +281,14 @@ def create_zip_of_files(files_to_zip, prefix="Archive"):
394
  print("Zip success."); st.success(f"Created {zip_name}"); return zip_name
395
  except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
396
 
397
- def delete_files(file_patterns, exclude_files=None):
398
- """Deletes files matching patterns, excluding protected/specified files."""
399
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
400
  current_world_base = st.session_state.get('current_world_file')
401
- if current_world_base: protected.append(current_world_base) # Protect currently loaded world
402
  if exclude_files: protected.extend(exclude_files)
403
-
404
  deleted_count = 0; errors = 0
405
  for pattern in file_patterns:
406
- pattern_path = pattern # Assume pattern includes path from os.path.join
407
  print(f"Attempting to delete files matching: {pattern_path}")
408
  try:
409
  files_to_delete = glob.glob(pattern_path)
@@ -422,50 +307,45 @@ def delete_files(file_patterns, exclude_files=None):
422
  st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
423
 
424
 
425
- # --- Image Handling ---
426
- async def save_pasted_image(image, username):
427
  if not image: return None
428
  try:
429
  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)
430
  image.save(filepath, "PNG"); print(f"Pasted image saved: {filepath}"); return filepath
431
  except Exception as e: print(f"Failed image save: {e}"); return None
432
 
433
- def paste_image_component():
434
  pasted_img = None; img_type = None
435
- # Use a button outside the form to trigger processing the text_area value
436
  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', ""))
437
  if st.button("Process Pasted Image 📋", key="process_paste_button"):
438
- st.session_state.paste_image_base64_input = paste_input_value # Store value when button clicked
439
  if paste_input_value and paste_input_value.startswith('data:image'):
440
  try:
441
  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]
442
- st.image(pasted_img, caption=f"Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str # Store processed b64
443
- st.session_state.paste_image_base64_input = "" # Clear input area state
444
- st.experimental_rerun() # Rerun to clear text area visually
445
  except ImportError: st.error("Pillow library needed for image pasting.")
446
- except Exception as e: st.error(f"Img decode err: {e}"); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = ""
447
  else:
448
- st.warning("No valid image data pasted."); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = paste_input_value # Keep invalid data if user wants to edit?
449
-
450
- # Return the image if it was processed successfully *in the previous run* (due to rerun)
451
  processed_b64 = st.session_state.get('paste_image_base64', '')
452
  if processed_b64:
453
- try:
454
- img_bytes = base64.b64decode(processed_b64)
455
- return Image.open(io.BytesIO(img_bytes))
456
- except Exception:
457
- return None # Failed to decode stored b64
458
  return None
459
 
460
 
461
- # --- PDF Processing ---
462
- class AudioProcessor:
463
  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 {}
464
  def _save_metadata(self):
465
  try:
466
  with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f, indent=2)
467
  except Exception as e: print(f"Failed metadata save: {e}")
468
- async def create_audio(self, text, voice='en-US-AriaNeural'):
469
  cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=os.path.join(self.cache_dir, f"{cache_key}.mp3");
470
  if cache_key in self.metadata and os.path.exists(cache_path): return cache_path
471
  text_cleaned=clean_text_for_tts(text);
@@ -477,49 +357,40 @@ class AudioProcessor:
477
  else: return None
478
  except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
479
 
480
- def process_pdf_tab(pdf_file, max_pages, voice):
481
  st.subheader("PDF Processing Results")
482
  if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
483
  audio_processor = AudioProcessor()
484
  try:
485
- reader=PdfReader(pdf_file)
486
  if reader.is_encrypted: st.warning("PDF is encrypted."); return
487
  total_pages=min(len(reader.pages),max_pages);
488
  st.write(f"Processing first {total_pages} pages of '{pdf_file.name}'...")
489
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
490
 
491
- # Nested function to process each page in a thread
492
  def process_page_sync(page_num, page_text):
493
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
494
- try:
495
- # Using asyncio.run here is simpler in thread context than managing loops explicitly
496
- audio_path = asyncio.run(run_async_audio())
497
  if audio_path:
498
  with results_lock: audios[page_num] = audio_path
499
  except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
500
 
501
- # Start threads
502
  for i in range(total_pages):
503
  try:
504
  page = reader.pages[i]; text = page.extract_text();
505
  if text and text.strip(): texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
506
- else: texts[i] = "[No text extracted or page empty]"
507
  except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
508
 
509
- # Wait for threads and display progress
510
  progress_bar = st.progress(0.0, text="Processing pages...")
511
- total_threads = len(page_threads)
512
- start_join_time = time.time()
513
  while any(t.is_alive() for t in page_threads):
514
- completed_threads = total_threads - sum(t.is_alive() for t in page_threads)
515
- progress = completed_threads / total_threads if total_threads > 0 else 1.0
516
  progress_bar.progress(min(progress, 1.0), text=f"Processed {completed_threads}/{total_threads} pages...")
517
- if time.time() - start_join_time > 600: print("PDF processing timed out."); break # 10 min timeout
518
  time.sleep(0.5)
519
  progress_bar.progress(1.0, text="Processing complete.")
520
 
521
- # Display results
522
- st.write("Displaying results:")
523
  for i in range(total_pages):
524
  with st.expander(f"Page {i+1}"):
525
  st.markdown(texts.get(i, "[Error getting text]"))
@@ -530,139 +401,6 @@ def process_pdf_tab(pdf_file, max_pages, voice):
530
  except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
531
 
532
 
533
- # ==============================================================================
534
- # WebSocket Server Logic
535
- # ==============================================================================
536
-
537
- async def register_client(websocket):
538
- client_id = str(websocket.id); connected_clients.add(client_id);
539
- if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict);
540
- st.session_state.active_connections[client_id] = websocket; print(f"Client registered: {client_id}. Total: {len(connected_clients)}")
541
-
542
- async def unregister_client(websocket):
543
- client_id = str(websocket.id); connected_clients.discard(client_id);
544
- if 'active_connections' in st.session_state: st.session_state.active_connections.pop(client_id, None);
545
- print(f"Client unregistered: {client_id}. Remaining: {len(connected_clients)}")
546
-
547
- async def send_safely(websocket, message, client_id):
548
- """Wrapper to send message and handle potential connection errors."""
549
- try: await websocket.send(message)
550
- except websockets.ConnectionClosed: print(f"WS Send failed (Closed) client {client_id}"); raise
551
- except RuntimeError as e: print(f"WS Send failed (Runtime {e}) client {client_id}"); raise
552
- except Exception as e: print(f"WS Send failed (Other {e}) client {client_id}"); raise
553
-
554
- async def broadcast_message(message, exclude_id=None):
555
- """Sends a message to all connected clients except the excluded one."""
556
- if not connected_clients: return
557
- tasks = []; current_client_ids = list(connected_clients); active_connections_copy = st.session_state.active_connections.copy()
558
- for client_id in current_client_ids:
559
- if client_id == exclude_id: continue
560
- websocket = active_connections_copy.get(client_id)
561
- if websocket: tasks.append(asyncio.create_task(send_safely(websocket, message, client_id)))
562
- if tasks: await asyncio.gather(*tasks, return_exceptions=True) # Gather results/exceptions
563
-
564
- async def broadcast_world_update():
565
- """Broadcasts the current world state to all clients."""
566
- with world_objects_lock: current_state_payload = dict(world_objects)
567
- update_msg = json.dumps({"type": "initial_state", "payload": current_state_payload})
568
- print(f"Broadcasting full world update ({len(current_state_payload)} objects)...")
569
- await broadcast_message(update_msg)
570
-
571
- async def websocket_handler(websocket, path):
572
- """Handles WebSocket connections and messages."""
573
- await register_client(websocket); client_id = str(websocket.id);
574
- username = st.session_state.get('username', f"User_{client_id[:4]}")
575
- try: # Send initial state
576
- with world_objects_lock: initial_state_payload = dict(world_objects)
577
- initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload}); await websocket.send(initial_state_msg)
578
- print(f"Sent initial state ({len(initial_state_payload)} objs) to {client_id}")
579
- await broadcast_message(json.dumps({"type": "user_join", "payload": {"username": username, "id": client_id}}), exclude_id=client_id)
580
- except Exception as e: print(f"Error initial phase {client_id}: {e}")
581
-
582
- try: # Message loop
583
- async for message in websocket:
584
- try:
585
- data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
586
- sender_username = payload.get("username", username)
587
-
588
- if msg_type == "chat_message":
589
- chat_text = payload.get('message', ''); voice = payload.get('voice', FUN_USERNAMES.get(sender_username, "en-US-AriaNeural"));
590
- run_async(save_chat_entry, sender_username, chat_text, voice) # Fire-and-forget save
591
- await broadcast_message(message, exclude_id=client_id) # Forward original msg
592
-
593
- elif msg_type == "place_object":
594
- obj_data = payload.get("object_data");
595
- if obj_data and 'obj_id' in obj_data and 'type' in obj_data:
596
- with world_objects_lock: world_objects[obj_data['obj_id']] = obj_data
597
- broadcast_payload = json.dumps({"type": "object_placed", "payload": {"object_data": obj_data, "username": sender_username}});
598
- await broadcast_message(broadcast_payload, exclude_id=client_id)
599
- else: print(f"WS Invalid place_object payload: {payload}")
600
-
601
- elif msg_type == "delete_object":
602
- obj_id = payload.get("obj_id"); removed = False
603
- if obj_id:
604
- with world_objects_lock:
605
- if obj_id in world_objects: del world_objects[obj_id]; removed = True
606
- if removed:
607
- broadcast_payload = json.dumps({"type": "object_deleted", "payload": {"obj_id": obj_id, "username": sender_username}});
608
- await broadcast_message(broadcast_payload, exclude_id=client_id)
609
- else: print(f"WS Invalid delete_object payload: {payload}")
610
-
611
- elif msg_type == "player_position":
612
- pos_data = payload.get("position"); rot_data = payload.get("rotation")
613
- if pos_data:
614
- broadcast_payload = json.dumps({"type": "player_moved", "payload": {"username": sender_username, "id": client_id, "position": pos_data, "rotation": rot_data}});
615
- await broadcast_message(broadcast_payload, exclude_id=client_id)
616
-
617
- except json.JSONDecodeError: print(f"WS Invalid JSON from {client_id}: {message[:100]}...")
618
- except Exception as e: print(f"WS Error processing msg from {client_id}: {e}")
619
- except websockets.ConnectionClosed: print(f"WS Client disconnected: {client_id} ({username})")
620
- except Exception as e: print(f"WS Unexpected handler error {client_id}: {e}")
621
- finally:
622
- await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
623
- await unregister_client(websocket)
624
-
625
-
626
- async def run_websocket_server():
627
- """Coroutine to run the WebSocket server."""
628
- if st.session_state.get('server_running_flag', False): return
629
- st.session_state['server_running_flag'] = True; print("Attempting start WS server 0.0.0.0:8765...")
630
- stop_event = asyncio.Event(); st.session_state['websocket_stop_event'] = stop_event
631
- server = None
632
- try:
633
- server = await websockets.serve(websocket_handler, "0.0.0.0", 8765); st.session_state['server_instance'] = server
634
- print(f"WS server started: {server.sockets[0].getsockname()}. Waiting for stop signal...")
635
- await stop_event.wait()
636
- except OSError as e: print(f"### FAILED START WS SERVER: {e}"); st.session_state['server_running_flag'] = False;
637
- except Exception as e: print(f"### UNEXPECTED WS SERVER ERROR: {e}"); st.session_state['server_running_flag'] = False;
638
- finally:
639
- print("WS server task finishing...");
640
- if server: server.close(); await server.wait_closed(); print("WS server closed.")
641
- st.session_state['server_running_flag'] = False; st.session_state['server_instance'] = None; st.session_state['websocket_stop_event'] = None
642
-
643
- def start_websocket_server_thread():
644
- """Starts the WebSocket server in a separate thread if not already running."""
645
- if st.session_state.get('server_task') and st.session_state.server_task.is_alive(): return
646
- if st.session_state.get('server_running_flag', False): return
647
- print("Creating/starting new server thread.");
648
- def run_loop():
649
- loop = None
650
- try: loop = asyncio.get_running_loop() # Check if loop exists in this thread
651
- except RuntimeError: loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop) # Create if not exists
652
- try: loop.run_until_complete(run_websocket_server())
653
- finally:
654
- if loop and not loop.is_closed():
655
- tasks = asyncio.all_tasks(loop)
656
- if tasks:
657
- for task in tasks: task.cancel()
658
- try: loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
659
- except asyncio.CancelledError: pass # Expected if tasks cancelled
660
- loop.close(); print("Server thread loop closed.")
661
- else: print("Server thread loop already closed or None.")
662
- st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5) # Allow time to start
663
- if not st.session_state.server_task.is_alive(): print("### Server thread failed to stay alive!")
664
-
665
-
666
  # ==============================================================================
667
  # Streamlit UI Layout Functions
668
  # ==============================================================================
@@ -688,43 +426,52 @@ def render_sidebar():
688
  key="world_selector_radio"
689
  )
690
 
 
691
  if selected_basename != current_selection_basename:
692
  st.session_state.current_world_file = selected_basename
693
  if selected_basename:
694
  with st.spinner(f"Loading {selected_basename}..."):
695
  if load_world_state_from_md(selected_basename):
696
- run_async(broadcast_world_update); st.toast("World loaded!", icon="✅")
 
697
  else: st.error("Failed to load world."); st.session_state.current_world_file = None
698
  else: print("Switched to live state."); st.toast("Switched to Live State.")
699
- st.rerun()
700
 
 
701
  st.caption("Download:")
702
  cols = st.columns([4, 1])
703
  with cols[0]: st.write("**Name** (Timestamp)")
704
  with cols[1]: st.write("**DL**", help="Download")
705
  display_limit = 10
706
  for i, world_info in enumerate(saved_worlds):
707
- f_basename = os.path.basename(world_info['filename'])
708
- f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename) # Reconstruct full path
709
- display_name = world_info.get('name', f_basename)
710
- timestamp = world_info.get('timestamp', 'N/A')
711
- if i < display_limit:
712
- col1, col2 = st.columns([4, 1]);
713
- with col1: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
714
- with col2: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
715
- elif i == display_limit: # Show expander starting from the limit item
716
- with st.expander(f"Show {len(saved_worlds)-display_limit} more..."):
717
- # Display the current item first inside expander
718
- col1_exp, col2_exp = st.columns([4, 1]);
719
- with col1_exp: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
720
- with col2_exp: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
721
- # Display rest inside expander
722
- for world_info_more in saved_worlds[display_limit+1:]:
723
- f_basename_more = os.path.basename(world_info_more['filename']); f_fullpath_more = os.path.join(SAVED_WORLDS_DIR, f_basename_more); display_name_more = world_info_more.get('name', f_basename_more); timestamp_more = world_info_more.get('timestamp', 'N/A')
724
- colA, colB = st.columns([4, 1]);
725
- with colA: st.write(f"<small>{display_name_more} ({timestamp_more})</small>", unsafe_allow_html=True)
726
- with colB: st.markdown(get_download_link(f_fullpath_more, "md"), unsafe_allow_html=True)
727
- break # Stop outer loop after showing expander content
 
 
 
 
 
 
728
 
729
 
730
  st.markdown("---")
@@ -738,28 +485,29 @@ def render_sidebar():
738
  if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type, use_container_width=True):
739
  if st.session_state.get('selected_object', 'None') != name:
740
  st.session_state.selected_object = name
741
- run_async(lambda name_arg=name: streamlit_js_eval(f"updateSelectedObjectType({json.dumps(name_arg)});", key=f"update_tool_js_{name_arg}"))
 
742
  st.rerun()
743
  col_idx += 1
744
  st.markdown("---")
745
  if st.button("🚫 Clear Tool", key="clear_tool", use_container_width=True):
746
  if st.session_state.get('selected_object', 'None') != 'None':
747
  st.session_state.selected_object = 'None';
748
- run_async(lambda: streamlit_js_eval("updateSelectedObjectType('None');", key="update_tool_js_none"))
749
  st.rerun()
750
 
751
  st.markdown("---")
752
  st.header("🗣️ Voice & User")
753
- current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0])
754
- username_options = list(FUN_USERNAMES.keys()); current_index = 0
 
755
  try: current_index = username_options.index(current_username)
756
  except ValueError: current_index = 0
757
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
758
  if new_username != st.session_state.username:
759
- old_username = st.session_state.username
760
- change_msg = json.dumps({"type":"user_rename", "payload": {"old_username": old_username, "new_username": new_username}})
761
- run_async(broadcast_message, change_msg)
762
- st.session_state.username = new_username; st.session_state.tts_voice = FUN_USERNAMES[new_username]; save_username(st.session_state.username)
763
  st.rerun()
764
  st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
765
 
@@ -768,68 +516,114 @@ def render_main_content():
768
  """Renders the main content area with tabs."""
769
  st.title(f"{Site_Name} - User: {st.session_state.username}")
770
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  tab_world, tab_chat, tab_pdf, tab_files = st.tabs(["🏗️ World Builder", "🗣️ Chat", "📚 PDF Tools", "📂 Files & Settings"])
772
 
773
  # --- World Builder Tab ---
774
  with tab_world:
775
  st.header("Shared 3D World")
776
- st.caption("Place objects using the sidebar tools. Changes are shared live!")
777
  current_file_basename = st.session_state.get('current_world_file', None)
778
  if current_file_basename:
779
- # Construct full path only if needed for parsing, might fail if file deleted externally
780
  full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
781
- if os.path.exists(full_path_for_parse):
782
- parsed = parse_world_filename(full_path_for_parse)
783
- st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
784
- else: # File might have been deleted
785
- st.warning(f"Loaded world file '{current_file_basename}' seems to be missing.")
786
- st.session_state.current_world_file = None # Clear state
787
- else: st.info("Live State Active (Unsaved changes only persist if saved)")
788
 
789
  # Embed HTML Component
790
  html_file_path = 'index.html'
791
  try:
792
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
793
- ws_url = "ws://localhost:8765" # Default
794
- try: # Get WS URL (Best effort)
795
- from streamlit.web.server.server import Server
796
- session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id)
797
- # Different streamlit versions might have different attribute names
798
- host_attr = getattr(session_info.ws.stream.request, 'host', None) # Try 'host'
799
- if not host_attr and hasattr(session_info.client, 'request'): host_attr = getattr(session_info.client.request, 'host', None) # Try client.request
800
- if host_attr:
801
- server_host = host_attr.split(':')[0]
802
- ws_url = f"ws://{server_host}:8765"
803
- else: raise AttributeError("Host attribute not found")
804
- except Exception as e: print(f"WS URL detection failed ({e}), using localhost.")
805
-
806
  js_injection_script = f"""<script>
807
  window.USERNAME = {json.dumps(st.session_state.username)};
808
- window.WEBSOCKET_URL = {json.dumps(ws_url)};
809
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
810
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
811
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
812
- console.log("Streamlit State Injected:", {{ username: window.USERNAME, websocketUrl: window.WEBSOCKET_URL, selectedObject: window.SELECTED_OBJECT_TYPE }});
 
 
813
  </script>"""
814
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
 
 
 
 
 
815
  components.html(html_content_with_state, height=700, scrolling=False)
 
816
  except FileNotFoundError: st.error(f"CRITICAL ERROR: Could not find '{html_file_path}'.")
817
  except Exception as e: st.error(f"Error loading 3D component: {e}"); st.exception(e)
818
 
819
  # --- Chat Tab ---
820
  with tab_chat:
821
  st.header(f"{START_ROOM} Chat")
822
- # Load history - use run_async result or session state if already loaded
823
  if 'chat_history' not in st.session_state or not st.session_state.chat_history:
824
- chat_history_list = asyncio.run(load_chat_history()) # Blocking load if first time
825
  else: chat_history_list = st.session_state.chat_history
826
-
827
  chat_container = st.container(height=500)
828
  with chat_container:
829
- if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:]))) # Show last 50
830
  else: st.caption("No chat messages yet.")
831
 
832
- # Chat Input Area
833
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
834
  send_button_clicked = st.button("Send Chat 💬", key="send_chat_button")
835
  should_autosend = st.session_state.get('autosend', False) and message_value
@@ -838,12 +632,12 @@ def render_main_content():
838
  message_to_send = message_value
839
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
840
  st.session_state.last_message = message_to_send
841
- voice = FUN_USERNAMES.get(st.session_state.username, "en-US-AriaNeural")
842
- ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}})
843
- # Use helper to run async tasks without blocking main thread excessively
844
- run_async(broadcast_message, ws_message)
845
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
846
- st.session_state.message_input = "" # Clear state for next run
847
  st.rerun()
848
  elif send_button_clicked: st.toast("Message empty or same as last.")
849
  st.checkbox("Autosend Chat", key="autosend")
@@ -854,7 +648,6 @@ def render_main_content():
854
  pdf_file = st.file_uploader("Upload PDF for Audio Conversion", type="pdf", key="pdf_upload")
855
  max_pages = st.slider('Max Pages to Process', 1, 50, 10, key="pdf_pages")
856
  if pdf_file:
857
- # Use a button to trigger potentially long processing
858
  if st.button("Process PDF to Audio", key="process_pdf_button"):
859
  with st.spinner("Processing PDF... This may take time."):
860
  process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice)
@@ -865,26 +658,21 @@ def render_main_content():
865
  st.subheader("💾 World State Management")
866
  current_file_basename = st.session_state.get('current_world_file', None)
867
 
868
- # Save Current Version Button
869
  if current_file_basename:
870
- # Need full path to parse filename correctly
871
  full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
872
- # Check existence before parsing display name
873
- if os.path.exists(full_path_for_parse):
874
- parsed = parse_world_filename(full_path_for_parse)
875
- save_label = f"Save Changes to '{parsed['name']}'"
876
- if st.button(save_label, key="save_current_world", help=f"Overwrite '{current_file_basename}'"):
877
- with st.spinner(f"Overwriting {current_file_basename}..."):
878
- if save_world_state_to_md(current_file_basename): st.success("Current world saved!")
879
- else: st.error("Failed to save world state.")
880
- else:
881
- st.warning(f"Currently selected world file '{current_file_basename}' not found. Cannot save changes to it.")
882
- else:
883
- st.info("Load a world from the sidebar to enable saving changes to it.")
884
 
885
- # Save As New Version Section
886
  st.subheader("Save As New Version")
887
- # Ensure key for text input is unique if it exists elsewhere
888
  new_name_files = st.text_input("New World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
889
  if st.button("💾 Save Live State as New Version", key="save_new_version_files"):
890
  if new_name_files.strip():
@@ -892,19 +680,12 @@ def render_main_content():
892
  with st.spinner(f"Saving new version '{new_name_files}'..."):
893
  if save_world_state_to_md(new_filename_base):
894
  st.success(f"Saved as {new_filename_base}")
895
- st.session_state.current_world_file = new_filename_base
896
- st.session_state.new_world_name = "MyWorld"; st.rerun() # Rerun to update sidebar
897
  else: st.error("Failed to save new version.")
898
  else: st.warning("Please enter a name.")
899
 
900
- st.subheader("⚙️ Server Status")
901
- col_ws, col_clients = st.columns(2)
902
- with col_ws:
903
- 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)
904
- if not server_alive and st.button("Restart Server Thread", key="restart_ws"): start_websocket_server_thread(); st.rerun()
905
- with col_clients: st.metric("Connected Clients", len(connected_clients))
906
 
907
- # File Deletion
908
  st.subheader("🗑️ Delete Files")
909
  st.warning("Deletion is permanent!", icon="⚠️")
910
  col_del1, col_del2, col_del3, col_del4 = st.columns(4)
@@ -913,29 +694,27 @@ def render_main_content():
913
  with col_del2:
914
  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()
915
  with col_del3:
916
- # Use prefix in delete pattern
917
  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()
918
  with col_del4:
919
  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()
920
 
921
- # Download Archives
922
  st.subheader("📦 Download Archives")
 
 
 
 
 
 
 
 
 
923
  zip_files = sorted(glob.glob(os.path.join(MEDIA_DIR,"*.zip")), key=os.path.getmtime, reverse=True)
924
  if zip_files:
925
- col_zip1, col_zip2, col_zip3 = st.columns(3)
926
- # Gather files for zipping inside the button click
927
- with col_zip1:
928
- if st.button("Zip Worlds"): create_zip_of_files(glob.glob(os.path.join(SAVED_WORLDS_DIR, "*.md")), "Worlds")
929
- with col_zip2:
930
- if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
931
- with col_zip3:
932
- 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")
933
-
934
- st.caption("Existing Zip Files:")
935
- for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
936
  else:
937
- # Correct indentation for the else block
938
- st.caption("No zip archives found.")
939
 
940
  # ==============================================================================
941
  # Main Execution Logic
@@ -952,25 +731,24 @@ def initialize_world():
952
  print(f"Loading most recent world on startup: {latest_world_file_basename}")
953
  if load_world_state_from_md(latest_world_file_basename): loaded_successfully = True
954
  else: print("Failed to load most recent world, starting empty.")
955
- else: print("No saved worlds found, starting with empty state.")
956
  if not loaded_successfully:
957
- with world_objects_lock: world_objects.clear(); # Ensure empty state
958
  st.session_state.current_world_file = None
959
  st.session_state.initial_world_state_loaded = True
960
  print("Initial world load process complete.")
961
 
962
  if __name__ == "__main__":
963
- # 1. Initialize session state (must be first)
964
  init_session_state()
965
 
966
- # 2. Start WebSocket server thread if needed
967
- server_thread = st.session_state.get('server_task'); server_alive = server_thread is not None and server_thread.is_alive()
968
- if not st.session_state.get('server_running_flag', False) and not server_alive: start_websocket_server_thread()
969
- elif server_alive and not st.session_state.get('server_running_flag', False): st.session_state.server_running_flag = True # Correct flag
970
 
971
  # 3. Load initial world state (once per session)
972
  initialize_world()
973
 
974
  # 4. Render the UI
975
  render_sidebar()
976
- render_main_content()
 
 
 
1
+ # app.py (Simplified - No WebSockets, Direct JS Communication)
2
  import streamlit as st
3
+ import asyncio # Keep for TTS
 
4
  import uuid
5
  from datetime import datetime
6
  import os
 
11
  import base64
12
  import io
13
  import streamlit.components.v1 as components
14
+ import edge_tts # Keep for TTS
15
  import nest_asyncio
16
  import re
17
  import pytz
18
  import shutil
19
+ from PyPDF2 import PdfReader # Keep if PDF tab is kept
20
  import threading
21
  import json
22
  import zipfile
23
  from dotenv import load_dotenv
24
+ from streamlit_marquee import streamlit_marquee # Keep if used
25
  from collections import defaultdict, Counter
26
+ # import pandas as pd # Only needed if CSV fallback kept, can likely remove
27
+ from streamlit_js_eval import streamlit_js_eval, sync # Use sync for direct calls
28
  from PIL import Image # Needed for paste_image_component
29
 
30
  # ==============================================================================
31
  # Configuration & Constants
32
  # ==============================================================================
33
 
34
+ # 🛠️ Patch asyncio for nesting (May still be needed for TTS)
35
  nest_asyncio.apply()
36
 
37
  # 🎨 Page Config
38
+ st.set_page_config(page_title="🏗️ Simpler World Builder 🏆", page_icon="🏗️", layout="wide", initial_sidebar_state="expanded")
 
 
 
 
 
39
 
40
  # General Constants
41
+ Site_Name = '🏗️ Simpler World Builder'
42
+ MEDIA_DIR = "."
 
 
43
  STATE_FILE = "user_state.txt" # For remembering username
44
 
45
+ # User/Chat Constants (Simplified - No longer needed for WS identity)
46
+ # FUN_USERNAMES = { ... } # Can remove if not used elsewhere
47
+ # EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values())) # Define default voice directly if needed
48
+ DEFAULT_TTS_VOICE = "en-US-AriaNeural"
49
+ CHAT_DIR = "chat_logs" # Keep if chat tab remains
 
 
 
 
 
50
 
51
  # Audio Constants
52
  AUDIO_CACHE_DIR = "audio_cache"
53
  AUDIO_DIR = "audio_logs"
54
 
55
  # World Builder Constants
56
+ SAVED_WORLDS_DIR = "saved_worlds"
57
  PLOT_WIDTH = 50.0 # Needed for JS injection
58
  PLOT_DEPTH = 50.0 # Needed for JS injection
59
+ WORLD_STATE_FILE_MD_PREFIX = "🌍_"
60
 
61
  # File Emojis
62
  FILE_EMOJIS = {"md": "📝", "mp3": "🎵", "png": "🖼️", "mp4": "🎥", "zip": "📦", "json": "📄"}
63
 
64
+ # Mapping Emojis to Primitive Types
 
65
  PRIMITIVE_MAP = {
66
  "🌳": "Tree", "🗿": "Rock", "🏛️": "Simple House", "🌲": "Pine Tree", "🧱": "Brick Wall",
67
  "🔵": "Sphere", "📦": "Cube", "🧴": "Cylinder", "🍦": "Cone", "🍩": "Torus",
68
  "🍄": "Mushroom", "🌵": "Cactus", "🔥": "Campfire", "⭐": "Star", "💎": "Gem",
69
  "🗼": "Tower", "🚧": "Barrier", "⛲": "Fountain", "🏮": "Lantern", "팻": "Sign Post"
 
70
  }
71
 
72
  # --- Directories ---
 
75
 
76
  # --- API Keys (Placeholder) ---
77
  load_dotenv()
 
 
78
 
79
+ # --- Global State & Lock ---
80
+ # Lock remains important for modifying the shared dictionary from potentially different Streamlit sessions/reruns
81
  world_objects_lock = threading.Lock()
82
+ # THIS NOW REPRESENTS THE STATE LOADED FROM A FILE OR BUILT UP LOCALLY BEFORE SAVING
83
+ # IT IS *NOT* A LIVE REPRESENTATION OF ALL USERS' ACTIONS
84
+ world_objects = defaultdict(dict)
85
 
86
  # ==============================================================================
87
+ # Utility Functions (Keep relevant ones)
88
  # ==============================================================================
 
89
  def get_current_time_str(tz='UTC'):
90
+ try: timezone = pytz.timezone(tz); now_aware = datetime.now(timezone)
91
+ except Exception: now_aware = datetime.now(pytz.utc)
 
 
 
 
 
 
 
92
  return now_aware.strftime('%Y%m%d_%H%M%S')
93
 
 
94
  def clean_filename_part(text, max_len=30):
 
95
  if not isinstance(text, str): text = "invalid_name"
96
+ text = re.sub(r'\s+', '_', text); text = re.sub(r'[^\w\-.]', '', text)
 
97
  return text[:max_len]
98
 
99
+ def ensure_dir(dir_path): os.makedirs(dir_path, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  # ==============================================================================
102
+ # World State File Handling (Markdown + JSON) - Keep as is
103
  # ==============================================================================
 
104
  def generate_world_save_filename(name="World"):
105
+ timestamp = get_current_time_str(); clean_name = clean_filename_part(name);
106
+ rand_hash = hashlib.md5(str(time.time()).encode() + name.encode()).hexdigest()[:6]
 
 
107
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_name}_{timestamp}_{rand_hash}.md"
108
 
109
  def parse_world_filename(filename):
 
110
  basename = os.path.basename(filename)
111
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
112
+ core_name = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]; parts = core_name.split('_')
113
+ if len(parts) >= 3:
114
+ timestamp_str = parts[-2]; name_parts = parts[:-2]; name = "_".join(name_parts) if name_parts else "Untitled"; dt_obj = None
115
+ try: dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
116
+ except Exception: dt_obj = None
 
 
 
 
 
117
  return {"name": name.replace('_', ' '), "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
 
118
  # Fallback
119
+ dt_fallback = None; try: mtime = os.path.getmtime(filename); dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
 
120
  except Exception: pass
121
  return {"name": basename.replace('.md',''), "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
122
 
 
123
  def save_world_state_to_md(target_filename_base):
124
+ """Saves the current 'world_objects' state to a specific MD file."""
125
  global world_objects
126
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
127
  print(f"Acquiring lock to save world state to: {save_path}...")
128
  success = False
129
  with world_objects_lock:
130
+ world_data_dict = dict(world_objects); print(f"Saving {len(world_data_dict)} objects...")
131
+ parsed_info = parse_world_filename(save_path); timestamp_save = get_current_time_str()
 
 
132
  md_content = f"""# World State: {parsed_info['name']}
133
  * **File Saved:** {timestamp_save} (UTC)
134
  * **Source Timestamp:** {parsed_info['timestamp']}
 
138
  {json.dumps(world_data_dict, indent=2)}
139
  ```"""
140
  try:
141
+ ensure_dir(SAVED_WORLDS_DIR);
142
  with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
143
+ print(f"World state saved successfully to {target_filename_base}"); success = True
144
+ except Exception as e: print(f"Error saving world state to {save_path}: {e}")
 
 
145
  return success
146
 
 
147
  def load_world_state_from_md(filename_base):
148
+ """Loads world state from MD, updates global 'world_objects', returns success bool."""
149
  global world_objects
150
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
151
  print(f"Loading world state from MD file: {load_path}...")
152
  if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return False
 
153
  try:
154
  with open(load_path, 'r', encoding='utf-8') as f: content = f.read()
155
+ json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE)
156
  if not json_match: st.error(f"Could not find valid JSON block in {filename_base}"); return False
 
157
  world_data_dict = json.loads(json_match.group(1))
 
158
  print(f"Acquiring lock to update world state from {filename_base}...")
159
  with world_objects_lock:
160
  world_objects.clear()
 
163
  print(f"Loaded {loaded_count} objects from {filename_base}. Lock released.")
164
  st.session_state.current_world_file = filename_base
165
  return True
 
 
166
  except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return False
167
 
168
  def get_saved_worlds():
169
  """Scans the saved worlds directory for world MD files and parses them."""
170
  try:
171
+ ensure_dir(SAVED_WORLDS_DIR);
172
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
173
  parsed_worlds = [parse_world_filename(f) for f in world_files]
174
  parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
175
  return parsed_worlds
176
+ except Exception as e: print(f"Error scanning saved worlds: {e}"); st.error(f"Could not scan saved worlds: {e}"); return []
 
177
 
178
  # ==============================================================================
179
  # User State & Session Init
180
  # ==============================================================================
181
 
182
+ def save_username(username): # Keep this simple utility
183
  try:
184
  with open(STATE_FILE, 'w') as f: f.write(username)
185
  except Exception as e: print(f"Failed save username: {e}")
186
 
187
+ def load_username(): # Keep this
188
  if os.path.exists(STATE_FILE):
189
  try:
190
  with open(STATE_FILE, 'r') as f: return f.read().strip()
 
194
  def init_session_state():
195
  """Initializes Streamlit session state variables."""
196
  defaults = {
197
+ # Removed WS state: server_running_flag, server_instance, server_task, active_connections
198
+ 'last_chat_update': 0, 'message_input': "",
199
+ 'audio_cache': {}, 'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [],
 
200
  'enable_audio': True, 'download_link_cache': {}, 'username': None, 'autosend': False,
201
+ 'last_message': "",
202
+ 'selected_object': 'None', # Current building tool
203
+ 'initial_world_state_loaded': False, # Flag to load state only once per session
204
  'current_world_file': None, # Track loaded world filename (basename)
205
+ 'paste_image_base64': "", 'new_world_name': "MyWorld",
206
+ 'js_event_data': None # NEW: To store data pushed from JS
207
  }
208
  for k, v in defaults.items():
209
  if k not in st.session_state: st.session_state[k] = v
210
  # Ensure complex types initialized correctly
 
211
  if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
 
212
  if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
213
  if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
214
 
215
+
216
  # ==============================================================================
217
+ # Audio / TTS / Chat / File Handling Helpers (Largely unchanged, remove WS calls)
218
  # ==============================================================================
219
+ async def async_edge_tts_generate(text, voice, username): # No change needed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  if not text: return None
221
  cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
222
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
 
232
  else: print(f"Audio file {save_path} failed generation."); return None
233
  except Exception as e: print(f"Edge TTS Error: {e}"); return None
234
 
235
+ def play_and_download_audio(file_path): # No change needed
 
236
  if file_path and os.path.exists(file_path):
237
  try:
238
  st.audio(file_path)
 
240
  st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
241
  except Exception as e: st.error(f"Audio display error for {os.path.basename(file_path)}: {e}")
242
 
243
+ async def save_chat_entry(username, message, voice, is_markdown=False): # No change needed
 
 
244
  if not message.strip(): return None, None
245
  timestamp_str = get_current_time_str();
246
  entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
 
250
  st.session_state.chat_history.append(entry) # Add to live history
251
  audio_file = None;
252
  if st.session_state.get('enable_audio', True):
253
+ tts_message = message
254
  audio_file = await async_edge_tts_generate(tts_message, voice, username)
255
  return md_file, audio_file
256
 
257
+ async def load_chat_history(): # No change needed
 
258
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
259
  if not st.session_state.chat_history:
260
+ ensure_dir(CHAT_DIR); print("Loading chat history from files...")
 
261
  chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime); loaded_count = 0
262
  temp_history = []
263
  for f_path in chat_files:
 
268
  print(f"Loaded {loaded_count} chat entries from files.")
269
  return st.session_state.chat_history
270
 
271
+ # --- File Management (Keep Create Zip, Delete Files) ---
272
+ def create_zip_of_files(files_to_zip, prefix="Archive"): # Keep as is
273
  if not files_to_zip: st.warning("No files provided to zip."); return None
274
  timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
275
  try:
 
281
  print("Zip success."); st.success(f"Created {zip_name}"); return zip_name
282
  except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
283
 
284
+ def delete_files(file_patterns, exclude_files=None): # Keep as is
 
285
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
286
  current_world_base = st.session_state.get('current_world_file')
287
+ if current_world_base: protected.append(current_world_base)
288
  if exclude_files: protected.extend(exclude_files)
 
289
  deleted_count = 0; errors = 0
290
  for pattern in file_patterns:
291
+ pattern_path = pattern
292
  print(f"Attempting to delete files matching: {pattern_path}")
293
  try:
294
  files_to_delete = glob.glob(pattern_path)
 
307
  st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
308
 
309
 
310
+ # --- Image Handling (Keep paste component logic) ---
311
+ async def save_pasted_image(image, username): # Keep as is
312
  if not image: return None
313
  try:
314
  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)
315
  image.save(filepath, "PNG"); print(f"Pasted image saved: {filepath}"); return filepath
316
  except Exception as e: print(f"Failed image save: {e}"); return None
317
 
318
+ def paste_image_component(): # Keep as is
319
  pasted_img = None; img_type = None
 
320
  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', ""))
321
  if st.button("Process Pasted Image 📋", key="process_paste_button"):
322
+ st.session_state.paste_image_base64_input = paste_input_value
323
  if paste_input_value and paste_input_value.startswith('data:image'):
324
  try:
325
  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]
326
+ st.image(pasted_img, caption=f"Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str
327
+ st.session_state.paste_image_base64_input = "" # Clear input state on success
328
+ st.rerun()
329
  except ImportError: st.error("Pillow library needed for image pasting.")
330
+ 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
331
  else:
332
+ st.warning("No valid image data pasted."); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = paste_input_value
333
+ # Return image if processed in previous run
 
334
  processed_b64 = st.session_state.get('paste_image_base64', '')
335
  if processed_b64:
336
+ try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
337
+ except Exception: return None
 
 
 
338
  return None
339
 
340
 
341
+ # --- PDF Processing (Keep if desired) ---
342
+ class AudioProcessor: # Keep as is
343
  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 {}
344
  def _save_metadata(self):
345
  try:
346
  with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f, indent=2)
347
  except Exception as e: print(f"Failed metadata save: {e}")
348
+ async def create_audio(self, text, voice=DEFAULT_TTS_VOICE):
349
  cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=os.path.join(self.cache_dir, f"{cache_key}.mp3");
350
  if cache_key in self.metadata and os.path.exists(cache_path): return cache_path
351
  text_cleaned=clean_text_for_tts(text);
 
357
  else: return None
358
  except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
359
 
360
+ def process_pdf_tab(pdf_file, max_pages, voice): # Keep as is
361
  st.subheader("PDF Processing Results")
362
  if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
363
  audio_processor = AudioProcessor()
364
  try:
365
+ reader=PdfReader(pdf_file);
366
  if reader.is_encrypted: st.warning("PDF is encrypted."); return
367
  total_pages=min(len(reader.pages),max_pages);
368
  st.write(f"Processing first {total_pages} pages of '{pdf_file.name}'...")
369
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
370
 
 
371
  def process_page_sync(page_num, page_text):
372
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
373
+ try: audio_path = asyncio.run(run_async_audio())
 
 
374
  if audio_path:
375
  with results_lock: audios[page_num] = audio_path
376
  except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
377
 
 
378
  for i in range(total_pages):
379
  try:
380
  page = reader.pages[i]; text = page.extract_text();
381
  if text and text.strip(): texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
382
+ else: texts[i] = "[No text extracted]"
383
  except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
384
 
 
385
  progress_bar = st.progress(0.0, text="Processing pages...")
386
+ total_threads = len(page_threads); start_join_time = time.time()
 
387
  while any(t.is_alive() for t in page_threads):
388
+ 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
 
389
  progress_bar.progress(min(progress, 1.0), text=f"Processed {completed_threads}/{total_threads} pages...")
390
+ if time.time() - start_join_time > 600: print("PDF processing timed out."); break
391
  time.sleep(0.5)
392
  progress_bar.progress(1.0, text="Processing complete.")
393
 
 
 
394
  for i in range(total_pages):
395
  with st.expander(f"Page {i+1}"):
396
  st.markdown(texts.get(i, "[Error getting text]"))
 
401
  except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
402
 
403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  # ==============================================================================
405
  # Streamlit UI Layout Functions
406
  # ==============================================================================
 
426
  key="world_selector_radio"
427
  )
428
 
429
+ # Handle selection change
430
  if selected_basename != current_selection_basename:
431
  st.session_state.current_world_file = selected_basename
432
  if selected_basename:
433
  with st.spinner(f"Loading {selected_basename}..."):
434
  if load_world_state_from_md(selected_basename):
435
+ st.toast("World loaded!", icon="✅")
436
+ # Rerun will cause component to reload with injected state from world_objects
437
  else: st.error("Failed to load world."); st.session_state.current_world_file = None
438
  else: print("Switched to live state."); st.toast("Switched to Live State.")
439
+ st.rerun() # Rerun needed to update main view and component state
440
 
441
+ # Download Links for Worlds
442
  st.caption("Download:")
443
  cols = st.columns([4, 1])
444
  with cols[0]: st.write("**Name** (Timestamp)")
445
  with cols[1]: st.write("**DL**", help="Download")
446
  display_limit = 10
447
  for i, world_info in enumerate(saved_worlds):
448
+ f_basename = os.path.basename(world_info['filename']); f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename);
449
+ display_name = world_info.get('name', f_basename); timestamp = world_info.get('timestamp', 'N/A')
450
+ is_last_displayed = i == display_limit -1
451
+ show_expander_trigger = len(saved_worlds) > display_limit and is_last_displayed
452
+
453
+ # Display logic adjusted for expander
454
+ if i < display_limit or (show_expander_trigger and i == display_limit): # Display items up to limit, and first item in expander
455
+ container = st if i < display_limit else st.expander(f"Show {len(saved_worlds)-display_limit} more...")
456
+ with container:
457
+ # If it's the trigger for expander, display current item first
458
+ if show_expander_trigger and i == display_limit:
459
+ col1_exp, col2_exp = st.columns([4, 1]);
460
+ with col1_exp: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
461
+ with col2_exp: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
462
+ # Display items before the limit, or items after limit within expander
463
+ elif i < display_limit:
464
+ col1, col2 = st.columns([4, 1]);
465
+ with col1: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
466
+ with col2: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
467
+ # Now display remaining items inside the expander if triggered
468
+ if show_expander_trigger and i == display_limit:
469
+ for world_info_more in saved_worlds[display_limit+1:]:
470
+ 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')
471
+ colA, colB = st.columns([4, 1]);
472
+ with colA: st.write(f"<small>{dn_more} ({ts_more})</small>", unsafe_allow_html=True)
473
+ with colB: st.markdown(get_download_link(f_fp_more, "md"), unsafe_allow_html=True)
474
+ if show_expander_trigger: break # Stop outer loop after processing expander
475
 
476
 
477
  st.markdown("---")
 
485
  if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type, use_container_width=True):
486
  if st.session_state.get('selected_object', 'None') != name:
487
  st.session_state.selected_object = name
488
+ # Fire and forget JS update
489
+ sync(js_code=f"updateSelectedObjectType({json.dumps(name)});", key=f"update_tool_js_{name}")
490
  st.rerun()
491
  col_idx += 1
492
  st.markdown("---")
493
  if st.button("🚫 Clear Tool", key="clear_tool", use_container_width=True):
494
  if st.session_state.get('selected_object', 'None') != 'None':
495
  st.session_state.selected_object = 'None';
496
+ sync(js_code="updateSelectedObjectType('None');", key="update_tool_js_none")
497
  st.rerun()
498
 
499
  st.markdown("---")
500
  st.header("🗣️ Voice & User")
501
+ current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0]) if FUN_USERNAMES else "DefaultUser"
502
+ username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
503
+ current_index = 0
504
  try: current_index = username_options.index(current_username)
505
  except ValueError: current_index = 0
506
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
507
  if new_username != st.session_state.username:
508
+ st.session_state.username = new_username;
509
+ st.session_state.tts_voice = FUN_USERNAMES.get(new_username, DEFAULT_TTS_VOICE); # Use get with default
510
+ save_username(st.session_state.username)
 
511
  st.rerun()
512
  st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
513
 
 
516
  """Renders the main content area with tabs."""
517
  st.title(f"{Site_Name} - User: {st.session_state.username}")
518
 
519
+ # Check for JS event data pushed from the component
520
+ # Use a unique key for the event listener setup
521
+ js_event = streamlit_js_eval(
522
+ js_code="""
523
+ // Ensure the listener is attached only once
524
+ if (!window.streamlitComponentEventAttached) {
525
+ window.addEventListener('message', event => {
526
+ // Basic check for component message structure
527
+ if (event.data.type === 'streamlit:componentChanged' && event.data.data) {
528
+ // Send the component's value back to Python under a specific key
529
+ Streamlit.setComponentValue({ type: 'js_event', payload: event.data.data });
530
+ }
531
+ });
532
+ window.streamlitComponentEventAttached = true; // Flag it
533
+ }
534
+ // Return null initially or on standard runs
535
+ null;
536
+ """,
537
+ key="component_event_listener" # Unique key for this setup code
538
+ )
539
+
540
+ # Process event data if received
541
+ if js_event and isinstance(js_event, dict) and js_event.get("type") == "js_event":
542
+ event_payload = js_event.get("payload")
543
+ print(f"Received JS Event Payload: {event_payload}") # Debug log
544
+ if isinstance(event_payload, dict):
545
+ action = event_payload.get("action")
546
+ data = event_payload.get("data")
547
+
548
+ if action == "object_placed" and data:
549
+ print(f"Processing object placed event for {data.get('obj_id')}")
550
+ # Add object to in-memory state (use lock)
551
+ with world_objects_lock:
552
+ world_objects[data['obj_id']] = data
553
+ # Clear the event from state to prevent reprocessing
554
+ # This might require careful handling if rerun happens before processing fully
555
+ # Maybe process directly instead of setting session state? Let's try direct.
556
+ # st.session_state.js_event_data = None # Clear trigger? No, js_event is the trigger value itself.
557
+ st.toast(f"Placed {data.get('type', 'object')}!", icon="➕")
558
+ # No rerun needed here, object added to server memory. User sees local obj.
559
+ # Save happens manually via button.
560
+
561
+ elif action == "object_deleted" and data:
562
+ obj_id = data.get("obj_id")
563
+ if obj_id:
564
+ print(f"Processing object deleted event for {obj_id}")
565
+ with world_objects_lock:
566
+ if obj_id in world_objects:
567
+ del world_objects[obj_id]
568
+ st.toast(f"Deleted object {obj_id[:6]}...", icon="➖")
569
+ else:
570
+ st.toast(f"Object {obj_id[:6]}... already deleted.", icon="❓")
571
+ # st.session_state.js_event_data = None
572
+
573
+ # Add more event types as needed
574
+
575
+ # Define Tabs
576
  tab_world, tab_chat, tab_pdf, tab_files = st.tabs(["🏗️ World Builder", "🗣️ Chat", "📚 PDF Tools", "📂 Files & Settings"])
577
 
578
  # --- World Builder Tab ---
579
  with tab_world:
580
  st.header("Shared 3D World")
581
+ st.caption("Place objects using sidebar tools. Use Files tab to Save/Load.")
582
  current_file_basename = st.session_state.get('current_world_file', None)
583
  if current_file_basename:
 
584
  full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
585
+ 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}`)")
586
+ else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
587
+ else: st.info("Live State Active (Save As New Version to persist)")
 
 
 
 
588
 
589
  # Embed HTML Component
590
  html_file_path = 'index.html'
591
  try:
592
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
593
+ ws_url = "ws://localhost:8765" # Default (WebSockets not used for state sync now)
594
+ # Inject state needed by JS (like current tool)
 
 
 
 
 
 
 
 
 
 
 
595
  js_injection_script = f"""<script>
596
  window.USERNAME = {json.dumps(st.session_state.username)};
597
+ // window.WEBSOCKET_URL = {json.dumps(ws_url)}; // No longer needed for state sync
598
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
599
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
600
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
601
+ // Send the current world objects for initial draw
602
+ window.INITIAL_WORLD_OBJECTS = {json.dumps(dict(world_objects))};
603
+ console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjects: {len(world_objects)} }});
604
  </script>"""
605
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
606
+ # Use a key for the component to potentially receive data via setComponentValue
607
+ # component_key = "three_world_component" # Define a key
608
+ # html_component_value = components.html(html_content_with_state, height=700, scrolling=False, key=component_key)
609
+
610
+ # Using streamlit_js_eval to receive events is more explicit
611
  components.html(html_content_with_state, height=700, scrolling=False)
612
+
613
  except FileNotFoundError: st.error(f"CRITICAL ERROR: Could not find '{html_file_path}'.")
614
  except Exception as e: st.error(f"Error loading 3D component: {e}"); st.exception(e)
615
 
616
  # --- Chat Tab ---
617
  with tab_chat:
618
  st.header(f"{START_ROOM} Chat")
 
619
  if 'chat_history' not in st.session_state or not st.session_state.chat_history:
620
+ chat_history_list = asyncio.run(load_chat_history())
621
  else: chat_history_list = st.session_state.chat_history
 
622
  chat_container = st.container(height=500)
623
  with chat_container:
624
+ if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:])))
625
  else: st.caption("No chat messages yet.")
626
 
 
627
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
628
  send_button_clicked = st.button("Send Chat 💬", key="send_chat_button")
629
  should_autosend = st.session_state.get('autosend', False) and message_value
 
632
  message_to_send = message_value
633
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
634
  st.session_state.last_message = message_to_send
635
+ voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
636
+ # Directly save and update history, no WS needed for chat display?
637
+ # If chat needs to be real-time, WS is still required for that part.
638
+ # Let's assume chat is NOT real-time for now to simplify fully.
639
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
640
+ st.session_state.message_input = ""
641
  st.rerun()
642
  elif send_button_clicked: st.toast("Message empty or same as last.")
643
  st.checkbox("Autosend Chat", key="autosend")
 
648
  pdf_file = st.file_uploader("Upload PDF for Audio Conversion", type="pdf", key="pdf_upload")
649
  max_pages = st.slider('Max Pages to Process', 1, 50, 10, key="pdf_pages")
650
  if pdf_file:
 
651
  if st.button("Process PDF to Audio", key="process_pdf_button"):
652
  with st.spinner("Processing PDF... This may take time."):
653
  process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice)
 
658
  st.subheader("💾 World State Management")
659
  current_file_basename = st.session_state.get('current_world_file', None)
660
 
 
661
  if current_file_basename:
 
662
  full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
663
+ if os.path.exists(full_path_for_parse): parsed = parse_world_filename(full_path_for_parse); save_label = f"Save Changes to '{parsed['name']}'"
664
+ else: parsed = None; save_label = f"Save Changes (File '{current_file_basename}' missing)"
665
+
666
+ if st.button(save_label, key="save_current_world", help=f"Overwrite '{current_file_basename}'"):
667
+ if not os.path.exists(full_path_for_parse):
668
+ st.error(f"Cannot save, file '{current_file_basename}' no longer exists.")
669
+ else:
670
+ with st.spinner(f"Overwriting {current_file_basename}..."):
671
+ if save_world_state_to_md(current_file_basename): st.success("Current world saved!")
672
+ else: st.error("Failed to save world state.")
673
+ else: st.info("Load a world from the sidebar or use 'Save As New Version' below.")
 
674
 
 
675
  st.subheader("Save As New Version")
 
676
  new_name_files = st.text_input("New World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
677
  if st.button("💾 Save Live State as New Version", key="save_new_version_files"):
678
  if new_name_files.strip():
 
680
  with st.spinner(f"Saving new version '{new_name_files}'..."):
681
  if save_world_state_to_md(new_filename_base):
682
  st.success(f"Saved as {new_filename_base}")
683
+ st.session_state.current_world_file = new_filename_base; st.session_state.new_world_name = "MyWorld"; st.rerun()
 
684
  else: st.error("Failed to save new version.")
685
  else: st.warning("Please enter a name.")
686
 
687
+ # --- Removed Server Status Section ---
 
 
 
 
 
688
 
 
689
  st.subheader("🗑️ Delete Files")
690
  st.warning("Deletion is permanent!", icon="⚠️")
691
  col_del1, col_del2, col_del3, col_del4 = st.columns(4)
 
694
  with col_del2:
695
  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()
696
  with col_del3:
 
697
  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()
698
  with col_del4:
699
  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()
700
 
 
701
  st.subheader("📦 Download Archives")
702
+ # Zip Buttons
703
+ col_zip1, col_zip2, col_zip3 = st.columns(3)
704
+ with col_zip1:
705
+ if st.button("Zip Worlds"): create_zip_of_files(glob.glob(os.path.join(SAVED_WORLDS_DIR, "*.md")), "Worlds")
706
+ with col_zip2:
707
+ if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
708
+ with col_zip3:
709
+ 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")
710
+ # List existing zips
711
  zip_files = sorted(glob.glob(os.path.join(MEDIA_DIR,"*.zip")), key=os.path.getmtime, reverse=True)
712
  if zip_files:
713
+ st.caption("Existing Zip Files:")
714
+ for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
715
  else:
716
+ st.caption("No zip archives found.")
717
+
718
 
719
  # ==============================================================================
720
  # Main Execution Logic
 
731
  print(f"Loading most recent world on startup: {latest_world_file_basename}")
732
  if load_world_state_from_md(latest_world_file_basename): loaded_successfully = True
733
  else: print("Failed to load most recent world, starting empty.")
734
+ else: print("No saved worlds found, starting with empty state.")
735
  if not loaded_successfully:
736
+ with world_objects_lock: world_objects.clear();
737
  st.session_state.current_world_file = None
738
  st.session_state.initial_world_state_loaded = True
739
  print("Initial world load process complete.")
740
 
741
  if __name__ == "__main__":
742
+ # 1. Initialize session state
743
  init_session_state()
744
 
745
+ # --- Removed WebSocket Server Start ---
 
 
 
746
 
747
  # 3. Load initial world state (once per session)
748
  initialize_world()
749
 
750
  # 4. Render the UI
751
  render_sidebar()
752
+ render_main_content()
753
+
754
+ # --- Removed Periodic Save ---