awacke1 commited on
Commit
11a0038
ยท
verified ยท
1 Parent(s): f234b10

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +285 -239
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # app.py (Refactored & Consolidated)
2
  import streamlit as st
3
  import asyncio
4
  import websockets
@@ -26,7 +26,7 @@ from streamlit_marquee import streamlit_marquee
26
  from collections import defaultdict, Counter
27
  import pandas as pd # Still used for fallback CSV load? Keep for now.
28
  from streamlit_js_eval import streamlit_js_eval
29
- from PIL import Image
30
 
31
  # ==============================================================================
32
  # Configuration & Constants
@@ -74,29 +74,20 @@ WORLD_STATE_FILE_MD_PREFIX = "๐ŸŒ_" # Prefix for world save files
74
  # File Emojis
75
  FILE_EMOJIS = {"md": "๐Ÿ“", "mp3": "๐ŸŽต", "png": "๐Ÿ–ผ๏ธ", "mp4": "๐ŸŽฅ", "zip": "๐Ÿ“ฆ", "json": "๐Ÿ“„"}
76
 
77
- # API Keys (Load from .env or secrets)
 
 
 
 
 
78
  load_dotenv()
79
  # ANTHROPIC_KEY = os.getenv('ANTHROPIC_API_KEY', st.secrets.get('ANTHROPIC_API_KEY', ""))
80
  # OPENAI_KEY = os.getenv('OPENAI_API_KEY', st.secrets.get('OPENAI_API_KEY', ""))
81
 
82
- # Mapping Emojis to Primitive Types
83
- PRIMITIVE_MAP = {
84
- "๐ŸŒณ": "Tree", "๐Ÿ—ฟ": "Rock", "๐Ÿ›๏ธ": "Simple House", "๐ŸŒฒ": "Pine Tree", "๐Ÿงฑ": "Brick Wall",
85
- "๐Ÿ”ต": "Sphere", "๐Ÿ“ฆ": "Cube", "๐Ÿงด": "Cylinder", "๐Ÿฆ": "Cone", "๐Ÿฉ": "Torus",
86
- "๐Ÿ„": "Mushroom", "๐ŸŒต": "Cactus", "๐Ÿ”ฅ": "Campfire", "โญ": "Star", "๐Ÿ’Ž": "Gem",
87
- "๐Ÿ—ผ": "Tower", "๐Ÿšง": "Barrier", "โ›ฒ": "Fountain", "๐Ÿฎ": "Lantern", "ํŒป": "Sign Post"
88
- }
89
-
90
- # ==============================================================================
91
- # Global State & Locks
92
- # ==============================================================================
93
-
94
- # Thread lock for accessing shared world state
95
  world_objects_lock = threading.Lock()
96
- # In-memory world state {obj_id: data} - Use defaultdict for convenience
97
- world_objects = defaultdict(dict)
98
- # Set of active WebSocket client IDs
99
- connected_clients = set()
100
 
101
  # ==============================================================================
102
  # Utility Functions
@@ -106,16 +97,12 @@ 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
- # Get current datetime localized to the specified timezone
110
  now_aware = datetime.now(timezone)
111
  except pytz.UnknownTimeZoneError:
112
- # Fallback to UTC if timezone is unknown
113
  now_aware = datetime.now(pytz.utc)
114
  except Exception as e:
115
- # General fallback if timezone localization fails
116
  print(f"Timezone error ({tz}), using UTC. Error: {e}")
117
  now_aware = datetime.now(pytz.utc)
118
-
119
  return now_aware.strftime('%Y%m%d_%H%M%S')
120
 
121
 
@@ -127,18 +114,21 @@ def clean_filename_part(text, max_len=30):
127
  return text[:max_len]
128
 
129
  def run_async(async_func, *args, **kwargs):
130
- """Runs an async function safely from a sync context."""
 
 
131
  try:
132
  loop = asyncio.get_running_loop()
133
- # Schedule as task if loop is running
134
  return loop.create_task(async_func(*args, **kwargs))
135
- except RuntimeError: # No running loop
136
- # Run in a new loop (blocks until completion)
 
 
137
  try:
138
  return asyncio.run(async_func(*args, **kwargs))
139
  except Exception as e:
140
  print(f"Error running async func {async_func.__name__} in new loop: {e}")
141
- return None # Indicate error or failure
142
  except Exception as e:
143
  print(f"Error scheduling async task {async_func.__name__}: {e}")
144
  return None
@@ -165,49 +155,56 @@ def generate_world_save_filename(name="World"):
165
  def parse_world_filename(filename):
166
  """Extracts info from filename if possible, otherwise returns defaults."""
167
  basename = os.path.basename(filename)
168
- # Ensure prefix and suffix are correct
169
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
170
- parts = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3].split('_') # Remove prefix and suffix before split
 
 
171
  if len(parts) >= 3: # Expecting Name_Timestamp_Hash
172
  timestamp_str = parts[-2]
173
- name = " ".join(parts[:-2]) # Join potentially multiple parts for name
 
 
174
  dt_obj = None
175
  try: # Try parsing timestamp
176
  dt_obj = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S')
177
- dt_obj = pytz.utc.localize(dt_obj) # Assume UTC since generated with UTC
178
  except (ValueError, pytz.exceptions.AmbiguousTimeError, pytz.exceptions.NonExistentTimeError):
179
- dt_obj = None # Parsing failed or timezone issue
180
- return {"name": name or "Untitled", "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
181
 
182
- # Fallback for unknown format or parsing failure
183
  dt_fallback = None
184
  try:
185
  mtime = os.path.getmtime(filename)
186
  dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
187
- except Exception: pass # Ignore errors getting mtime
188
  return {"name": basename.replace('.md',''), "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
189
 
 
190
  def save_world_state_to_md(target_filename_base):
191
  """Saves the current in-memory world state to a specific MD file (basename)."""
192
  global world_objects
193
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
194
- print(f"Acquiring lock to save {len(world_objects)} objects to: {save_path}...")
195
  success = False
196
  with world_objects_lock:
197
- world_data_dict = dict(world_objects) # Create copy inside lock
 
198
  print(f"Saving {len(world_data_dict)} objects...")
199
- parsed_info = parse_world_filename(target_filename_base) # Use original filename for info
 
200
  timestamp_save = get_current_time_str()
201
  md_content = f"""# World State: {parsed_info['name']}
202
  * **File Saved:** {timestamp_save} (UTC)
203
- * **Original Timestamp:** {parsed_info['timestamp']}
204
  * **Objects:** {len(world_data_dict)}
205
 
206
  ```json
207
  {json.dumps(world_data_dict, indent=2)}
208
  ```"""
209
  try:
210
- ensure_dir(SAVED_WORLDS_DIR) # Ensure dir exists
211
  with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
212
  print(f"World state saved successfully to {target_filename_base}")
213
  success = True
@@ -216,6 +213,7 @@ def save_world_state_to_md(target_filename_base):
216
  # Avoid st.error in potentially non-main thread
217
  return success
218
 
 
219
  def load_world_state_from_md(filename_base):
220
  """Loads world state from an MD file (basename), updates global state, returns success bool."""
221
  global world_objects
@@ -227,15 +225,16 @@ def load_world_state_from_md(filename_base):
227
 
228
  try:
229
  with open(load_path, 'r', encoding='utf-8') as f: content = f.read()
230
- json_match = re.search(r"```json\s*(\{.*?\})\s*```", content, re.DOTALL | re.IGNORECASE)
 
231
  if not json_match: st.error(f"Could not find valid JSON block in {filename_base}"); return False
232
 
233
  world_data_dict = json.loads(json_match.group(1))
234
 
235
  print(f"Acquiring lock to update world state from {filename_base}...")
236
  with world_objects_lock:
237
- world_objects.clear() # Clear previous state
238
- for k, v in world_data_dict.items(): world_objects[str(k)] = v # Update with loaded data
239
  loaded_count = len(world_objects)
240
  print(f"Loaded {loaded_count} objects from {filename_base}. Lock released.")
241
  st.session_state.current_world_file = filename_base # Track loaded file (basename)
@@ -245,12 +244,13 @@ def load_world_state_from_md(filename_base):
245
  except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return False
246
 
247
  def get_saved_worlds():
248
- """Scans the saved worlds directory for MD files and parses them."""
249
  try:
250
  ensure_dir(SAVED_WORLDS_DIR)
 
251
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
252
  parsed_worlds = [parse_world_filename(f) for f in world_files]
253
- # Sort by datetime object if available (newest first)
254
  parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
255
  return parsed_worlds
256
  except Exception as e:
@@ -305,17 +305,16 @@ def init_session_state():
305
  # --- Text & File Helpers ---
306
  def clean_text_for_tts(text):
307
  if not isinstance(text, str): return "No text"
308
- text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text) # Remove markdown links, keep text
309
- text = re.sub(r'[#*_`!]', '', text) # Remove some markdown chars
310
- text = ' '.join(text.split()) # Normalize whitespace
311
  return text[:250] or "No text"
312
 
313
  def create_file(content, username, file_type="md", save_path=None):
314
  if not save_path:
315
- # Generate filename if specific path not given
316
  filename = generate_filename(content, username, file_type)
317
- save_path = os.path.join(MEDIA_DIR, filename) # Save to base dir by default
318
- ensure_dir(os.path.dirname(save_path)) # Ensure directory exists
319
  try:
320
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content)
321
  print(f"Created file: {save_path}"); return save_path
@@ -335,7 +334,8 @@ def get_download_link(file_path, file_type="md"):
335
  with open(file_path, "rb") as f: b64 = base64.b64encode(f.read()).decode()
336
  mime_types = {"md": "text/markdown", "mp3": "audio/mpeg", "png": "image/png", "mp4": "video/mp4", "zip": "application/zip", "json": "application/json"}
337
  basename = os.path.basename(file_path)
338
- link_html = f'<a href="data:{mime_types.get(file_type, "application/octet-stream")};base64,{b64}" download="{basename}">{FILE_EMOJIS.get(file_type, "๐Ÿ“„")} DL</a>'
 
339
  st.session_state.download_link_cache[cache_key] = link_html
340
  except Exception as e:
341
  print(f"Error generating DL link for {file_path}: {e}")
@@ -346,7 +346,6 @@ def get_download_link(file_path, file_type="md"):
346
  async def async_edge_tts_generate(text, voice, username):
347
  """Generates TTS audio using EdgeTTS and caches the result."""
348
  if not text: return None
349
- # Cache key based on text hash and voice
350
  cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest()
351
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
352
  cached_path = st.session_state.audio_cache.get(cache_key)
@@ -370,8 +369,8 @@ def play_and_download_audio(file_path):
370
  st.audio(file_path)
371
  file_type = file_path.split('.')[-1]
372
  st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
373
- except Exception as e: st.error(f"Audio display error: {e}")
374
- else: st.warning(f"Audio file not found: {os.path.basename(file_path) if file_path else 'N/A'}")
375
 
376
  # --- Chat ---
377
  async def save_chat_entry(username, message, voice, is_markdown=False):
@@ -379,7 +378,8 @@ async def save_chat_entry(username, message, voice, is_markdown=False):
379
  if not message.strip(): return None, None
380
  timestamp_str = get_current_time_str();
381
  entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
382
- 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)
 
383
  if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
384
  st.session_state.chat_history.append(entry) # Add to live history
385
  audio_file = None;
@@ -390,6 +390,7 @@ async def save_chat_entry(username, message, voice, is_markdown=False):
390
 
391
  async def load_chat_history():
392
  """Loads chat history from files if session state is empty."""
 
393
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
394
  if not st.session_state.chat_history:
395
  ensure_dir(CHAT_DIR)
@@ -400,7 +401,7 @@ async def load_chat_history():
400
  try:
401
  with open(f_path, 'r', encoding='utf-8') as file: temp_history.append(file.read().strip()); loaded_count += 1
402
  except Exception as e: print(f"Err read chat {f_path}: {e}")
403
- st.session_state.chat_history = temp_history
404
  print(f"Loaded {loaded_count} chat entries from files.")
405
  return st.session_state.chat_history
406
 
@@ -418,20 +419,24 @@ def create_zip_of_files(files_to_zip, prefix="Archive"):
418
  except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
419
 
420
  def delete_files(file_patterns, exclude_files=None):
 
 
421
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
422
- # Dynamically protect all currently saved world files unless explicitly targeted
423
- try:
424
- current_worlds = [os.path.basename(w['filename']) for w in get_saved_worlds()]
425
- protected.extend(current_worlds)
426
- except Exception: pass # Ignore if world listing fails
427
  if exclude_files: protected.extend(exclude_files)
428
 
429
  deleted_count = 0; errors = 0
430
  for pattern in file_patterns:
431
- pattern_path = os.path.join(MEDIA_DIR, pattern) # Assume relative to app dir
 
 
432
  try:
433
  files_to_delete = glob.glob(pattern_path)
434
- if not files_to_delete: continue
 
435
  for f_path in files_to_delete:
436
  basename = os.path.basename(f_path)
437
  # Check if it's a file and NOT protected
@@ -439,13 +444,17 @@ def delete_files(file_patterns, exclude_files=None):
439
  try: os.remove(f_path); print(f"Deleted: {f_path}"); deleted_count += 1
440
  except Exception as e: print(f"Failed delete {f_path}: {e}"); errors += 1
441
  elif os.path.isdir(f_path): print(f"Skipping directory: {f_path}")
442
- except Exception as glob_e: print(f"Err matching {pattern}: {glob_e}"); errors += 1
 
 
443
  msg = f"Deleted {deleted_count} files.";
444
  if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
445
  elif deleted_count > 0: st.success(msg)
446
  else: st.info("No matching files found to delete.")
447
  # Clear relevant caches
448
- st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
 
 
449
 
450
  # --- Image Handling ---
451
  async def save_pasted_image(image, username):
@@ -458,7 +467,7 @@ async def save_pasted_image(image, username):
458
  def paste_image_component():
459
  pasted_img = None; img_type = None
460
  with st.form(key="paste_form"):
461
- paste_input = st.text_area("Paste Image Data Here (Ctrl+V)", key="paste_input_area", height=50); submit_button = st.form_submit_button("Paste Image ๐Ÿ“‹")
462
  if submit_button and paste_input and paste_input.startswith('data:image'):
463
  try:
464
  mime_type = paste_input.split(';')[0].split(':')[1]; base64_str = paste_input.split(',')[1]; img_bytes = base64.b64decode(base64_str); pasted_img = Image.open(io.BytesIO(img_bytes)); img_type = mime_type.split('/')[1]
@@ -466,12 +475,11 @@ def paste_image_component():
466
  except ImportError: st.error("Pillow library needed for image pasting.")
467
  except Exception as e: st.error(f"Img decode err: {e}"); st.session_state.paste_image_base64 = ""
468
  elif submit_button: st.warning("No valid img data."); st.session_state.paste_image_base64 = ""
469
- # Return the image object if successfully pasted and submitted
470
  return pasted_img if submit_button and pasted_img else None
471
 
472
 
473
  # --- PDF Processing ---
474
- # Note: Depends on PyPDF2 and potentially AudioProcessor class if generating audio
475
  class AudioProcessor:
476
  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 {}
477
  def _save_metadata(self):
@@ -479,7 +487,7 @@ class AudioProcessor:
479
  with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f, indent=2)
480
  except Exception as e: print(f"Failed metadata save: {e}")
481
  async def create_audio(self, text, voice='en-US-AriaNeural'):
482
- cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=f"{self.cache_dir}/{cache_key}.mp3"
483
  if cache_key in self.metadata and os.path.exists(cache_path): return cache_path
484
  text_cleaned=clean_text_for_tts(text);
485
  if not text_cleaned: return None
@@ -494,81 +502,59 @@ class AudioProcessor:
494
 
495
  def process_pdf_tab(pdf_file, max_pages, voice):
496
  st.subheader("PDF Processing")
497
- if pdf_file is None:
498
- st.info("Upload a PDF file to begin.")
499
- return
500
- audio_processor = AudioProcessor() # Instance for this run
501
  try:
502
  reader=PdfReader(pdf_file)
503
- # Check if PDF is password protected (optional but good practice)
504
- if reader.is_encrypted:
505
- st.warning("PDF is encrypted and cannot be processed.")
506
- return
507
  total_pages=min(len(reader.pages),max_pages);
508
- st.write(f"Processing first {total_pages} pages of '{pdf_file.name}'...")
509
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
510
 
511
- # --- Corrected process_page_sync function ---
512
  def process_page_sync(page_num, page_text):
513
- # Runs async audio generation using asyncio.run in this thread
514
- async def run_async_audio():
515
- # Ensure audio_processor is accessible (it is, from outer scope)
516
- return await audio_processor.create_audio(page_text, voice)
517
- try: # Start of the try block
518
- # It's generally better not to run asyncio.run inside threads repeatedly
519
- # if the main loop is async, but in Streamlit context this might be necessary.
520
- audio_path = asyncio.run(run_async_audio()) # Attempt to run async func
521
- if audio_path: # Check result *inside* the try block
522
- with results_lock:
523
- audios[page_num] = audio_path # Update shared dict safely
524
- except RuntimeError as run_err:
525
- # Handle cases where asyncio.run is called from an already running loop
526
- # This might happen depending on Streamlit's internal async handling
527
- print(f"RuntimeError processing page {page_num+1} (asyncio loop issue?): {run_err}")
528
- # Fallback? Or just log the error.
529
- except Exception as page_e: # Correctly indented except block
530
- print(f"Err process page {page_num+1}: {page_e}")
531
- # --- End of corrected function ---
532
 
533
  # Start threads
534
  for i in range(total_pages):
535
  try:
536
- page = reader.pages[i]
537
- text = page.extract_text()
538
- if text and text.strip(): # Check if text extraction yielded something meaningful
539
- texts[i]=text
540
- # Start a new thread for each page's audio processing
541
- thread = threading.Thread(target=process_page_sync, args=(i, text))
542
- page_threads.append(thread)
543
- thread.start()
544
- else: texts[i] = "[No text extracted or page empty]"
545
- except Exception as extract_e:
546
- print(f"Error extracting text from page {i+1}: {extract_e}")
547
- texts[i] = f"[Error extracting text: {extract_e}]"
548
-
549
-
550
- # Wait for threads and display results
551
  progress_bar = st.progress(0.0)
552
  total_threads = len(page_threads)
553
- completed_threads = 0
554
- while completed_threads < total_threads:
555
- completed_threads = total_threads - sum(t.is_alive() for t in page_threads)
556
- progress = completed_threads / total_threads if total_threads > 0 else 1.0
557
- progress_bar.progress(progress)
558
- time.sleep(0.2) # Brief sleep to avoid busy-waiting
559
-
560
- progress_bar.progress(1.0) # Ensure it reaches 100%
561
-
562
- # Display results after all threads are done (or tried)
 
 
563
  st.write("Processing complete. Displaying results:")
564
  for i in range(total_pages):
565
  with st.expander(f"Page {i+1}"):
566
  st.markdown(texts.get(i, "[Error getting text]"))
567
- audio_file = audios.get(i) # Get result from shared dict
568
  if audio_file: play_and_download_audio(audio_file)
569
- else: st.caption("Audio generation failed or pending.")
 
 
 
570
 
571
- except Exception as pdf_e: st.error(f"Error reading PDF: {pdf_e}"); st.exception(pdf_e)
572
  # ==============================================================================
573
  # WebSocket Server Logic
574
  # ==============================================================================
@@ -610,7 +596,11 @@ async def broadcast_world_update():
610
  async def websocket_handler(websocket, path):
611
  """Handles WebSocket connections and messages."""
612
  await register_client(websocket); client_id = str(websocket.id);
613
- username = st.session_state.get('username', f"User_{client_id[:4]}") # Get username for this session
 
 
 
 
614
  try: # Send initial state
615
  with world_objects_lock: initial_state_payload = dict(world_objects)
616
  initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload}); await websocket.send(initial_state_msg)
@@ -622,14 +612,12 @@ async def websocket_handler(websocket, path):
622
  async for message in websocket:
623
  try:
624
  data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
625
- # Use username from payload if provided, otherwise session username
626
- sender_username = payload.get("username", username)
627
 
628
  if msg_type == "chat_message":
629
  chat_text = payload.get('message', ''); voice = payload.get('voice', FUN_USERNAMES.get(sender_username, "en-US-AriaNeural"));
630
- # Schedule save/TTS, but broadcast immediately
631
- run_async(save_chat_entry, sender_username, chat_text, voice) # Fire and forget save
632
- await broadcast_message(message, exclude_id=client_id) # Forward original msg
633
 
634
  elif msg_type == "place_object":
635
  obj_data = payload.get("object_data");
@@ -650,21 +638,18 @@ async def websocket_handler(websocket, path):
650
  else: print(f"WS Invalid delete_object payload: {payload}")
651
 
652
  elif msg_type == "player_position":
653
- pos_data = payload.get("position")
654
- rot_data = payload.get("rotation") # Optionally include rotation
655
  if pos_data:
656
  broadcast_payload = json.dumps({"type": "player_moved", "payload": {"username": sender_username, "id": client_id, "position": pos_data, "rotation": rot_data}});
657
  await broadcast_message(broadcast_payload, exclude_id=client_id)
658
 
659
- # Add more handlers here
660
-
661
  except json.JSONDecodeError: print(f"WS Invalid JSON from {client_id}: {message[:100]}...")
662
  except Exception as e: print(f"WS Error processing msg from {client_id}: {e}")
663
  except websockets.ConnectionClosed: print(f"WS Client disconnected: {client_id} ({username})")
664
  except Exception as e: print(f"WS Unexpected handler error {client_id}: {e}")
665
  finally:
666
  await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
667
- await unregister_client(websocket)
668
 
669
 
670
  async def run_websocket_server():
@@ -674,11 +659,12 @@ async def run_websocket_server():
674
  stop_event = asyncio.Event(); st.session_state['websocket_stop_event'] = stop_event
675
  server = None
676
  try:
 
677
  server = await websockets.serve(websocket_handler, "0.0.0.0", 8765); st.session_state['server_instance'] = server
678
  print(f"WS server started: {server.sockets[0].getsockname()}. Waiting for stop signal...")
679
  await stop_event.wait() # Keep running
680
- except OSError as e: print(f"### FAILED START WS SERVER: {e}"); st.session_state['server_running_flag'] = False;
681
- except Exception as e: print(f"### UNEXPECTED WS SERVER ERROR: {e}"); st.session_state['server_running_flag'] = False;
682
  finally:
683
  print("WS server task finishing...");
684
  if server: server.close(); await server.wait_closed(); print("WS server closed.")
@@ -690,15 +676,32 @@ def start_websocket_server_thread():
690
  if st.session_state.get('server_running_flag', False): return
691
  print("Creating/starting new server thread.");
692
  def run_loop():
693
- loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop)
694
- try: loop.run_until_complete(run_websocket_server())
695
- finally: loop.close(); print("Server thread loop closed.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
  st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5)
697
  if not st.session_state.server_task.is_alive(): print("### Server thread failed to stay alive!")
698
 
699
 
700
  # ==============================================================================
701
- # Streamlit UI Layout
702
  # ==============================================================================
703
 
704
  def render_sidebar():
@@ -707,72 +710,70 @@ def render_sidebar():
707
  st.header("๐Ÿ’พ World Versions")
708
  st.caption("Load or save named world states.")
709
 
710
- # World Selector
711
  saved_worlds = get_saved_worlds()
712
- # Create display text mapping filename to formatted string
713
- world_options = {w['filename']: f"{w['name']} ({w['timestamp']})" for w in saved_worlds}
714
- # Get the currently selected filename (basename) from session state
715
- current_selection_basename = st.session_state.get('current_world_file', None)
716
-
717
- # Prepare options for radio button: None first, then filenames
718
- radio_options_list = [None] + [w['filename'] for w in saved_worlds] # Store full path initially? No, use basename.
719
  radio_options_basenames = [None] + [os.path.basename(w['filename']) for w in saved_worlds]
720
 
721
- # Find the index of the current selection in the options list
722
- current_radio_index = 0 # Default to "Live State"
723
  if current_selection_basename and current_selection_basename in radio_options_basenames:
724
- try:
725
- current_radio_index = radio_options_basenames.index(current_selection_basename)
726
- except ValueError:
727
- current_radio_index = 0 # Fallback if filename somehow not in list
728
 
729
- # Display radio buttons
730
  selected_basename = st.radio(
731
- "Load World:",
732
- options=radio_options_basenames,
733
- index=current_radio_index,
734
- format_func=lambda x: "Live State (Unsaved)" if x is None else world_options.get(os.path.join(SAVED_WORLDS_DIR, x), x), # Format using full path to get name/time? complex
735
  key="world_selector_radio"
736
  )
737
 
738
  # Handle selection change
739
  if selected_basename != current_selection_basename:
740
- st.session_state.current_world_file = selected_basename # Store basename
741
  if selected_basename:
742
  with st.spinner(f"Loading {selected_basename}..."):
743
  if load_world_state_from_md(selected_basename):
744
  run_async(broadcast_world_update) # Broadcast new state
745
  st.toast("World loaded!", icon="โœ…")
746
- else:
747
- st.error("Failed to load world."); st.session_state.current_world_file = None # Reset on failure
748
- else:
749
  print("Switched to live state.")
750
- # Optionally clear world or just stop tracking file? Stop tracking.
751
- # Maybe broadcast current live state if switching TO live?
752
- # run_async(broadcast_world_update) # Broadcast current live state
753
  st.toast("Switched to Live State.")
754
  st.rerun()
755
 
756
- # Display download links
757
  st.caption("Download:")
758
- cols = st.columns([3, 1]) # Columns for name and download button
759
- with cols[0]: st.write("**Name**")
760
- with cols[1]: st.write("**Link**")
761
- for world_info in saved_worlds:
 
762
  f_basename = os.path.basename(world_info['filename'])
763
- f_fullpath = world_info['filename'] # Full path needed for reading file
764
  display_name = world_info.get('name', f_basename)
765
  timestamp = world_info.get('timestamp', 'N/A')
766
- col1, col2 = st.columns([3, 1])
767
  with col1: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
768
  with col2: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
769
-
 
 
 
 
 
 
 
 
 
 
770
 
771
  st.markdown("---")
772
 
773
- # Save New Version moved to Files Tab
774
-
775
- # Build Tools section
776
  st.header("๐Ÿ—๏ธ Build Tools")
777
  st.caption("Select an object to place.")
778
  cols = st.columns(5)
@@ -783,7 +784,8 @@ def render_sidebar():
783
  if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type, use_container_width=True):
784
  if st.session_state.get('selected_object', 'None') != name:
785
  st.session_state.selected_object = name
786
- run_async(lambda: streamlit_js_eval(f"updateSelectedObjectType({json.dumps(name)});", key=f"update_tool_js_{name}")) # Fire-and-forget JS update
 
787
  st.rerun()
788
  col_idx += 1
789
  st.markdown("---")
@@ -793,18 +795,18 @@ def render_sidebar():
793
  run_async(lambda: streamlit_js_eval("updateSelectedObjectType('None');", key="update_tool_js_none"))
794
  st.rerun()
795
 
796
- # Voice/User section
797
  st.markdown("---")
798
  st.header("๐Ÿ—ฃ๏ธ Voice & User")
799
  current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0])
800
  username_options = list(FUN_USERNAMES.keys()); current_index = 0
801
  try: current_index = username_options.index(current_username)
802
- except ValueError: current_index = 0
803
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
804
  if new_username != st.session_state.username:
805
  old_username = st.session_state.username
806
  change_msg = json.dumps({"type":"user_rename", "payload": {"old_username": old_username, "new_username": new_username}})
807
- run_async(broadcast_message, change_msg)
808
  st.session_state.username = new_username; st.session_state.tts_voice = FUN_USERNAMES[new_username]; save_username(st.session_state.username)
809
  st.rerun()
810
  st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
@@ -820,20 +822,24 @@ def render_main_content():
820
  with tab_world:
821
  st.header("Shared 3D World")
822
  st.caption("Place objects using the sidebar tools. Changes are shared live!")
823
- current_file = st.session_state.get('current_world_file', None)
824
- if current_file: parsed = parse_world_filename(current_file); st.info(f"Current World: **{parsed['name']}** (`{os.path.basename(current_file)}`)")
825
- else: st.info("Live State Active (Unsaved changes will be lost unless saved as new version)")
 
 
 
826
 
827
  # Embed HTML Component
828
  html_file_path = 'index.html'
829
  try:
830
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
831
- try: # Get WS URL
 
832
  from streamlit.web.server.server import Server
833
  session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id)
834
  server_host = session_info.ws.stream.request.host.split(':')[0]
835
  ws_url = f"ws://{server_host}:8765"
836
- except Exception: ws_url = "ws://localhost:8765"
837
 
838
  js_injection_script = f"""<script>
839
  window.USERNAME = {json.dumps(st.session_state.username)};
@@ -851,10 +857,15 @@ def render_main_content():
851
  # --- Chat Tab ---
852
  with tab_chat:
853
  st.header(f"{START_ROOM} Chat")
854
- chat_history = run_async(load_chat_history).result() if 'chat_history' not in st.session_state else st.session_state.chat_history # Load sync if needed
 
 
 
 
 
855
  chat_container = st.container(height=500)
856
  with chat_container:
857
- if chat_history: st.markdown("----\n".join(reversed(chat_history[-50:])))
858
  else: st.caption("No chat messages yet.")
859
 
860
  # Chat Input Area
@@ -865,56 +876,59 @@ def render_main_content():
865
  if send_button_clicked or should_autosend:
866
  message_to_send = message_value
867
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
868
- st.session_state.last_message = message_to_send
869
  voice = FUN_USERNAMES.get(st.session_state.username, "en-US-AriaNeural")
870
  ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}})
871
- run_async(broadcast_message, ws_message) # Send via WS
872
- run_async(save_chat_entry, st.session_state.username, message_to_send, voice) # Save locally
 
873
  st.session_state.message_input = "" # Clear state for next run
874
  st.rerun()
875
  elif send_button_clicked: st.toast("Message empty or same as last.")
876
-
877
  st.checkbox("Autosend Chat", key="autosend") # Toggle autosend
878
 
879
  # --- PDF Tab ---
880
  with tab_pdf:
881
  st.header("๐Ÿ“š PDF Tools")
882
  pdf_file = st.file_uploader("Upload PDF for Audio Conversion", type="pdf", key="pdf_upload")
883
- max_pages = st.slider('Max Pages to Process', 1, 50, 10, key="pdf_pages") # Limit pages
884
  if pdf_file:
885
- process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice)
886
-
 
 
887
 
888
  # --- Files & Settings Tab ---
889
  with tab_files:
890
  st.header("๐Ÿ“‚ Files & Settings")
891
  st.subheader("๐Ÿ’พ World State Management")
892
- current_file = st.session_state.get('current_world_file', None)
893
 
894
  # Save Current Version Button
895
- if current_file:
896
- parsed = parse_world_filename(current_file); save_label = f"Save Changes to '{parsed['name']}'"
897
- if st.button(save_label, key="save_current_world", help=f"Overwrite '{os.path.basename(current_file)}' with current live state."):
898
- with st.spinner(f"Overwriting {os.path.basename(current_file)}..."):
899
- if save_world_state_to_md(current_file): st.success("Current world version saved!")
900
- else: st.error("Failed to save world state.")
 
901
  else:
902
- st.info("Load a world version from the sidebar or use 'Save New Version' below to save the current live state.")
903
 
904
  # Save As New Version Section
905
  st.subheader("Save As New Version")
906
- new_name_files = st.text_input("New World Name:", key="new_world_name_files") # Use different key from sidebar one if kept there
907
  if st.button("๐Ÿ’พ Save Live State as New Version", key="save_new_version_files"):
908
  if new_name_files.strip():
909
- new_filename = generate_world_save_filename(new_name_files) # Generates basename
910
  with st.spinner(f"Saving new version '{new_name_files}'..."):
911
- if save_world_state_to_md(new_filename): # Pass basename
912
- st.success(f"Saved as {new_filename}")
913
- st.session_state.current_world_file = new_filename # Switch to new file automatically
914
- st.session_state.new_world_name_files = "" # Reset input
915
  st.rerun()
916
  else: st.error("Failed to save new version.")
917
- else: st.warning("Please enter a name for the new world version.")
918
 
919
  st.subheader("โš™๏ธ Server Status")
920
  col_ws, col_clients = st.columns(2)
@@ -932,14 +946,24 @@ def render_main_content():
932
  with col_del2:
933
  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()
934
  with col_del3:
935
- if st.button("๐Ÿ—‘๏ธ Worlds", key="del_worlds_md", help="Deletes world save files (.md) in saved_worlds"): delete_files([os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")]); st.session_state.current_world_file = None; st.rerun()
936
  with col_del4:
937
  if st.button("๐Ÿ—‘๏ธ All Gen", key="del_all_gen", help="Deletes Chats, Audio, Worlds, Zips"): delete_files([os.path.join(CHAT_DIR, "*.md"), os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3"), os.path.join(SAVED_WORLDS_DIR, "*.md"), "*.zip"]); st.session_state.chat_history = []; st.session_state.audio_cache = {}; st.session_state.current_world_file = None; st.rerun()
938
 
939
  # Download Archives
940
  st.subheader("๐Ÿ“ฆ Download Archives")
941
- zip_files = sorted(glob.glob("*.zip"), key=os.path.getmtime, reverse=True)
942
  if zip_files:
 
 
 
 
 
 
 
 
 
 
943
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
944
  else: st.caption("No zip archives found.")
945
 
@@ -950,40 +974,62 @@ def render_main_content():
950
 
951
  def initialize_world():
952
  """Loads initial world state (most recent) if not already loaded."""
 
953
  if not st.session_state.get('initial_world_state_loaded', False):
954
- print("Performing initial world load...")
955
  saved_worlds = get_saved_worlds()
 
956
  if saved_worlds:
 
957
  latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
958
  print(f"Loading most recent world on startup: {latest_world_file_basename}")
959
- load_world_state_from_md(latest_world_file_basename) # This updates global state and sets session state 'current_world_file'
 
 
 
960
  else:
961
  print("No saved worlds found, starting with empty state.")
962
- with world_objects_lock: world_objects.clear() # Ensure empty state
963
- st.session_state.current_world_file = None # Ensure no file is marked as loaded
964
- st.session_state.initial_world_state_loaded = True
965
- print("Initial world load complete.")
966
 
 
 
 
 
 
 
 
967
 
968
  if __name__ == "__main__":
969
- # 1. Initialize session state first
970
  init_session_state()
971
 
972
- # 2. Start WebSocket server thread if needed
973
- # Use server_running_flag to prevent multiple start attempts within one session
974
- if not st.session_state.get('server_running_flag', False):
975
- if 'server_task' not in st.session_state or not st.session_state.server_task.is_alive():
976
- start_websocket_server_thread()
977
- else:
978
- # If task exists but flag is false, maybe update flag?
979
- if st.session_state.server_task.is_alive():
980
- st.session_state.server_running_flag = True
981
- print("Corrected server_running_flag based on alive thread.")
982
-
983
-
984
- # 3. Load initial world state from disk if needed
985
  initialize_world()
986
 
987
  # 4. Render the UI (Sidebar and Main Content)
988
  render_sidebar()
989
- render_main_content()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py (Refactored & Consolidated - Checked Indentation)
2
  import streamlit as st
3
  import asyncio
4
  import websockets
 
26
  from collections import defaultdict, Counter
27
  import pandas as pd # Still used for fallback CSV load? Keep for now.
28
  from streamlit_js_eval import streamlit_js_eval
29
+ from PIL import Image # Needed for paste_image_component
30
 
31
  # ==============================================================================
32
  # Configuration & Constants
 
74
  # File Emojis
75
  FILE_EMOJIS = {"md": "๐Ÿ“", "mp3": "๐ŸŽต", "png": "๐Ÿ–ผ๏ธ", "mp4": "๐ŸŽฅ", "zip": "๐Ÿ“ฆ", "json": "๐Ÿ“„"}
76
 
77
+
78
+ # --- Directories ---
79
+ for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
80
+ os.makedirs(d, exist_ok=True)
81
+
82
+ # --- API Keys (Placeholder) ---
83
  load_dotenv()
84
  # ANTHROPIC_KEY = os.getenv('ANTHROPIC_API_KEY', st.secrets.get('ANTHROPIC_API_KEY', ""))
85
  # OPENAI_KEY = os.getenv('OPENAI_API_KEY', st.secrets.get('OPENAI_API_KEY', ""))
86
 
87
+ # --- Global State & Locks ---
 
 
 
 
 
 
 
 
 
 
 
 
88
  world_objects_lock = threading.Lock()
89
+ world_objects = defaultdict(dict) # In-memory world state {obj_id: data}
90
+ connected_clients = set() # Holds client_id strings
 
 
91
 
92
  # ==============================================================================
93
  # Utility Functions
 
97
  """Gets formatted timestamp string in specified timezone (default UTC)."""
98
  try:
99
  timezone = pytz.timezone(tz)
 
100
  now_aware = datetime.now(timezone)
101
  except pytz.UnknownTimeZoneError:
 
102
  now_aware = datetime.now(pytz.utc)
103
  except Exception as e:
 
104
  print(f"Timezone error ({tz}), using UTC. Error: {e}")
105
  now_aware = datetime.now(pytz.utc)
 
106
  return now_aware.strftime('%Y%m%d_%H%M%S')
107
 
108
 
 
114
  return text[:max_len]
115
 
116
  def run_async(async_func, *args, **kwargs):
117
+ """Runs an async function safely from a sync context using create_task."""
118
+ # This helper attempts to schedule the async function as a background task
119
+ # without blocking the main Streamlit thread.
120
  try:
121
  loop = asyncio.get_running_loop()
 
122
  return loop.create_task(async_func(*args, **kwargs))
123
+ except RuntimeError: # No running loop in this thread
124
+ # Fallback: Run in a new loop (might block slightly, less ideal for UI responsiveness)
125
+ # Consider if truly background execution is needed (e.g., ThreadPoolExecutor)
126
+ print(f"Warning: Running async func {async_func.__name__} in new event loop.")
127
  try:
128
  return asyncio.run(async_func(*args, **kwargs))
129
  except Exception as e:
130
  print(f"Error running async func {async_func.__name__} in new loop: {e}")
131
+ return None
132
  except Exception as e:
133
  print(f"Error scheduling async task {async_func.__name__}: {e}")
134
  return None
 
155
  def parse_world_filename(filename):
156
  """Extracts info from filename if possible, otherwise returns defaults."""
157
  basename = os.path.basename(filename)
158
+ # Check prefix and suffix
159
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
160
+ # Remove prefix and suffix before splitting
161
+ core_name = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
162
+ parts = core_name.split('_')
163
  if len(parts) >= 3: # Expecting Name_Timestamp_Hash
164
  timestamp_str = parts[-2]
165
+ # Combine parts before timestamp and hash for the name
166
+ name_parts = parts[:-2]
167
+ name = "_".join(name_parts) if name_parts else "Untitled" # Handle empty name parts
168
  dt_obj = None
169
  try: # Try parsing timestamp
170
  dt_obj = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S')
171
+ dt_obj = pytz.utc.localize(dt_obj) # Assume UTC
172
  except (ValueError, pytz.exceptions.AmbiguousTimeError, pytz.exceptions.NonExistentTimeError):
173
+ dt_obj = None # Parsing failed
174
+ return {"name": name.replace('_', ' '), "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
175
 
176
+ # Fallback for unknown format
177
  dt_fallback = None
178
  try:
179
  mtime = os.path.getmtime(filename)
180
  dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
181
+ except Exception: pass
182
  return {"name": basename.replace('.md',''), "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
183
 
184
+
185
  def save_world_state_to_md(target_filename_base):
186
  """Saves the current in-memory world state to a specific MD file (basename)."""
187
  global world_objects
188
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
189
+ print(f"Acquiring lock to save world state to: {save_path}...")
190
  success = False
191
  with world_objects_lock:
192
+ # Create a deep copy for saving if needed, dict() might be shallow
193
+ world_data_dict = dict(world_objects) # Convert defaultdict for saving
194
  print(f"Saving {len(world_data_dict)} objects...")
195
+ # Use the target filename to generate header info
196
+ parsed_info = parse_world_filename(save_path) # Parse the full path/intended name
197
  timestamp_save = get_current_time_str()
198
  md_content = f"""# World State: {parsed_info['name']}
199
  * **File Saved:** {timestamp_save} (UTC)
200
+ * **Source Timestamp:** {parsed_info['timestamp']}
201
  * **Objects:** {len(world_data_dict)}
202
 
203
  ```json
204
  {json.dumps(world_data_dict, indent=2)}
205
  ```"""
206
  try:
207
+ ensure_dir(SAVED_WORLDS_DIR)
208
  with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
209
  print(f"World state saved successfully to {target_filename_base}")
210
  success = True
 
213
  # Avoid st.error in potentially non-main thread
214
  return success
215
 
216
+
217
  def load_world_state_from_md(filename_base):
218
  """Loads world state from an MD file (basename), updates global state, returns success bool."""
219
  global world_objects
 
225
 
226
  try:
227
  with open(load_path, 'r', encoding='utf-8') as f: content = f.read()
228
+ # More robust JSON extraction
229
+ json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE)
230
  if not json_match: st.error(f"Could not find valid JSON block in {filename_base}"); return False
231
 
232
  world_data_dict = json.loads(json_match.group(1))
233
 
234
  print(f"Acquiring lock to update world state from {filename_base}...")
235
  with world_objects_lock:
236
+ world_objects.clear()
237
+ for k, v in world_data_dict.items(): world_objects[str(k)] = v
238
  loaded_count = len(world_objects)
239
  print(f"Loaded {loaded_count} objects from {filename_base}. Lock released.")
240
  st.session_state.current_world_file = filename_base # Track loaded file (basename)
 
244
  except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return False
245
 
246
  def get_saved_worlds():
247
+ """Scans the saved worlds directory for world MD files and parses them."""
248
  try:
249
  ensure_dir(SAVED_WORLDS_DIR)
250
+ # Use the prefix in the glob pattern
251
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
252
  parsed_worlds = [parse_world_filename(f) for f in world_files]
253
+ # Sort by datetime object (newest first), handle None dt values
254
  parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
255
  return parsed_worlds
256
  except Exception as e:
 
305
  # --- Text & File Helpers ---
306
  def clean_text_for_tts(text):
307
  if not isinstance(text, str): return "No text"
308
+ text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
309
+ text = re.sub(r'[#*_`!]', '', text)
310
+ text = ' '.join(text.split())
311
  return text[:250] or "No text"
312
 
313
  def create_file(content, username, file_type="md", save_path=None):
314
  if not save_path:
 
315
  filename = generate_filename(content, username, file_type)
316
+ save_path = os.path.join(MEDIA_DIR, filename)
317
+ ensure_dir(os.path.dirname(save_path))
318
  try:
319
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content)
320
  print(f"Created file: {save_path}"); return save_path
 
334
  with open(file_path, "rb") as f: b64 = base64.b64encode(f.read()).decode()
335
  mime_types = {"md": "text/markdown", "mp3": "audio/mpeg", "png": "image/png", "mp4": "video/mp4", "zip": "application/zip", "json": "application/json"}
336
  basename = os.path.basename(file_path)
337
+ # Changed emoji and text for clarity
338
+ 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>'
339
  st.session_state.download_link_cache[cache_key] = link_html
340
  except Exception as e:
341
  print(f"Error generating DL link for {file_path}: {e}")
 
346
  async def async_edge_tts_generate(text, voice, username):
347
  """Generates TTS audio using EdgeTTS and caches the result."""
348
  if not text: return None
 
349
  cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest()
350
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
351
  cached_path = st.session_state.audio_cache.get(cache_key)
 
369
  st.audio(file_path)
370
  file_type = file_path.split('.')[-1]
371
  st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
372
+ except Exception as e: st.error(f"Audio display error for {os.path.basename(file_path)}: {e}")
373
+ # else: st.warning(f"Audio file not found: {os.path.basename(file_path) if file_path else 'N/A'}") # Can be noisy
374
 
375
  # --- Chat ---
376
  async def save_chat_entry(username, message, voice, is_markdown=False):
 
378
  if not message.strip(): return None, None
379
  timestamp_str = get_current_time_str();
380
  entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
381
+ md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
382
+ md_file = create_file(entry, username, "md", save_path=md_file_path) # Save to file
383
  if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
384
  st.session_state.chat_history.append(entry) # Add to live history
385
  audio_file = None;
 
390
 
391
  async def load_chat_history():
392
  """Loads chat history from files if session state is empty."""
393
+ # This ensures history is loaded once per session if needed
394
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
395
  if not st.session_state.chat_history:
396
  ensure_dir(CHAT_DIR)
 
401
  try:
402
  with open(f_path, 'r', encoding='utf-8') as file: temp_history.append(file.read().strip()); loaded_count += 1
403
  except Exception as e: print(f"Err read chat {f_path}: {e}")
404
+ st.session_state.chat_history = temp_history # Assign loaded history
405
  print(f"Loaded {loaded_count} chat entries from files.")
406
  return st.session_state.chat_history
407
 
 
419
  except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
420
 
421
  def delete_files(file_patterns, exclude_files=None):
422
+ """Deletes files matching patterns, excluding protected/specified files."""
423
+ # Core protected files
424
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
425
+ # Dynamically protect currently loaded world file if specified
426
+ current_world = st.session_state.get('current_world_file')
427
+ if current_world: protected.append(current_world)
428
+ # Add user exclusions
 
429
  if exclude_files: protected.extend(exclude_files)
430
 
431
  deleted_count = 0; errors = 0
432
  for pattern in file_patterns:
433
+ # Expand pattern relative to current directory or specified dir
434
+ pattern_path = pattern # Assume pattern includes path if needed (e.g., from os.path.join)
435
+ print(f"Attempting to delete files matching: {pattern_path}")
436
  try:
437
  files_to_delete = glob.glob(pattern_path)
438
+ if not files_to_delete: print(f"No files found for pattern: {pattern}"); continue
439
+
440
  for f_path in files_to_delete:
441
  basename = os.path.basename(f_path)
442
  # Check if it's a file and NOT protected
 
444
  try: os.remove(f_path); print(f"Deleted: {f_path}"); deleted_count += 1
445
  except Exception as e: print(f"Failed delete {f_path}: {e}"); errors += 1
446
  elif os.path.isdir(f_path): print(f"Skipping directory: {f_path}")
447
+ #else: print(f"Skipping protected/non-file: {f_path}") # Debugging
448
+ except Exception as glob_e: print(f"Error matching pattern {pattern}: {glob_e}"); errors += 1
449
+
450
  msg = f"Deleted {deleted_count} files.";
451
  if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
452
  elif deleted_count > 0: st.success(msg)
453
  else: st.info("No matching files found to delete.")
454
  # Clear relevant caches
455
+ st.session_state['download_link_cache'] = {}
456
+ st.session_state['audio_cache'] = {}
457
+
458
 
459
  # --- Image Handling ---
460
  async def save_pasted_image(image, username):
 
467
  def paste_image_component():
468
  pasted_img = None; img_type = None
469
  with st.form(key="paste_form"):
470
+ paste_input = st.text_area("Paste Image Data Here", key="paste_input_area", height=50); submit_button = st.form_submit_button("Paste Image ๐Ÿ“‹")
471
  if submit_button and paste_input and paste_input.startswith('data:image'):
472
  try:
473
  mime_type = paste_input.split(';')[0].split(':')[1]; base64_str = paste_input.split(',')[1]; img_bytes = base64.b64decode(base64_str); pasted_img = Image.open(io.BytesIO(img_bytes)); img_type = mime_type.split('/')[1]
 
475
  except ImportError: st.error("Pillow library needed for image pasting.")
476
  except Exception as e: st.error(f"Img decode err: {e}"); st.session_state.paste_image_base64 = ""
477
  elif submit_button: st.warning("No valid img data."); st.session_state.paste_image_base64 = ""
478
+ # Return the image object if successfully pasted and submitted in THIS RUN
479
  return pasted_img if submit_button and pasted_img else None
480
 
481
 
482
  # --- PDF Processing ---
 
483
  class AudioProcessor:
484
  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 {}
485
  def _save_metadata(self):
 
487
  with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f, indent=2)
488
  except Exception as e: print(f"Failed metadata save: {e}")
489
  async def create_audio(self, text, voice='en-US-AriaNeural'):
490
+ cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=os.path.join(self.cache_dir, f"{cache_key}.mp3") # Use join
491
  if cache_key in self.metadata and os.path.exists(cache_path): return cache_path
492
  text_cleaned=clean_text_for_tts(text);
493
  if not text_cleaned: return None
 
502
 
503
  def process_pdf_tab(pdf_file, max_pages, voice):
504
  st.subheader("PDF Processing")
505
+ if pdf_file is None: st.info("Upload a PDF file to begin."); return
506
+ audio_processor = AudioProcessor()
 
 
507
  try:
508
  reader=PdfReader(pdf_file)
509
+ if reader.is_encrypted: st.warning("PDF is encrypted."); return
 
 
 
510
  total_pages=min(len(reader.pages),max_pages);
511
+ st.write(f"Processing first {total_pages} pages of '{pdf_file.name}'...");
512
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
513
 
 
514
  def process_page_sync(page_num, page_text):
515
+ async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
516
+ try:
517
+ # Use the run_async helper
518
+ audio_path = run_async(run_async_audio).result() # Blocking wait here might be okay for thread
519
+ if audio_path:
520
+ with results_lock: audios[page_num] = audio_path
521
+ except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
 
 
 
 
 
 
 
 
 
 
 
 
522
 
523
  # Start threads
524
  for i in range(total_pages):
525
  try:
526
+ page = reader.pages[i]; text = page.extract_text();
527
+ if text and text.strip():
528
+ texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
529
+ else: texts[i] = "[No text extracted]"
530
+ except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
531
+
532
+ # Wait for threads and display progress
 
 
 
 
 
 
 
 
533
  progress_bar = st.progress(0.0)
534
  total_threads = len(page_threads)
535
+ start_join_time = time.time()
536
+ while any(t.is_alive() for t in page_threads):
537
+ completed_threads = total_threads - sum(t.is_alive() for t in page_threads)
538
+ progress = completed_threads / total_threads if total_threads > 0 else 1.0
539
+ progress_bar.progress(min(progress, 1.0)) # Cap at 1.0
540
+ if time.time() - start_join_time > 600: # Timeout after 10 mins
541
+ print("PDF processing timed out waiting for threads.")
542
+ break
543
+ time.sleep(0.5)
544
+ progress_bar.progress(1.0)
545
+
546
+ # Display results
547
  st.write("Processing complete. Displaying results:")
548
  for i in range(total_pages):
549
  with st.expander(f"Page {i+1}"):
550
  st.markdown(texts.get(i, "[Error getting text]"))
551
+ audio_file = audios.get(i)
552
  if audio_file: play_and_download_audio(audio_file)
553
+ else: st.caption("Audio generation failed or was skipped.")
554
+
555
+ except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
556
+
557
 
 
558
  # ==============================================================================
559
  # WebSocket Server Logic
560
  # ==============================================================================
 
596
  async def websocket_handler(websocket, path):
597
  """Handles WebSocket connections and messages."""
598
  await register_client(websocket); client_id = str(websocket.id);
599
+ # Use username from main session state - ASSUMES session state is accessible here.
600
+ # This might be unreliable depending on how threads/asyncio interact with Streamlit's context.
601
+ # A safer approach might involve passing necessary user info during registration if needed.
602
+ username = st.session_state.get('username', f"User_{client_id[:4]}")
603
+
604
  try: # Send initial state
605
  with world_objects_lock: initial_state_payload = dict(world_objects)
606
  initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload}); await websocket.send(initial_state_msg)
 
612
  async for message in websocket:
613
  try:
614
  data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
615
+ sender_username = payload.get("username", username) # Get username from payload
 
616
 
617
  if msg_type == "chat_message":
618
  chat_text = payload.get('message', ''); voice = payload.get('voice', FUN_USERNAMES.get(sender_username, "en-US-AriaNeural"));
619
+ run_async(save_chat_entry, sender_username, chat_text, voice) # Fire-and-forget
620
+ await broadcast_message(message, exclude_id=client_id) # Forward
 
621
 
622
  elif msg_type == "place_object":
623
  obj_data = payload.get("object_data");
 
638
  else: print(f"WS Invalid delete_object payload: {payload}")
639
 
640
  elif msg_type == "player_position":
641
+ pos_data = payload.get("position"); rot_data = payload.get("rotation")
 
642
  if pos_data:
643
  broadcast_payload = json.dumps({"type": "player_moved", "payload": {"username": sender_username, "id": client_id, "position": pos_data, "rotation": rot_data}});
644
  await broadcast_message(broadcast_payload, exclude_id=client_id)
645
 
 
 
646
  except json.JSONDecodeError: print(f"WS Invalid JSON from {client_id}: {message[:100]}...")
647
  except Exception as e: print(f"WS Error processing msg from {client_id}: {e}")
648
  except websockets.ConnectionClosed: print(f"WS Client disconnected: {client_id} ({username})")
649
  except Exception as e: print(f"WS Unexpected handler error {client_id}: {e}")
650
  finally:
651
  await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
652
+ await unregister_client(websocket) # Cleanup
653
 
654
 
655
  async def run_websocket_server():
 
659
  stop_event = asyncio.Event(); st.session_state['websocket_stop_event'] = stop_event
660
  server = None
661
  try:
662
+ # Changed host to 0.0.0.0 for accessibility, ensure firewall allows port 8765
663
  server = await websockets.serve(websocket_handler, "0.0.0.0", 8765); st.session_state['server_instance'] = server
664
  print(f"WS server started: {server.sockets[0].getsockname()}. Waiting for stop signal...")
665
  await stop_event.wait() # Keep running
666
+ except OSError as e: print(f"### FAILED START WS SERVER: {e}"); st.session_state['server_running_flag'] = False; # Reset flag on failure
667
+ except Exception as e: print(f"### UNEXPECTED WS SERVER ERROR: {e}"); st.session_state['server_running_flag'] = False; # Reset flag on failure
668
  finally:
669
  print("WS server task finishing...");
670
  if server: server.close(); await server.wait_closed(); print("WS server closed.")
 
676
  if st.session_state.get('server_running_flag', False): return
677
  print("Creating/starting new server thread.");
678
  def run_loop():
679
+ current_loop = None
680
+ try:
681
+ current_loop = asyncio.get_event_loop()
682
+ if current_loop.is_running():
683
+ print("Server thread: Attaching to existing running loop (rare case).")
684
+ # If already running, might need different approach, but usually new thread = new loop
685
+ # This case is less likely with daemon threads starting fresh.
686
+ else:
687
+ raise RuntimeError("No running loop found initially - expected.")
688
+ except RuntimeError: # No loop in this thread, create new one
689
+ print("Server thread: Creating new event loop.")
690
+ loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop)
691
+ try: loop.run_until_complete(run_websocket_server())
692
+ finally:
693
+ # Gracefully shutdown tasks if loop is closing
694
+ tasks = asyncio.all_tasks(loop)
695
+ for task in tasks: task.cancel()
696
+ loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
697
+ loop.close(); print("Server thread loop closed.")
698
+
699
  st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5)
700
  if not st.session_state.server_task.is_alive(): print("### Server thread failed to stay alive!")
701
 
702
 
703
  # ==============================================================================
704
+ # Streamlit UI Layout Functions
705
  # ==============================================================================
706
 
707
  def render_sidebar():
 
710
  st.header("๐Ÿ’พ World Versions")
711
  st.caption("Load or save named world states.")
712
 
 
713
  saved_worlds = get_saved_worlds()
714
+ # Format options for radio button display
715
+ world_options_display = {os.path.basename(w['filename']): f"{w['name']} ({w['timestamp']})" for w in saved_worlds}
716
+ # The actual options list stores basenames
 
 
 
 
717
  radio_options_basenames = [None] + [os.path.basename(w['filename']) for w in saved_worlds]
718
 
719
+ current_selection_basename = st.session_state.get('current_world_file', None)
720
+ current_radio_index = 0
721
  if current_selection_basename and current_selection_basename in radio_options_basenames:
722
+ try: current_radio_index = radio_options_basenames.index(current_selection_basename)
723
+ except ValueError: current_radio_index = 0 # Default to None if not found
 
 
724
 
 
725
  selected_basename = st.radio(
726
+ "Load World:", options=radio_options_basenames, index=current_radio_index,
727
+ format_func=lambda x: "Live State (Unsaved)" if x is None else world_options_display.get(x, x), # Display formatted name
 
 
728
  key="world_selector_radio"
729
  )
730
 
731
  # Handle selection change
732
  if selected_basename != current_selection_basename:
733
+ st.session_state.current_world_file = selected_basename # Store selected basename
734
  if selected_basename:
735
  with st.spinner(f"Loading {selected_basename}..."):
736
  if load_world_state_from_md(selected_basename):
737
  run_async(broadcast_world_update) # Broadcast new state
738
  st.toast("World loaded!", icon="โœ…")
739
+ else: st.error("Failed to load world."); st.session_state.current_world_file = None
740
+ else: # Switched to "Live State"
 
741
  print("Switched to live state.")
742
+ # Optionally clear world state or just stop tracking file? Stop tracking.
743
+ # Maybe broadcast current live state to ensure consistency?
744
+ # run_async(broadcast_world_update)
745
  st.toast("Switched to Live State.")
746
  st.rerun()
747
 
748
+ # Download Links for Worlds
749
  st.caption("Download:")
750
+ cols = st.columns([4, 1]) # Columns for name and download button
751
+ with cols[0]: st.write("**World Name** (Timestamp)")
752
+ with cols[1]: st.write("**DL**")
753
+ # Display max 10 worlds initially, add expander if more?
754
+ for world_info in saved_worlds[:10]: # Limit display
755
  f_basename = os.path.basename(world_info['filename'])
756
+ f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename) # Reconstruct full path for link
757
  display_name = world_info.get('name', f_basename)
758
  timestamp = world_info.get('timestamp', 'N/A')
759
+ col1, col2 = st.columns([4, 1])
760
  with col1: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
761
  with col2: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
762
+ if len(saved_worlds) > 10:
763
+ with st.expander(f"Show {len(saved_worlds)-10} more..."):
764
+ for world_info in saved_worlds[10:]:
765
+ # Repeat display logic
766
+ f_basename = os.path.basename(world_info['filename'])
767
+ f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename)
768
+ display_name = world_info.get('name', f_basename)
769
+ timestamp = world_info.get('timestamp', 'N/A')
770
+ col1, col2 = st.columns([4, 1])
771
+ with col1: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
772
+ with col2: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
773
 
774
  st.markdown("---")
775
 
776
+ # Build Tools Section
 
 
777
  st.header("๐Ÿ—๏ธ Build Tools")
778
  st.caption("Select an object to place.")
779
  cols = st.columns(5)
 
784
  if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type, use_container_width=True):
785
  if st.session_state.get('selected_object', 'None') != name:
786
  st.session_state.selected_object = name
787
+ # Fire and forget JS update
788
+ run_async(lambda name_arg=name: streamlit_js_eval(f"updateSelectedObjectType({json.dumps(name_arg)});", key=f"update_tool_js_{name_arg}"))
789
  st.rerun()
790
  col_idx += 1
791
  st.markdown("---")
 
795
  run_async(lambda: streamlit_js_eval("updateSelectedObjectType('None');", key="update_tool_js_none"))
796
  st.rerun()
797
 
798
+ # Voice/User Section
799
  st.markdown("---")
800
  st.header("๐Ÿ—ฃ๏ธ Voice & User")
801
  current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0])
802
  username_options = list(FUN_USERNAMES.keys()); current_index = 0
803
  try: current_index = username_options.index(current_username)
804
+ except ValueError: current_index = 0 # Handle case where saved username is no longer valid
805
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
806
  if new_username != st.session_state.username:
807
  old_username = st.session_state.username
808
  change_msg = json.dumps({"type":"user_rename", "payload": {"old_username": old_username, "new_username": new_username}})
809
+ run_async(broadcast_message, change_msg) # Fire and forget broadcast
810
  st.session_state.username = new_username; st.session_state.tts_voice = FUN_USERNAMES[new_username]; save_username(st.session_state.username)
811
  st.rerun()
812
  st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
 
822
  with tab_world:
823
  st.header("Shared 3D World")
824
  st.caption("Place objects using the sidebar tools. Changes are shared live!")
825
+ current_file_basename = st.session_state.get('current_world_file', None)
826
+ if current_file_basename:
827
+ # Reconstruct full path for parsing if needed, or just use basename
828
+ parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file_basename)) # Parse info
829
+ st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
830
+ else: st.info("Live State Active (Unsaved changes only persist if saved)")
831
 
832
  # Embed HTML Component
833
  html_file_path = 'index.html'
834
  try:
835
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
836
+ ws_url = "ws://localhost:8765" # Default for local dev
837
+ try: # Attempt to get dynamic host
838
  from streamlit.web.server.server import Server
839
  session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id)
840
  server_host = session_info.ws.stream.request.host.split(':')[0]
841
  ws_url = f"ws://{server_host}:8765"
842
+ except Exception as e: print(f"WS URL detection failed ({e}), using localhost.")
843
 
844
  js_injection_script = f"""<script>
845
  window.USERNAME = {json.dumps(st.session_state.username)};
 
857
  # --- Chat Tab ---
858
  with tab_chat:
859
  st.header(f"{START_ROOM} Chat")
860
+ # Load history - use run_async result or session state if already loaded
861
+ if 'chat_history' not in st.session_state or not st.session_state.chat_history:
862
+ chat_history = asyncio.run(load_chat_history()) # Blocking load if first time
863
+ else:
864
+ chat_history = st.session_state.chat_history
865
+
866
  chat_container = st.container(height=500)
867
  with chat_container:
868
+ if chat_history: st.markdown("----\n".join(reversed(chat_history[-50:]))) # Show last 50, reversed
869
  else: st.caption("No chat messages yet.")
870
 
871
  # Chat Input Area
 
876
  if send_button_clicked or should_autosend:
877
  message_to_send = message_value
878
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
879
+ st.session_state.last_message = message_to_send # Update tracker
880
  voice = FUN_USERNAMES.get(st.session_state.username, "en-US-AriaNeural")
881
  ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}})
882
+ # Fire and forget async tasks
883
+ run_async(broadcast_message, ws_message)
884
+ run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
885
  st.session_state.message_input = "" # Clear state for next run
886
  st.rerun()
887
  elif send_button_clicked: st.toast("Message empty or same as last.")
 
888
  st.checkbox("Autosend Chat", key="autosend") # Toggle autosend
889
 
890
  # --- PDF Tab ---
891
  with tab_pdf:
892
  st.header("๐Ÿ“š PDF Tools")
893
  pdf_file = st.file_uploader("Upload PDF for Audio Conversion", type="pdf", key="pdf_upload")
894
+ max_pages = st.slider('Max Pages to Process', 1, 50, 10, key="pdf_pages")
895
  if pdf_file:
896
+ # Use a button to trigger potentially long processing
897
+ if st.button("Process PDF to Audio", key="process_pdf_button"):
898
+ with st.spinner("Processing PDF... This may take time."):
899
+ process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice)
900
 
901
  # --- Files & Settings Tab ---
902
  with tab_files:
903
  st.header("๐Ÿ“‚ Files & Settings")
904
  st.subheader("๐Ÿ’พ World State Management")
905
+ current_file_basename = st.session_state.get('current_world_file', None)
906
 
907
  # Save Current Version Button
908
+ if current_file_basename:
909
+ parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file_basename))
910
+ save_label = f"Save Changes to '{parsed['name']}'"
911
+ if st.button(save_label, key="save_current_world", help=f"Overwrite '{current_file_basename}'"):
912
+ with st.spinner(f"Overwriting {current_file_basename}..."):
913
+ if save_world_state_to_md(current_file_basename): st.success("Current world saved!")
914
+ else: st.error("Failed to save world.")
915
  else:
916
+ st.info("Load a world from the sidebar to enable saving changes to it.")
917
 
918
  # Save As New Version Section
919
  st.subheader("Save As New Version")
920
+ new_name_files = st.text_input("New World Name:", key="new_world_name_files", value=st.session_state.get('new_world_name', 'MyWorld'))
921
  if st.button("๐Ÿ’พ Save Live State as New Version", key="save_new_version_files"):
922
  if new_name_files.strip():
923
+ new_filename_base = generate_world_save_filename(new_name_files)
924
  with st.spinner(f"Saving new version '{new_name_files}'..."):
925
+ if save_world_state_to_md(new_filename_base):
926
+ st.success(f"Saved as {new_filename_base}")
927
+ st.session_state.current_world_file = new_filename_base # Switch to new file
928
+ st.session_state.new_world_name = "MyWorld" # Reset default for next time
929
  st.rerun()
930
  else: st.error("Failed to save new version.")
931
+ else: st.warning("Please enter a name.")
932
 
933
  st.subheader("โš™๏ธ Server Status")
934
  col_ws, col_clients = st.columns(2)
 
946
  with col_del2:
947
  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()
948
  with col_del3:
949
+ if st.button("๐Ÿ—‘๏ธ Worlds", key="del_worlds_md"): delete_files([os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")], exclude_files=[st.session_state.get('current_world_file')]); st.session_state.current_world_file = None; st.rerun() # Protect current? No, delete all.
950
  with col_del4:
951
  if st.button("๐Ÿ—‘๏ธ All Gen", key="del_all_gen", help="Deletes Chats, Audio, Worlds, Zips"): delete_files([os.path.join(CHAT_DIR, "*.md"), os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3"), os.path.join(SAVED_WORLDS_DIR, "*.md"), "*.zip"]); st.session_state.chat_history = []; st.session_state.audio_cache = {}; st.session_state.current_world_file = None; st.rerun()
952
 
953
  # Download Archives
954
  st.subheader("๐Ÿ“ฆ Download Archives")
955
+ zip_files = sorted(glob.glob(os.path.join(MEDIA_DIR,"*.zip")), key=os.path.getmtime, reverse=True) # Look in base dir
956
  if zip_files:
957
+ # Zip specific content types
958
+ col_zip1, col_zip2, col_zip3 = st.columns(3)
959
+ with col_zip1:
960
+ if st.button("Zip Worlds"): create_zip_of_files(glob.glob(os.path.join(SAVED_WORLDS_DIR, "*.md")), "Worlds")
961
+ with col_zip2:
962
+ if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
963
+ with col_zip3:
964
+ 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")
965
+
966
+ st.caption("Existing Zip Files:")
967
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
968
  else: st.caption("No zip archives found.")
969
 
 
974
 
975
  def initialize_world():
976
  """Loads initial world state (most recent) if not already loaded."""
977
+ # This check prevents reloading state on every single rerun, only on session start
978
  if not st.session_state.get('initial_world_state_loaded', False):
979
+ print("Performing initial world load for session...")
980
  saved_worlds = get_saved_worlds()
981
+ loaded_successfully = False
982
  if saved_worlds:
983
+ # Load the most recent world file (first in sorted list)
984
  latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
985
  print(f"Loading most recent world on startup: {latest_world_file_basename}")
986
+ if load_world_state_from_md(latest_world_file_basename): # This updates global state and sets session state 'current_world_file'
987
+ loaded_successfully = True
988
+ else:
989
+ print("Failed to load most recent world, starting empty.")
990
  else:
991
  print("No saved worlds found, starting with empty state.")
 
 
 
 
992
 
993
+ # Ensure global dict is empty if no file loaded successfully
994
+ if not loaded_successfully:
995
+ with world_objects_lock: world_objects.clear()
996
+ st.session_state.current_world_file = None # Ensure no file is marked as loaded
997
+
998
+ st.session_state.initial_world_state_loaded = True # Mark as loaded for this session
999
+ print("Initial world load process complete.")
1000
 
1001
  if __name__ == "__main__":
1002
+ # 1. Initialize session state first (essential for other checks)
1003
  init_session_state()
1004
 
1005
+ # 2. Start WebSocket server thread if needed (check flags and thread life)
1006
+ # Use server_running_flag to prevent multiple start attempts
1007
+ server_thread = st.session_state.get('server_task')
1008
+ server_alive = server_thread is not None and server_thread.is_alive()
1009
+ if not st.session_state.get('server_running_flag', False) and not server_alive:
1010
+ start_websocket_server_thread()
1011
+ elif server_alive and not st.session_state.get('server_running_flag', False):
1012
+ # Correct flag if thread is alive but flag is false
1013
+ st.session_state.server_running_flag = True
1014
+
1015
+ # 3. Load initial world state from disk if not already done for this session
 
 
1016
  initialize_world()
1017
 
1018
  # 4. Render the UI (Sidebar and Main Content)
1019
  render_sidebar()
1020
+ render_main_content()
1021
+
1022
+ # 5. Optional Periodic Save (Example - uncomment to enable)
1023
+ # interval_seconds = 300 # 5 minutes
1024
+ # if 'last_periodic_save' not in st.session_state: st.session_state.last_periodic_save = 0
1025
+ # if time.time() - st.session_state.last_periodic_save > interval_seconds:
1026
+ # current_file_to_save = st.session_state.get('current_world_file')
1027
+ # if current_file_to_save: # Only save if a specific file is loaded
1028
+ # print(f"Triggering periodic save for {current_file_to_save}...")
1029
+ # if save_world_state_to_md(current_file_to_save):
1030
+ # st.session_state.last_periodic_save = time.time()
1031
+ # print("Periodic save successful.")
1032
+ # else:
1033
+ # print("Periodic save failed.")
1034
+ # else:
1035
+ # st.session_state.last_periodic_save = time.time() # Reset timer even if not saving