awacke1 commited on
Commit
c42f181
·
verified ·
1 Parent(s): 0787c72

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +200 -283
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
@@ -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 # Needed for paste_image_component
30
 
31
  # ==============================================================================
32
  # Configuration & Constants
@@ -74,20 +74,29 @@ WORLD_STATE_FILE_MD_PREFIX = "🌍_" # Prefix for world save files
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,12 +106,16 @@ def get_current_time_str(tz='UTC'):
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,21 +127,18 @@ def clean_filename_part(text, max_len=30):
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,56 +165,49 @@ def generate_world_save_filename(name="World"):
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,7 +216,6 @@ def save_world_state_to_md(target_filename_base):
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,16 +227,15 @@ def load_world_state_from_md(filename_base):
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,13 +245,12 @@ def load_world_state_from_md(filename_base):
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,16 +305,17 @@ 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
@@ -334,8 +335,7 @@ def get_download_link(file_path, file_type="md"):
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,6 +346,7 @@ 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 = 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,8 +370,8 @@ def play_and_download_audio(file_path):
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,8 +379,7 @@ 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,7 +390,6 @@ 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
- # 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,7 +400,7 @@ async def load_chat_history():
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,24 +418,20 @@ def create_zip_of_files(files_to_zip, prefix="Archive"):
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,17 +439,13 @@ def delete_files(file_patterns, exclude_files=None):
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,7 +458,7 @@ 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,11 +466,12 @@ def paste_image_component():
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,7 +479,7 @@ 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
@@ -502,55 +494,40 @@ class AudioProcessor:
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
 
@@ -596,11 +573,7 @@ async def broadcast_world_update():
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 +585,14 @@ 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");
@@ -638,18 +613,21 @@ async def websocket_handler(websocket, path):
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,12 +637,11 @@ 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,32 +653,15 @@ def start_websocket_server_thread():
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,70 +670,72 @@ 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,8 +746,7 @@ 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("---")
@@ -795,18 +756,18 @@ def render_sidebar():
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,24 +783,20 @@ def render_main_content():
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,15 +814,10 @@ 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
@@ -876,59 +828,56 @@ 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:
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,24 +895,14 @@ 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:
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,62 +913,40 @@ def render_main_content():
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)
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
30
 
31
  # ==============================================================================
32
  # Configuration & Constants
 
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
  """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
  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
  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
  # 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
 
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
  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
  # --- 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
  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
  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
  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
  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
 
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
  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
  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
  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
  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
  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
  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
 
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); total_pages=min(len(reader.pages),max_pages);
503
+ st.write(f"Processing first {total_pages} pages of '{pdf_file.name}'...")
 
 
504
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
505
 
506
  def process_page_sync(page_num, page_text):
507
+ # Runs async audio generation in a separate thread
508
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
509
+ try: audio_path = asyncio.run(run_async_audio());
 
 
510
  if audio_path:
511
  with results_lock: audios[page_num] = audio_path
512
  except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
513
 
514
  # Start threads
515
  for i in range(total_pages):
516
+ text=reader.pages[i].extract_text();
517
+ if text: texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
518
+ else: texts[i] = "[No text extracted]"
519
+
520
+ # Display results as they become available (or after join)
521
+ st.progress(0) # Placeholder for progress
522
+ # Wait for threads and display - consider using st.empty() for updates
523
+ for thread in page_threads: thread.join()
524
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  for i in range(total_pages):
526
  with st.expander(f"Page {i+1}"):
527
  st.markdown(texts.get(i, "[Error getting text]"))
528
  audio_file = audios.get(i)
529
  if audio_file: play_and_download_audio(audio_file)
530
+ else: st.caption("Audio generation failed or pending.")
531
 
532
  except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
533
 
 
573
  async def websocket_handler(websocket, path):
574
  """Handles WebSocket connections and messages."""
575
  await register_client(websocket); client_id = str(websocket.id);
576
+ username = st.session_state.get('username', f"User_{client_id[:4]}") # Get username for this session
 
 
 
 
577
  try: # Send initial state
578
  with world_objects_lock: initial_state_payload = dict(world_objects)
579
  initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload}); await websocket.send(initial_state_msg)
 
585
  async for message in websocket:
586
  try:
587
  data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
588
+ # Use username from payload if provided, otherwise session username
589
+ sender_username = payload.get("username", username)
590
 
591
  if msg_type == "chat_message":
592
  chat_text = payload.get('message', ''); voice = payload.get('voice', FUN_USERNAMES.get(sender_username, "en-US-AriaNeural"));
593
+ # Schedule save/TTS, but broadcast immediately
594
+ run_async(save_chat_entry, sender_username, chat_text, voice) # Fire and forget save
595
+ await broadcast_message(message, exclude_id=client_id) # Forward original msg
596
 
597
  elif msg_type == "place_object":
598
  obj_data = payload.get("object_data");
 
613
  else: print(f"WS Invalid delete_object payload: {payload}")
614
 
615
  elif msg_type == "player_position":
616
+ pos_data = payload.get("position")
617
+ rot_data = payload.get("rotation") # Optionally include rotation
618
  if pos_data:
619
  broadcast_payload = json.dumps({"type": "player_moved", "payload": {"username": sender_username, "id": client_id, "position": pos_data, "rotation": rot_data}});
620
  await broadcast_message(broadcast_payload, exclude_id=client_id)
621
 
622
+ # Add more handlers here
623
+
624
  except json.JSONDecodeError: print(f"WS Invalid JSON from {client_id}: {message[:100]}...")
625
  except Exception as e: print(f"WS Error processing msg from {client_id}: {e}")
626
  except websockets.ConnectionClosed: print(f"WS Client disconnected: {client_id} ({username})")
627
  except Exception as e: print(f"WS Unexpected handler error {client_id}: {e}")
628
  finally:
629
  await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
630
+ await unregister_client(websocket)
631
 
632
 
633
  async def run_websocket_server():
 
637
  stop_event = asyncio.Event(); st.session_state['websocket_stop_event'] = stop_event
638
  server = None
639
  try:
 
640
  server = await websockets.serve(websocket_handler, "0.0.0.0", 8765); st.session_state['server_instance'] = server
641
  print(f"WS server started: {server.sockets[0].getsockname()}. Waiting for stop signal...")
642
  await stop_event.wait() # Keep running
643
+ except OSError as e: print(f"### FAILED START WS SERVER: {e}"); st.session_state['server_running_flag'] = False;
644
+ except Exception as e: print(f"### UNEXPECTED WS SERVER ERROR: {e}"); st.session_state['server_running_flag'] = False;
645
  finally:
646
  print("WS server task finishing...");
647
  if server: server.close(); await server.wait_closed(); print("WS server closed.")
 
653
  if st.session_state.get('server_running_flag', False): return
654
  print("Creating/starting new server thread.");
655
  def run_loop():
656
+ loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop)
657
+ try: loop.run_until_complete(run_websocket_server())
658
+ finally: loop.close(); print("Server thread loop closed.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5)
660
  if not st.session_state.server_task.is_alive(): print("### Server thread failed to stay alive!")
661
 
662
 
663
  # ==============================================================================
664
+ # Streamlit UI Layout
665
  # ==============================================================================
666
 
667
  def render_sidebar():
 
670
  st.header("💾 World Versions")
671
  st.caption("Load or save named world states.")
672
 
673
+ # World Selector
674
  saved_worlds = get_saved_worlds()
675
+ # Create display text mapping filename to formatted string
676
+ world_options = {w['filename']: f"{w['name']} ({w['timestamp']})" for w in saved_worlds}
677
+ # Get the currently selected filename (basename) from session state
678
+ current_selection_basename = st.session_state.get('current_world_file', None)
679
+
680
+ # Prepare options for radio button: None first, then filenames
681
+ radio_options_list = [None] + [w['filename'] for w in saved_worlds] # Store full path initially? No, use basename.
682
  radio_options_basenames = [None] + [os.path.basename(w['filename']) for w in saved_worlds]
683
 
684
+ # Find the index of the current selection in the options list
685
+ current_radio_index = 0 # Default to "Live State"
686
  if current_selection_basename and current_selection_basename in radio_options_basenames:
687
+ try:
688
+ current_radio_index = radio_options_basenames.index(current_selection_basename)
689
+ except ValueError:
690
+ current_radio_index = 0 # Fallback if filename somehow not in list
691
 
692
+ # Display radio buttons
693
  selected_basename = st.radio(
694
+ "Load World:",
695
+ options=radio_options_basenames,
696
+ index=current_radio_index,
697
+ 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
698
  key="world_selector_radio"
699
  )
700
 
701
  # Handle selection change
702
  if selected_basename != current_selection_basename:
703
+ st.session_state.current_world_file = selected_basename # Store basename
704
  if selected_basename:
705
  with st.spinner(f"Loading {selected_basename}..."):
706
  if load_world_state_from_md(selected_basename):
707
  run_async(broadcast_world_update) # Broadcast new state
708
  st.toast("World loaded!", icon="✅")
709
+ else:
710
+ st.error("Failed to load world."); st.session_state.current_world_file = None # Reset on failure
711
+ else:
712
  print("Switched to live state.")
713
+ # Optionally clear world or just stop tracking file? Stop tracking.
714
+ # Maybe broadcast current live state if switching TO live?
715
+ # run_async(broadcast_world_update) # Broadcast current live state
716
  st.toast("Switched to Live State.")
717
  st.rerun()
718
 
719
+ # Display download links
720
  st.caption("Download:")
721
+ cols = st.columns([3, 1]) # Columns for name and download button
722
+ with cols[0]: st.write("**Name**")
723
+ with cols[1]: st.write("**Link**")
724
+ for world_info in saved_worlds:
 
725
  f_basename = os.path.basename(world_info['filename'])
726
+ f_fullpath = world_info['filename'] # Full path needed for reading file
727
  display_name = world_info.get('name', f_basename)
728
  timestamp = world_info.get('timestamp', 'N/A')
729
+ col1, col2 = st.columns([3, 1])
730
  with col1: st.write(f"<small>{display_name} ({timestamp})</small>", unsafe_allow_html=True)
731
  with col2: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
732
+
 
 
 
 
 
 
 
 
 
 
733
 
734
  st.markdown("---")
735
 
736
+ # Save New Version moved to Files Tab
737
+
738
+ # Build Tools section
739
  st.header("🏗️ Build Tools")
740
  st.caption("Select an object to place.")
741
  cols = st.columns(5)
 
746
  if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type, use_container_width=True):
747
  if st.session_state.get('selected_object', 'None') != name:
748
  st.session_state.selected_object = name
749
+ run_async(lambda: streamlit_js_eval(f"updateSelectedObjectType({json.dumps(name)});", key=f"update_tool_js_{name}")) # Fire-and-forget JS update
 
750
  st.rerun()
751
  col_idx += 1
752
  st.markdown("---")
 
756
  run_async(lambda: streamlit_js_eval("updateSelectedObjectType('None');", key="update_tool_js_none"))
757
  st.rerun()
758
 
759
+ # Voice/User section
760
  st.markdown("---")
761
  st.header("🗣️ Voice & User")
762
  current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0])
763
  username_options = list(FUN_USERNAMES.keys()); current_index = 0
764
  try: current_index = username_options.index(current_username)
765
+ except ValueError: current_index = 0
766
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
767
  if new_username != st.session_state.username:
768
  old_username = st.session_state.username
769
  change_msg = json.dumps({"type":"user_rename", "payload": {"old_username": old_username, "new_username": new_username}})
770
+ run_async(broadcast_message, change_msg)
771
  st.session_state.username = new_username; st.session_state.tts_voice = FUN_USERNAMES[new_username]; save_username(st.session_state.username)
772
  st.rerun()
773
  st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
 
783
  with tab_world:
784
  st.header("Shared 3D World")
785
  st.caption("Place objects using the sidebar tools. Changes are shared live!")
786
+ current_file = st.session_state.get('current_world_file', None)
787
+ if current_file: parsed = parse_world_filename(current_file); st.info(f"Current World: **{parsed['name']}** (`{os.path.basename(current_file)}`)")
788
+ else: st.info("Live State Active (Unsaved changes will be lost unless saved as new version)")
 
 
 
789
 
790
  # Embed HTML Component
791
  html_file_path = 'index.html'
792
  try:
793
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
794
+ try: # Get WS URL
 
795
  from streamlit.web.server.server import Server
796
  session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id)
797
  server_host = session_info.ws.stream.request.host.split(':')[0]
798
  ws_url = f"ws://{server_host}:8765"
799
+ except Exception: ws_url = "ws://localhost:8765"
800
 
801
  js_injection_script = f"""<script>
802
  window.USERNAME = {json.dumps(st.session_state.username)};
 
814
  # --- Chat Tab ---
815
  with tab_chat:
816
  st.header(f"{START_ROOM} Chat")
817
+ 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
 
 
 
 
 
818
  chat_container = st.container(height=500)
819
  with chat_container:
820
+ if chat_history: st.markdown("----\n".join(reversed(chat_history[-50:])))
821
  else: st.caption("No chat messages yet.")
822
 
823
  # Chat Input Area
 
828
  if send_button_clicked or should_autosend:
829
  message_to_send = message_value
830
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
831
+ st.session_state.last_message = message_to_send
832
  voice = FUN_USERNAMES.get(st.session_state.username, "en-US-AriaNeural")
833
  ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}})
834
+ run_async(broadcast_message, ws_message) # Send via WS
835
+ run_async(save_chat_entry, st.session_state.username, message_to_send, voice) # Save locally
 
836
  st.session_state.message_input = "" # Clear state for next run
837
  st.rerun()
838
  elif send_button_clicked: st.toast("Message empty or same as last.")
839
+
840
  st.checkbox("Autosend Chat", key="autosend") # Toggle autosend
841
 
842
  # --- PDF Tab ---
843
  with tab_pdf:
844
  st.header("📚 PDF Tools")
845
  pdf_file = st.file_uploader("Upload PDF for Audio Conversion", type="pdf", key="pdf_upload")
846
+ max_pages = st.slider('Max Pages to Process', 1, 50, 10, key="pdf_pages") # Limit pages
847
  if pdf_file:
848
+ process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice)
849
+
 
 
850
 
851
  # --- Files & Settings Tab ---
852
  with tab_files:
853
  st.header("📂 Files & Settings")
854
  st.subheader("💾 World State Management")
855
+ current_file = st.session_state.get('current_world_file', None)
856
 
857
  # Save Current Version Button
858
+ if current_file:
859
+ parsed = parse_world_filename(current_file); save_label = f"Save Changes to '{parsed['name']}'"
860
+ if st.button(save_label, key="save_current_world", help=f"Overwrite '{os.path.basename(current_file)}' with current live state."):
861
+ with st.spinner(f"Overwriting {os.path.basename(current_file)}..."):
862
+ if save_world_state_to_md(current_file): st.success("Current world version saved!")
863
+ else: st.error("Failed to save world state.")
 
864
  else:
865
+ st.info("Load a world version from the sidebar or use 'Save New Version' below to save the current live state.")
866
 
867
  # Save As New Version Section
868
  st.subheader("Save As New Version")
869
+ new_name_files = st.text_input("New World Name:", key="new_world_name_files") # Use different key from sidebar one if kept there
870
  if st.button("💾 Save Live State as New Version", key="save_new_version_files"):
871
  if new_name_files.strip():
872
+ new_filename = generate_world_save_filename(new_name_files) # Generates basename
873
  with st.spinner(f"Saving new version '{new_name_files}'..."):
874
+ if save_world_state_to_md(new_filename): # Pass basename
875
+ st.success(f"Saved as {new_filename}")
876
+ st.session_state.current_world_file = new_filename # Switch to new file automatically
877
+ st.session_state.new_world_name_files = "" # Reset input
878
  st.rerun()
879
  else: st.error("Failed to save new version.")
880
+ else: st.warning("Please enter a name for the new world version.")
881
 
882
  st.subheader("⚙️ Server Status")
883
  col_ws, col_clients = st.columns(2)
 
895
  with col_del2:
896
  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()
897
  with col_del3:
898
+ 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()
899
  with col_del4:
900
  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()
901
 
902
  # Download Archives
903
  st.subheader("📦 Download Archives")
904
+ zip_files = sorted(glob.glob("*.zip"), key=os.path.getmtime, reverse=True)
905
  if zip_files:
 
 
 
 
 
 
 
 
 
 
906
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
907
  else: st.caption("No zip archives found.")
908
 
 
913
 
914
  def initialize_world():
915
  """Loads initial world state (most recent) if not already loaded."""
 
916
  if not st.session_state.get('initial_world_state_loaded', False):
917
+ print("Performing initial world load...")
918
  saved_worlds = get_saved_worlds()
 
919
  if saved_worlds:
 
920
  latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
921
  print(f"Loading most recent world on startup: {latest_world_file_basename}")
922
+ load_world_state_from_md(latest_world_file_basename) # This updates global state and sets session state 'current_world_file'
 
 
 
923
  else:
924
  print("No saved worlds found, starting with empty state.")
925
+ with world_objects_lock: world_objects.clear() # Ensure empty state
926
+ st.session_state.current_world_file = None # Ensure no file is marked as loaded
927
+ st.session_state.initial_world_state_loaded = True
928
+ print("Initial world load complete.")
929
 
 
 
 
 
 
 
 
930
 
931
  if __name__ == "__main__":
932
+ # 1. Initialize session state first
933
  init_session_state()
934
 
935
+ # 2. Start WebSocket server thread if needed
936
+ # Use server_running_flag to prevent multiple start attempts within one session
937
+ if not st.session_state.get('server_running_flag', False):
938
+ if 'server_task' not in st.session_state or not st.session_state.server_task.is_alive():
939
+ start_websocket_server_thread()
940
+ else:
941
+ # If task exists but flag is false, maybe update flag?
942
+ if st.session_state.server_task.is_alive():
943
+ st.session_state.server_running_flag = True
944
+ print("Corrected server_running_flag based on alive thread.")
945
+
946
+
947
+ # 3. Load initial world state from disk if needed
948
  initialize_world()
949
 
950
  # 4. Render the UI (Sidebar and Main Content)
951
  render_sidebar()
952
+ render_main_content()