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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +166 -294
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # app.py (Refactored & Consolidated - Checked Indentation)
2
  import streamlit as st
3
  import asyncio
4
  import websockets
@@ -22,9 +22,9 @@ import threading
22
  import json
23
  import zipfile
24
  from dotenv import load_dotenv
25
- 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 # Needed for paste_image_component
30
 
@@ -115,14 +115,10 @@ def clean_filename_part(text, max_len=30):
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))
@@ -157,27 +153,21 @@ def parse_world_filename(filename):
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
 
@@ -189,10 +179,8 @@ def save_world_state_to_md(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']}
@@ -210,7 +198,6 @@ def save_world_state_to_md(target_filename_base):
210
  success = True
211
  except Exception as e:
212
  print(f"Error saving world state to {save_path}: {e}")
213
- # Avoid st.error in potentially non-main thread
214
  return success
215
 
216
 
@@ -219,14 +206,11 @@ def load_world_state_from_md(filename_base):
219
  global world_objects
220
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
221
  print(f"Loading world state from MD file: {load_path}...")
222
- if not os.path.exists(load_path):
223
- st.error(f"World file not found: {filename_base}")
224
- return False
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))
@@ -247,16 +231,12 @@ 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:
257
- print(f"Error scanning saved worlds: {e}")
258
- st.error(f"Could not scan saved worlds: {e}")
259
- return []
260
 
261
  # ==============================================================================
262
  # User State & Session Init
@@ -305,76 +285,61 @@ 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)
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
 
321
  except Exception as e: print(f"Error creating file {save_path}: {e}"); return None
322
 
323
  def get_download_link(file_path, file_type="md"):
324
- """Generates a base64 download link for a given file."""
325
- if not file_path or not os.path.exists(file_path):
326
- basename = os.path.basename(file_path) if file_path else "N/A"
327
- return f"<small>Not found: {basename}</small>"
328
  try: mtime = os.path.getmtime(file_path)
329
  except OSError: mtime = 0
330
- cache_key = f"dl_{file_path}_{mtime}"
331
  if 'download_link_cache' not in st.session_state: st.session_state.download_link_cache = {}
332
  if cache_key not in st.session_state.download_link_cache:
333
  try:
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}")
342
- return f"<small>Err</small>"
343
  return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
344
 
345
  # --- Audio / TTS ---
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)
352
  if cached_path and os.path.exists(cached_path): return cached_path
353
-
354
  text_cleaned = clean_text_for_tts(text);
355
  if not text_cleaned or text_cleaned == "No text": return None
356
  filename_base = generate_filename(text_cleaned, username, "mp3"); save_path = os.path.join(AUDIO_DIR, filename_base);
357
  ensure_dir(AUDIO_DIR)
358
  try:
359
  communicate = edge_tts.Communicate(text_cleaned, voice); await communicate.save(save_path);
360
- if os.path.exists(save_path) and os.path.getsize(save_path) > 0:
361
- st.session_state.audio_cache[cache_key] = save_path; return save_path
362
  else: print(f"Audio file {save_path} failed generation."); return None
363
  except Exception as e: print(f"Edge TTS Error: {e}"); return None
364
 
365
  def play_and_download_audio(file_path):
366
- """Displays audio player and download link in Streamlit."""
367
  if file_path and os.path.exists(file_path):
368
  try:
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):
377
- """Saves chat entry to a file and session state, generates audio."""
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```"
@@ -389,10 +354,8 @@ async def save_chat_entry(username, message, voice, is_markdown=False):
389
  return md_file, audio_file
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)
397
  print("Loading chat history from files...")
398
  chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime); loaded_count = 0
@@ -413,47 +376,36 @@ def create_zip_of_files(files_to_zip, prefix="Archive"):
413
  print(f"Creating zip: {zip_name}...");
414
  with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as z:
415
  for f in files_to_zip:
416
- if os.path.exists(f): z.write(f, os.path.basename(f))
417
  else: print(f"Skip zip missing: {f}")
418
  print("Zip success."); st.success(f"Created {zip_name}"); return zip_name
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
443
  if os.path.isfile(f_path) and basename not in 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 ---
@@ -466,17 +418,18 @@ async def save_pasted_image(image, username):
466
 
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]
474
  st.image(pasted_img, caption=f"Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str
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 ---
@@ -487,73 +440,71 @@ class AudioProcessor:
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
494
  ensure_dir(os.path.dirname(cache_path))
495
  try:
496
  communicate=edge_tts.Communicate(text_cleaned,voice); await communicate.save(cache_path)
497
- if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0:
498
- self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text_cleaned), 'voice': voice}; self._save_metadata()
499
- return cache_path
500
  else: return None
501
  except Exception as e: print(f"TTS Create Audio Error: {e}"); 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
@@ -570,37 +521,29 @@ async def unregister_client(websocket):
570
  print(f"Client unregistered: {client_id}. Remaining: {len(connected_clients)}")
571
 
572
  async def send_safely(websocket, message, client_id):
573
- """Wrapper to send message and handle potential connection errors."""
574
  try: await websocket.send(message)
575
  except websockets.ConnectionClosed: print(f"WS Send failed (Closed) client {client_id}"); raise
576
  except RuntimeError as e: print(f"WS Send failed (Runtime {e}) client {client_id}"); raise
577
  except Exception as e: print(f"WS Send failed (Other {e}) client {client_id}"); raise
578
 
579
  async def broadcast_message(message, exclude_id=None):
580
- """Sends a message to all connected clients except the excluded one."""
581
  if not connected_clients: return
582
  tasks = []; current_client_ids = list(connected_clients); active_connections_copy = st.session_state.active_connections.copy()
583
  for client_id in current_client_ids:
584
  if client_id == exclude_id: continue
585
  websocket = active_connections_copy.get(client_id)
586
  if websocket: tasks.append(asyncio.create_task(send_safely(websocket, message, client_id)))
587
- if tasks: await asyncio.gather(*tasks, return_exceptions=True) # Gather results/exceptions
588
 
589
  async def broadcast_world_update():
590
- """Broadcasts the current world state to all clients."""
591
  with world_objects_lock: current_state_payload = dict(world_objects)
592
- update_msg = json.dumps({"type": "initial_state", "payload": current_state_payload}) # Force full refresh
593
  print(f"Broadcasting full world update ({len(current_state_payload)} objects)...")
594
  await broadcast_message(update_msg)
595
 
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,12 +555,12 @@ async def websocket_handler(websocket, path):
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");
@@ -649,53 +592,42 @@ async def websocket_handler(websocket, path):
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():
656
- """Coroutine to run the WebSocket server."""
657
  if st.session_state.get('server_running_flag', False): return
658
  st.session_state['server_running_flag'] = True; print("Attempting start WS server 0.0.0.0:8765...")
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.")
671
  st.session_state['server_running_flag'] = False; st.session_state['server_instance'] = None; st.session_state['websocket_stop_event'] = None
672
 
673
  def start_websocket_server_thread():
674
- """Starts the WebSocket server in a separate thread if not already running."""
675
  if st.session_state.get('server_task') and st.session_state.server_task.is_alive(): return
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
 
@@ -711,69 +643,54 @@ def render_sidebar():
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,29 +701,27 @@ def render_sidebar():
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("---")
792
  if st.button("๐Ÿšซ Clear Tool", key="clear_tool", use_container_width=True):
793
  if st.session_state.get('selected_object', 'None') != 'None':
794
- st.session_state.selected_object = 'None'
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))
@@ -824,17 +739,15 @@ def render_main_content():
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]
@@ -857,18 +770,13 @@ def render_main_content():
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
872
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
873
  send_button_clicked = st.button("Send Chat ๐Ÿ’ฌ", key="send_chat_button")
874
  should_autosend = st.session_state.get('autosend', False) and message_value
@@ -876,16 +784,15 @@ def render_main_content():
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:
@@ -893,7 +800,6 @@ def render_main_content():
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)
@@ -904,18 +810,15 @@ def render_main_content():
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"):
@@ -924,9 +827,7 @@ def render_main_content():
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
 
@@ -937,7 +838,6 @@ def render_main_content():
937
  if not server_alive and st.button("Restart Server Thread", key="restart_ws"): start_websocket_server_thread(); st.rerun()
938
  with col_clients: st.metric("Connected Clients", len(connected_clients))
939
 
940
- # File Deletion
941
  st.subheader("๐Ÿ—‘๏ธ Delete Files")
942
  st.warning("Deletion is permanent!", icon="โš ๏ธ")
943
  col_del1, col_del2, col_del3, col_del4 = st.columns(4)
@@ -946,18 +846,16 @@ def render_main_content():
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:
@@ -965,7 +863,9 @@ def render_main_content():
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
 
970
 
971
  # ==============================================================================
@@ -973,63 +873,35 @@ def render_main_content():
973
  # ==============================================================================
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
 
1
+ # app.py (Refactored & Consolidated - Indentation Fixes Applied)
2
  import streamlit as st
3
  import asyncio
4
  import websockets
 
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
 
 
115
 
116
  def run_async(async_func, *args, **kwargs):
117
  """Runs an async function safely from a sync context using create_task."""
 
 
118
  try:
119
  loop = asyncio.get_running_loop()
120
  return loop.create_task(async_func(*args, **kwargs))
121
  except RuntimeError: # No running loop in this thread
 
 
122
  print(f"Warning: Running async func {async_func.__name__} in new event loop.")
123
  try:
124
  return asyncio.run(async_func(*args, **kwargs))
 
153
  basename = os.path.basename(filename)
154
  # Check prefix and suffix
155
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
 
156
  core_name = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
157
  parts = core_name.split('_')
158
  if len(parts) >= 3: # Expecting Name_Timestamp_Hash
159
  timestamp_str = parts[-2]
160
+ name_parts = parts[:-2]; name = "_".join(name_parts) if name_parts else "Untitled"
 
 
161
  dt_obj = None
162
  try: # Try parsing timestamp
163
  dt_obj = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S')
164
  dt_obj = pytz.utc.localize(dt_obj) # Assume UTC
165
+ except (ValueError, pytz.exceptions.AmbiguousTimeError, pytz.exceptions.NonExistentTimeError): dt_obj = None
 
166
  return {"name": name.replace('_', ' '), "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
167
 
168
  # Fallback for unknown format
169
  dt_fallback = None
170
+ try: mtime = os.path.getmtime(filename); dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
 
 
171
  except Exception: pass
172
  return {"name": basename.replace('.md',''), "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
173
 
 
179
  print(f"Acquiring lock to save world state to: {save_path}...")
180
  success = False
181
  with world_objects_lock:
 
182
  world_data_dict = dict(world_objects) # Convert defaultdict for saving
183
  print(f"Saving {len(world_data_dict)} objects...")
 
184
  parsed_info = parse_world_filename(save_path) # Parse the full path/intended name
185
  timestamp_save = get_current_time_str()
186
  md_content = f"""# World State: {parsed_info['name']}
 
198
  success = True
199
  except Exception as e:
200
  print(f"Error saving world state to {save_path}: {e}")
 
201
  return success
202
 
203
 
 
206
  global world_objects
207
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
208
  print(f"Loading world state from MD file: {load_path}...")
209
+ if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return False
 
 
210
 
211
  try:
212
  with open(load_path, 'r', encoding='utf-8') as f: content = f.read()
213
+ json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE) # More robust regex
 
214
  if not json_match: st.error(f"Could not find valid JSON block in {filename_base}"); return False
215
 
216
  world_data_dict = json.loads(json_match.group(1))
 
231
  """Scans the saved worlds directory for world MD files and parses them."""
232
  try:
233
  ensure_dir(SAVED_WORLDS_DIR)
 
234
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
235
  parsed_worlds = [parse_world_filename(f) for f in world_files]
 
236
  parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
237
  return parsed_worlds
238
  except Exception as e:
239
+ print(f"Error scanning saved worlds: {e}"); st.error(f"Could not scan saved worlds: {e}"); return []
 
 
240
 
241
  # ==============================================================================
242
  # User State & Session Init
 
285
  # --- Text & File Helpers ---
286
  def clean_text_for_tts(text):
287
  if not isinstance(text, str): return "No text"
288
+ text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
289
+ text = ' '.join(text.split()); return text[:250] or "No text"
 
 
290
 
291
  def create_file(content, username, file_type="md", save_path=None):
292
+ if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
 
 
293
  ensure_dir(os.path.dirname(save_path))
294
  try:
295
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content)
296
+ # print(f"Created file: {save_path}"); # Can be too verbose
297
+ return save_path
298
  except Exception as e: print(f"Error creating file {save_path}: {e}"); return None
299
 
300
  def get_download_link(file_path, file_type="md"):
301
+ 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>"
 
 
 
302
  try: mtime = os.path.getmtime(file_path)
303
  except OSError: mtime = 0
304
+ cache_key = f"dl_{file_path}_{mtime}";
305
  if 'download_link_cache' not in st.session_state: st.session_state.download_link_cache = {}
306
  if cache_key not in st.session_state.download_link_cache:
307
  try:
308
  with open(file_path, "rb") as f: b64 = base64.b64encode(f.read()).decode()
309
  mime_types = {"md": "text/markdown", "mp3": "audio/mpeg", "png": "image/png", "mp4": "video/mp4", "zip": "application/zip", "json": "application/json"}
310
  basename = os.path.basename(file_path)
 
311
  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>'
312
  st.session_state.download_link_cache[cache_key] = link_html
313
+ except Exception as e: print(f"Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
 
 
314
  return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
315
 
316
  # --- Audio / TTS ---
317
  async def async_edge_tts_generate(text, voice, username):
 
318
  if not text: return None
319
+ cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
320
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
321
+ cached_path = st.session_state.audio_cache.get(cache_key);
322
  if cached_path and os.path.exists(cached_path): return cached_path
 
323
  text_cleaned = clean_text_for_tts(text);
324
  if not text_cleaned or text_cleaned == "No text": return None
325
  filename_base = generate_filename(text_cleaned, username, "mp3"); save_path = os.path.join(AUDIO_DIR, filename_base);
326
  ensure_dir(AUDIO_DIR)
327
  try:
328
  communicate = edge_tts.Communicate(text_cleaned, voice); await communicate.save(save_path);
329
+ if os.path.exists(save_path) and os.path.getsize(save_path) > 0: st.session_state.audio_cache[cache_key] = save_path; return save_path
 
330
  else: print(f"Audio file {save_path} failed generation."); return None
331
  except Exception as e: print(f"Edge TTS Error: {e}"); return None
332
 
333
  def play_and_download_audio(file_path):
 
334
  if file_path and os.path.exists(file_path):
335
  try:
336
  st.audio(file_path)
337
  file_type = file_path.split('.')[-1]
338
  st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
339
  except Exception as e: st.error(f"Audio display error for {os.path.basename(file_path)}: {e}")
 
340
 
341
  # --- Chat ---
342
  async def save_chat_entry(username, message, voice, is_markdown=False):
 
343
  if not message.strip(): return None, None
344
  timestamp_str = get_current_time_str();
345
  entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
 
354
  return md_file, audio_file
355
 
356
  async def load_chat_history():
 
 
357
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
358
+ if not st.session_state.chat_history: # Only load from files if session state is empty
359
  ensure_dir(CHAT_DIR)
360
  print("Loading chat history from files...")
361
  chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime); loaded_count = 0
 
376
  print(f"Creating zip: {zip_name}...");
377
  with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as z:
378
  for f in files_to_zip:
379
+ if os.path.exists(f): z.write(f, os.path.basename(f)) # Use basename in archive
380
  else: print(f"Skip zip missing: {f}")
381
  print("Zip success."); st.success(f"Created {zip_name}"); return zip_name
382
  except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
383
 
384
  def delete_files(file_patterns, exclude_files=None):
385
  """Deletes files matching patterns, excluding protected/specified files."""
 
386
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
387
+ # Don't automatically protect current world file during generic delete
 
 
 
388
  if exclude_files: protected.extend(exclude_files)
389
 
390
  deleted_count = 0; errors = 0
391
  for pattern in file_patterns:
392
+ pattern_path = pattern # Assume pattern includes path from os.path.join
 
393
  print(f"Attempting to delete files matching: {pattern_path}")
394
  try:
395
  files_to_delete = glob.glob(pattern_path)
396
  if not files_to_delete: print(f"No files found for pattern: {pattern}"); continue
 
397
  for f_path in files_to_delete:
398
  basename = os.path.basename(f_path)
 
399
  if os.path.isfile(f_path) and basename not in protected:
400
  try: os.remove(f_path); print(f"Deleted: {f_path}"); deleted_count += 1
401
  except Exception as e: print(f"Failed delete {f_path}: {e}"); errors += 1
402
  elif os.path.isdir(f_path): print(f"Skipping directory: {f_path}")
 
403
  except Exception as glob_e: print(f"Error matching pattern {pattern}: {glob_e}"); errors += 1
 
404
  msg = f"Deleted {deleted_count} files.";
405
  if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
406
  elif deleted_count > 0: st.success(msg)
407
  else: st.info("No matching files found to delete.")
408
+ st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
 
 
409
 
410
 
411
  # --- Image Handling ---
 
418
 
419
  def paste_image_component():
420
  pasted_img = None; img_type = None
421
+ # Added default value to text_area to ensure key exists even if empty
422
+ paste_input = st.text_area("Paste Image Data Here", key="paste_input_area", height=50, value="")
423
+ if st.button("Process Pasted Image ๐Ÿ“‹", key="paste_form_button"): # Simplified trigger
424
+ if paste_input and paste_input.startswith('data:image'):
425
  try:
426
  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]
427
  st.image(pasted_img, caption=f"Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str
428
  except ImportError: st.error("Pillow library needed for image pasting.")
429
  except Exception as e: st.error(f"Img decode err: {e}"); st.session_state.paste_image_base64 = ""
430
+ else: st.warning("No valid image data pasted."); st.session_state.paste_image_base64 = ""
431
+ # Return image if processed successfully in this run
432
+ return pasted_img
433
 
434
 
435
  # --- PDF Processing ---
 
440
  with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f, indent=2)
441
  except Exception as e: print(f"Failed metadata save: {e}")
442
  async def create_audio(self, text, voice='en-US-AriaNeural'):
443
+ cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=os.path.join(self.cache_dir, f"{cache_key}.mp3");
444
  if cache_key in self.metadata and os.path.exists(cache_path): return cache_path
445
  text_cleaned=clean_text_for_tts(text);
446
  if not text_cleaned: return None
447
  ensure_dir(os.path.dirname(cache_path))
448
  try:
449
  communicate=edge_tts.Communicate(text_cleaned,voice); await communicate.save(cache_path)
450
+ if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0: self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text_cleaned), 'voice': voice}; self._save_metadata(); return cache_path
 
 
451
  else: return None
452
  except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
453
 
454
  def process_pdf_tab(pdf_file, max_pages, voice):
455
+ st.subheader("PDF Processing Results") # Change header
456
+ if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
457
+ audio_processor = AudioProcessor()
458
+ try:
459
+ reader=PdfReader(pdf_file)
460
+ if reader.is_encrypted: st.warning("PDF is encrypted."); return
461
+ total_pages=min(len(reader.pages),max_pages);
462
+ st.write(f"Processing first {total_pages} pages of '{pdf_file.name}'...")
463
+ texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
464
+
465
+ def process_page_sync(page_num, page_text):
466
+ async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
467
+ try:
468
+ # asyncio.run can cause issues if called repeatedly in threads, use run_async helper
469
+ audio_path_future = run_async(run_async_audio)
470
+ # This needs careful handling. run_async might return a task or result.
471
+ # For simplicity here, let's assume it blocks or we retrieve result later.
472
+ # A better pattern uses concurrent.futures or manages event loop properly.
473
+ # Sticking to asyncio.run for now as run_async tries it.
474
+ audio_path = asyncio.run(run_async_audio()) # Revert to simpler asyncio.run for thread context
475
+ if audio_path:
476
+ with results_lock: audios[page_num] = audio_path
477
+ except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
478
+
479
+ # Start threads
480
+ for i in range(total_pages):
481
+ try:
482
+ page = reader.pages[i]; text = page.extract_text();
483
+ if text and text.strip(): texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
484
+ else: texts[i] = "[No text extracted]"
485
+ except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
486
+
487
+ # Wait for threads and display progress
488
+ progress_bar = st.progress(0.0, text="Processing pages...")
489
+ total_threads = len(page_threads)
490
+ start_join_time = time.time()
491
+ while any(t.is_alive() for t in page_threads):
492
+ completed_threads = total_threads - sum(t.is_alive() for t in page_threads)
493
+ progress = completed_threads / total_threads if total_threads > 0 else 1.0
494
+ progress_bar.progress(min(progress, 1.0), text=f"Processed {completed_threads}/{total_threads} pages...")
495
+ if time.time() - start_join_time > 600: print("PDF processing timed out."); break # 10 min timeout
496
+ time.sleep(0.5)
497
+ progress_bar.progress(1.0, text="Processing complete.")
498
+
499
+ # Display results
500
+ for i in range(total_pages):
501
+ with st.expander(f"Page {i+1}"):
502
+ st.markdown(texts.get(i, "[Error getting text]"))
503
+ audio_file = audios.get(i)
504
+ if audio_file: play_and_download_audio(audio_file)
505
+ else: st.caption("Audio generation failed or was skipped.")
506
+
507
+ except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
508
 
509
  # ==============================================================================
510
  # WebSocket Server Logic
 
521
  print(f"Client unregistered: {client_id}. Remaining: {len(connected_clients)}")
522
 
523
  async def send_safely(websocket, message, client_id):
 
524
  try: await websocket.send(message)
525
  except websockets.ConnectionClosed: print(f"WS Send failed (Closed) client {client_id}"); raise
526
  except RuntimeError as e: print(f"WS Send failed (Runtime {e}) client {client_id}"); raise
527
  except Exception as e: print(f"WS Send failed (Other {e}) client {client_id}"); raise
528
 
529
  async def broadcast_message(message, exclude_id=None):
 
530
  if not connected_clients: return
531
  tasks = []; current_client_ids = list(connected_clients); active_connections_copy = st.session_state.active_connections.copy()
532
  for client_id in current_client_ids:
533
  if client_id == exclude_id: continue
534
  websocket = active_connections_copy.get(client_id)
535
  if websocket: tasks.append(asyncio.create_task(send_safely(websocket, message, client_id)))
536
+ if tasks: await asyncio.gather(*tasks, return_exceptions=True)
537
 
538
  async def broadcast_world_update():
 
539
  with world_objects_lock: current_state_payload = dict(world_objects)
540
+ update_msg = json.dumps({"type": "initial_state", "payload": current_state_payload})
541
  print(f"Broadcasting full world update ({len(current_state_payload)} objects)...")
542
  await broadcast_message(update_msg)
543
 
544
  async def websocket_handler(websocket, path):
 
545
  await register_client(websocket); client_id = str(websocket.id);
 
 
 
546
  username = st.session_state.get('username', f"User_{client_id[:4]}")
 
547
  try: # Send initial state
548
  with world_objects_lock: initial_state_payload = dict(world_objects)
549
  initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload}); await websocket.send(initial_state_msg)
 
555
  async for message in websocket:
556
  try:
557
  data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
558
+ sender_username = payload.get("username", username)
559
 
560
  if msg_type == "chat_message":
561
  chat_text = payload.get('message', ''); voice = payload.get('voice', FUN_USERNAMES.get(sender_username, "en-US-AriaNeural"));
562
+ run_async(save_chat_entry, sender_username, chat_text, voice)
563
+ await broadcast_message(message, exclude_id=client_id)
564
 
565
  elif msg_type == "place_object":
566
  obj_data = payload.get("object_data");
 
592
  except Exception as e: print(f"WS Unexpected handler error {client_id}: {e}")
593
  finally:
594
  await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
595
+ await unregister_client(websocket)
596
 
597
 
598
  async def run_websocket_server():
 
599
  if st.session_state.get('server_running_flag', False): return
600
  st.session_state['server_running_flag'] = True; print("Attempting start WS server 0.0.0.0:8765...")
601
  stop_event = asyncio.Event(); st.session_state['websocket_stop_event'] = stop_event
602
  server = None
603
  try:
 
604
  server = await websockets.serve(websocket_handler, "0.0.0.0", 8765); st.session_state['server_instance'] = server
605
  print(f"WS server started: {server.sockets[0].getsockname()}. Waiting for stop signal...")
606
+ await stop_event.wait()
607
+ except OSError as e: print(f"### FAILED START WS SERVER: {e}"); st.session_state['server_running_flag'] = False;
608
+ except Exception as e: print(f"### UNEXPECTED WS SERVER ERROR: {e}"); st.session_state['server_running_flag'] = False;
609
  finally:
610
  print("WS server task finishing...");
611
  if server: server.close(); await server.wait_closed(); print("WS server closed.")
612
  st.session_state['server_running_flag'] = False; st.session_state['server_instance'] = None; st.session_state['websocket_stop_event'] = None
613
 
614
  def start_websocket_server_thread():
 
615
  if st.session_state.get('server_task') and st.session_state.server_task.is_alive(): return
616
  if st.session_state.get('server_running_flag', False): return
617
  print("Creating/starting new server thread.");
618
  def run_loop():
619
+ loop = None
620
+ try: loop = asyncio.get_running_loop()
621
+ except RuntimeError: loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop)
622
+ try: loop.run_until_complete(run_websocket_server())
623
+ finally:
624
+ if loop and not loop.is_closed():
625
+ tasks = asyncio.all_tasks(loop);
626
+ for task in tasks: task.cancel()
627
+ try: loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
628
+ except asyncio.CancelledError: pass
629
+ finally: loop.close(); print("Server thread loop closed.")
630
+ else: print("Server thread loop already closed or None.")
 
 
 
 
 
 
 
 
631
  st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5)
632
  if not st.session_state.server_task.is_alive(): print("### Server thread failed to stay alive!")
633
 
 
643
  st.caption("Load or save named world states.")
644
 
645
  saved_worlds = get_saved_worlds()
 
646
  world_options_display = {os.path.basename(w['filename']): f"{w['name']} ({w['timestamp']})" for w in saved_worlds}
 
647
  radio_options_basenames = [None] + [os.path.basename(w['filename']) for w in saved_worlds]
 
648
  current_selection_basename = st.session_state.get('current_world_file', None)
649
  current_radio_index = 0
650
  if current_selection_basename and current_selection_basename in radio_options_basenames:
651
  try: current_radio_index = radio_options_basenames.index(current_selection_basename)
652
+ except ValueError: current_radio_index = 0
653
 
654
  selected_basename = st.radio(
655
  "Load World:", options=radio_options_basenames, index=current_radio_index,
656
+ format_func=lambda x: "Live State (Unsaved)" if x is None else world_options_display.get(x, x),
657
  key="world_selector_radio"
658
  )
659
 
 
660
  if selected_basename != current_selection_basename:
661
+ st.session_state.current_world_file = selected_basename
662
  if selected_basename:
663
  with st.spinner(f"Loading {selected_basename}..."):
664
  if load_world_state_from_md(selected_basename):
665
+ run_async(broadcast_world_update)
666
  st.toast("World loaded!", icon="โœ…")
667
  else: st.error("Failed to load world."); st.session_state.current_world_file = None
668
+ else:
669
+ print("Switched to live state."); st.toast("Switched to Live State.")
 
 
 
 
670
  st.rerun()
671
 
 
672
  st.caption("Download:")
673
+ cols = st.columns([4, 1])
674
+ with cols[0]: st.write("**Name** (Timestamp)")
675
  with cols[1]: st.write("**DL**")
676
+ display_limit = 10
677
+ for i, world_info in enumerate(saved_worlds):
678
+ if i >= display_limit and len(saved_worlds) > display_limit + 1: # Show expander after limit
679
+ with st.expander(f"Show {len(saved_worlds)-display_limit} more..."):
680
+ for world_info_more in saved_worlds[display_limit:]:
681
+ f_basename = os.path.basename(world_info_more['filename']); f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename); display_name = world_info_more.get('name', f_basename); timestamp = world_info_more.get('timestamp', 'N/A')
682
+ colA, colB = st.columns([4, 1]);
683
+ with colA: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
684
+ with colB: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
685
+ break # Stop outer loop after showing expander
686
+
687
+ f_basename = os.path.basename(world_info['filename']); f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename); display_name = world_info.get('name', f_basename); timestamp = world_info.get('timestamp', 'N/A')
688
+ col1, col2 = st.columns([4, 1]);
689
  with col1: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
690
  with col2: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
691
 
 
692
 
693
+ st.markdown("---")
694
  st.header("๐Ÿ—๏ธ Build Tools")
695
  st.caption("Select an object to place.")
696
  cols = st.columns(5)
 
701
  if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type, use_container_width=True):
702
  if st.session_state.get('selected_object', 'None') != name:
703
  st.session_state.selected_object = name
 
704
  run_async(lambda name_arg=name: streamlit_js_eval(f"updateSelectedObjectType({json.dumps(name_arg)});", key=f"update_tool_js_{name_arg}"))
705
  st.rerun()
706
  col_idx += 1
707
  st.markdown("---")
708
  if st.button("๐Ÿšซ Clear Tool", key="clear_tool", use_container_width=True):
709
  if st.session_state.get('selected_object', 'None') != 'None':
710
+ st.session_state.selected_object = 'None';
711
  run_async(lambda: streamlit_js_eval("updateSelectedObjectType('None');", key="update_tool_js_none"))
712
  st.rerun()
713
 
 
714
  st.markdown("---")
715
  st.header("๐Ÿ—ฃ๏ธ Voice & User")
716
  current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0])
717
  username_options = list(FUN_USERNAMES.keys()); current_index = 0
718
  try: current_index = username_options.index(current_username)
719
+ except ValueError: current_index = 0
720
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
721
  if new_username != st.session_state.username:
722
  old_username = st.session_state.username
723
  change_msg = json.dumps({"type":"user_rename", "payload": {"old_username": old_username, "new_username": new_username}})
724
+ run_async(broadcast_message, change_msg)
725
  st.session_state.username = new_username; st.session_state.tts_voice = FUN_USERNAMES[new_username]; save_username(st.session_state.username)
726
  st.rerun()
727
  st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
 
739
  st.caption("Place objects using the sidebar tools. Changes are shared live!")
740
  current_file_basename = st.session_state.get('current_world_file', None)
741
  if current_file_basename:
742
+ parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file_basename)); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
 
 
743
  else: st.info("Live State Active (Unsaved changes only persist if saved)")
744
 
745
  # Embed HTML Component
746
  html_file_path = 'index.html'
747
  try:
748
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
749
+ ws_url = "ws://localhost:8765" # Default
750
+ try: # Get WS URL (Best effort)
751
  from streamlit.web.server.server import Server
752
  session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id)
753
  server_host = session_info.ws.stream.request.host.split(':')[0]
 
770
  # --- Chat Tab ---
771
  with tab_chat:
772
  st.header(f"{START_ROOM} Chat")
773
+ chat_history_task = run_async(load_chat_history) # Schedule load
774
+ chat_history = chat_history_task.result() if chat_history_task else st.session_state.chat_history # Get result if task ran sync, else use current state
 
 
 
 
775
  chat_container = st.container(height=500)
776
  with chat_container:
777
+ if chat_history: st.markdown("----\n".join(reversed(chat_history[-50:])))
778
  else: st.caption("No chat messages yet.")
779
 
 
780
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
781
  send_button_clicked = st.button("Send Chat ๐Ÿ’ฌ", key="send_chat_button")
782
  should_autosend = st.session_state.get('autosend', False) and message_value
 
784
  if send_button_clicked or should_autosend:
785
  message_to_send = message_value
786
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
787
+ st.session_state.last_message = message_to_send
788
  voice = FUN_USERNAMES.get(st.session_state.username, "en-US-AriaNeural")
789
  ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}})
 
790
  run_async(broadcast_message, ws_message)
791
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
792
  st.session_state.message_input = "" # Clear state for next run
793
  st.rerun()
794
  elif send_button_clicked: st.toast("Message empty or same as last.")
795
+ st.checkbox("Autosend Chat", key="autosend")
796
 
797
  # --- PDF Tab ---
798
  with tab_pdf:
 
800
  pdf_file = st.file_uploader("Upload PDF for Audio Conversion", type="pdf", key="pdf_upload")
801
  max_pages = st.slider('Max Pages to Process', 1, 50, 10, key="pdf_pages")
802
  if pdf_file:
 
803
  if st.button("Process PDF to Audio", key="process_pdf_button"):
804
  with st.spinner("Processing PDF... This may take time."):
805
  process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice)
 
810
  st.subheader("๐Ÿ’พ World State Management")
811
  current_file_basename = st.session_state.get('current_world_file', None)
812
 
 
813
  if current_file_basename:
814
  parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file_basename))
815
  save_label = f"Save Changes to '{parsed['name']}'"
816
  if st.button(save_label, key="save_current_world", help=f"Overwrite '{current_file_basename}'"):
817
  with st.spinner(f"Overwriting {current_file_basename}..."):
818
  if save_world_state_to_md(current_file_basename): st.success("Current world saved!")
819
+ else: st.error("Failed to save world state.")
820
+ else: st.info("Load a world from the sidebar to enable saving changes to it.")
 
821
 
 
822
  st.subheader("Save As New Version")
823
  new_name_files = st.text_input("New World Name:", key="new_world_name_files", value=st.session_state.get('new_world_name', 'MyWorld'))
824
  if st.button("๐Ÿ’พ Save Live State as New Version", key="save_new_version_files"):
 
827
  with st.spinner(f"Saving new version '{new_name_files}'..."):
828
  if save_world_state_to_md(new_filename_base):
829
  st.success(f"Saved as {new_filename_base}")
830
+ st.session_state.current_world_file = new_filename_base; st.session_state.new_world_name = "MyWorld"; st.rerun()
 
 
831
  else: st.error("Failed to save new version.")
832
  else: st.warning("Please enter a name.")
833
 
 
838
  if not server_alive and st.button("Restart Server Thread", key="restart_ws"): start_websocket_server_thread(); st.rerun()
839
  with col_clients: st.metric("Connected Clients", len(connected_clients))
840
 
 
841
  st.subheader("๐Ÿ—‘๏ธ Delete Files")
842
  st.warning("Deletion is permanent!", icon="โš ๏ธ")
843
  col_del1, col_del2, col_del3, col_del4 = st.columns(4)
 
846
  with col_del2:
847
  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()
848
  with col_del3:
849
+ 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()
850
  with col_del4:
851
+ 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"), "*.zip"]); st.session_state.chat_history = []; st.session_state.audio_cache = {}; st.session_state.current_world_file = None; st.rerun()
852
 
 
853
  st.subheader("๐Ÿ“ฆ Download Archives")
854
+ zip_files = sorted(glob.glob(os.path.join(MEDIA_DIR,"*.zip")), key=os.path.getmtime, reverse=True)
855
  if zip_files:
 
856
  col_zip1, col_zip2, col_zip3 = st.columns(3)
857
  with col_zip1:
858
+ if st.button("Zip Worlds"): create_zip_of_files([w['filename'] for w in get_saved_worlds()], "Worlds")
859
  with col_zip2:
860
  if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
861
  with col_zip3:
 
863
 
864
  st.caption("Existing Zip Files:")
865
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
866
+ else:
867
+ # Ensure correct indentation here for the else block
868
+ st.caption("No zip archives found.")
869
 
870
 
871
  # ==============================================================================
 
873
  # ==============================================================================
874
 
875
  def initialize_world():
876
+ """Loads initial world state (most recent) if not already done for this session."""
 
877
  if not st.session_state.get('initial_world_state_loaded', False):
878
  print("Performing initial world load for session...")
879
  saved_worlds = get_saved_worlds()
880
  loaded_successfully = False
881
  if saved_worlds:
 
882
  latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
883
  print(f"Loading most recent world on startup: {latest_world_file_basename}")
884
+ if load_world_state_from_md(latest_world_file_basename): loaded_successfully = True
885
+ else: print("Failed to load most recent world, starting empty.")
886
+ else: print("No saved worlds found, starting with empty state.")
 
 
 
 
 
887
  if not loaded_successfully:
888
  with world_objects_lock: world_objects.clear()
889
+ st.session_state.current_world_file = None
890
+ st.session_state.initial_world_state_loaded = True
 
891
  print("Initial world load process complete.")
892
 
893
  if __name__ == "__main__":
894
+ # 1. Initialize session state
895
  init_session_state()
896
 
897
+ # 2. Start WebSocket server thread if needed
898
+ server_thread = st.session_state.get('server_task'); server_alive = server_thread is not None and server_thread.is_alive()
899
+ if not st.session_state.get('server_running_flag', False) and not server_alive: start_websocket_server_thread()
900
+ elif server_alive and not st.session_state.get('server_running_flag', False): st.session_state.server_running_flag = True
901
+
902
+ # 3. Load initial world state (once per session)
 
 
 
 
 
903
  initialize_world()
904
 
905
+ # 4. Render UI
906
  render_sidebar()
907
+ render_main_content()