awacke1 commited on
Commit
f337e77
Β·
verified Β·
1 Parent(s): 72b4204

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +129 -97
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # app.py (Re-integrated WebSockets for 3D Sync - Cleaned)
2
  import streamlit as st
3
  import asyncio
4
  import websockets # Re-added
@@ -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, deque
27
- from streamlit_js_eval import streamlit_js_eval # Keep for UI interaction if needed
28
  from PIL import Image
29
 
30
  # ==============================================================================
@@ -149,11 +149,21 @@ def parse_world_filename(filename): # Define this before get_saved_worlds uses i
149
  try: dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
150
  except Exception: dt_obj = None
151
  return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
152
- # Fallback
153
- dt_fallback = None; try: mtime = os.path.getmtime(filename); dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
154
- except Exception: pass
 
 
 
 
 
 
 
 
 
155
  return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
156
 
 
157
  def load_initial_world_from_file():
158
  """Loads the state from the most recent MD file found."""
159
  print(f"[{time.time():.1f}] ⏳ Attempting to load initial world state from files...")
@@ -190,7 +200,15 @@ def get_world_state_manager():
190
  "lock": threading.Lock(),
191
  "state": load_initial_world_from_file() # Load initial state here
192
  }
193
- # Initial current_world_file is now handled after init_session_state in main logic
 
 
 
 
 
 
 
 
194
  return manager
195
 
196
  def get_current_world_state_copy():
@@ -210,12 +228,12 @@ def generate_world_save_filename(username="User", world_name="World"):
210
 
211
  def save_world_state_to_md(target_filename_base):
212
  """Saves the current cached world state to a specific MD file."""
213
- manager = get_world_state_manager()
214
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
215
  print(f"πŸ’Ύ Acquiring lock to save world state to: {save_path}...")
216
  success = False
217
- with manager["lock"]:
218
- world_data_dict = dict(manager["state"])
219
  print(f"πŸ’Ύ Saving {len(world_data_dict)} objects...")
220
  parsed_info = parse_world_filename(save_path)
221
  timestamp_save = get_current_time_str()
@@ -237,7 +255,7 @@ def save_world_state_to_md(target_filename_base):
237
 
238
  def load_world_state_from_md(filename_base):
239
  """Loads world state from MD, updates cached state, returns success bool."""
240
- manager = get_world_state_manager()
241
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
242
  print(f"πŸ“œ Loading world state from MD file: {load_path}...")
243
  if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return False
@@ -248,9 +266,9 @@ def load_world_state_from_md(filename_base):
248
  world_data_dict = json.loads(json_match.group(1))
249
 
250
  print(f"βš™οΈ Acquiring lock to update cached world state from {filename_base}...")
251
- with manager["lock"]:
252
- manager["state"].clear()
253
- for k, v in world_data_dict.items(): manager["state"][str(k)] = v
254
  loaded_count = len(manager["state"])
255
  print(f"βœ… Loaded {loaded_count} objects into cached state. Lock released.")
256
  st.session_state.current_world_file = filename_base # Track loaded file
@@ -284,11 +302,10 @@ def init_session_state():
284
  'download_link_cache': {}, 'username': None, 'autosend': False,
285
  'last_message': "",
286
  'selected_object': 'None',
287
- # 'initial_world_state_loaded' flag removed, cache resource handles init
288
  'current_world_file': None, # Track loaded world filename (basename)
289
  'new_world_name': "MyDreamscape",
290
  'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
291
- # State related to JS interaction moved or removed if WS handles it
292
  }
293
  for k, v in defaults.items():
294
  if k not in st.session_state:
@@ -314,18 +331,17 @@ def add_action_log(message, emoji="➑️"):
314
  # ==============================================================================
315
  # 7. 🎧 Audio / TTS / Chat / File Handling Helpers
316
  # ==============================================================================
317
- # (Keep implementations from previous correct version - Placeholder for brevity)
318
- def clean_text_for_tts(text): # ... implementation ...
319
  if not isinstance(text, str): return "No text"
320
  text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
321
  text = ' '.join(text.split()); return text[:250] or "No text"
322
- def create_file(content, username, file_type="md", save_path=None): # ... implementation ...
323
  if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
324
  ensure_dir(os.path.dirname(save_path))
325
  try:
326
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
327
  except Exception as e: print(f"❌ Error creating file {save_path}: {e}"); return None
328
- def get_download_link(file_path, file_type="md"): # ... implementation ...
329
  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>"
330
  try: mtime = os.path.getmtime(file_path)
331
  except OSError: mtime = 0
@@ -340,7 +356,7 @@ def get_download_link(file_path, file_type="md"): # ... implementation ...
340
  st.session_state.download_link_cache[cache_key] = link_html
341
  except Exception as e: print(f"❌ Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
342
  return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
343
- async def async_edge_tts_generate(text, voice, username): # ... implementation ...
344
  if not text: return None
345
  cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
346
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
@@ -355,21 +371,27 @@ async def async_edge_tts_generate(text, voice, username): # ... implementation .
355
  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
356
  else: print(f"❌ Audio file {save_path} failed generation."); return None
357
  except Exception as e: print(f"❌ Edge TTS Error: {e}"); return None
358
- def play_and_download_audio(file_path): # ... implementation ...
359
  if file_path and os.path.exists(file_path):
360
- try: st.audio(file_path); file_type = file_path.split('.')[-1]; st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
 
 
 
361
  except Exception as e: st.error(f"❌ Audio display error for {os.path.basename(file_path)}: {e}")
362
- async def save_chat_entry(username, message, voice, is_markdown=False): # ... implementation ...
363
  if not message.strip(): return None, None
364
- timestamp_str = get_current_time_str(); entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
 
365
  md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
366
  md_file = create_file(entry, username, "md", save_path=md_file_path)
367
  if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
368
  st.session_state.chat_history.append(entry)
369
  audio_file = None;
370
- if st.session_state.get('enable_audio', True): tts_message = message ; audio_file = await async_edge_tts_generate(tts_message, voice, username)
 
 
371
  return md_file, audio_file
372
- async def load_chat_history(): # ... implementation ...
373
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
374
  if not st.session_state.chat_history:
375
  ensure_dir(CHAT_DIR); print("πŸ“œ Loading chat history from files...")
@@ -382,7 +404,7 @@ async def load_chat_history(): # ... implementation ...
382
  st.session_state.chat_history = temp_history
383
  print(f"βœ… Loaded {loaded_count} chat entries from files.")
384
  return st.session_state.chat_history
385
- def create_zip_of_files(files_to_zip, prefix="Archive"): # ... implementation ...
386
  if not files_to_zip: st.warning("πŸ’¨ Nothing to gather into an archive."); return None
387
  timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
388
  try:
@@ -393,7 +415,7 @@ def create_zip_of_files(files_to_zip, prefix="Archive"): # ... implementation ..
393
  else: print(f"πŸ’¨ Skip zip missing file: {f}")
394
  print("βœ… Zip archive created successfully."); st.success(f"Created {zip_name}"); return zip_name
395
  except Exception as e: print(f"❌ Zip creation failed: {e}"); st.error(f"Zip creation failed: {e}"); return None
396
- def delete_files(file_patterns, exclude_files=None): # ... implementation ...
397
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
398
  current_world_base = st.session_state.get('current_world_file')
399
  if current_world_base: protected.append(current_world_base)
@@ -410,18 +432,18 @@ def delete_files(file_patterns, exclude_files=None): # ... implementation ...
410
  if os.path.isfile(f_path) and basename not in protected:
411
  try: os.remove(f_path); print(f"πŸ—‘οΈ Deleted: {f_path}"); deleted_count += 1
412
  except Exception as e: print(f"❌ Failed delete {f_path}: {e}"); errors += 1
413
- #else: print(f"🚫 Skipping protected/directory: {f_path}") # Debugging
414
  except Exception as glob_e: print(f"❌ Error matching pattern {pattern}: {glob_e}"); errors += 1
415
  msg = f"βœ… Successfully deleted {deleted_count} files." if errors == 0 and deleted_count > 0 else f"Deleted {deleted_count} files."
416
  if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
417
  elif deleted_count > 0: st.success(msg)
418
  else: st.info("πŸ’¨ No matching unprotected files found to delete.")
419
  st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
420
- async def save_pasted_image(image, username): # ... implementation ...
421
  if not image: return None
422
  try: img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]; timestamp = format_timestamp_prefix(username); filename = f"{timestamp}_pasted_{img_hash}.png"; filepath = os.path.join(MEDIA_DIR, filename); image.save(filepath, "PNG"); print(f"πŸ–ΌοΈ Pasted image saved: {filepath}"); return filepath
423
  except Exception as e: print(f"❌ Failed image save: {e}"); return None
424
- def paste_image_component(): # ... implementation ...
425
  pasted_img = None; img_type = None
426
  paste_input_value = st.text_area("πŸ“‹ Paste Image Data Here", key="paste_input_area", height=50, value=st.session_state.get('paste_image_base64_input', ""), help="Paste image data directly (e.g., from clipboard)")
427
  if st.button("πŸ–ΌοΈ Process Pasted Image", key="process_paste_button"):
@@ -440,7 +462,8 @@ def paste_image_component(): # ... implementation ...
440
  try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
441
  except Exception: return None
442
  return None
443
- class AudioProcessor: # ... implementation ...
 
444
  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 {}
445
  def _save_metadata(self):
446
  try:
@@ -458,7 +481,7 @@ class AudioProcessor: # ... implementation ...
458
  else: return None
459
  except Exception as e: print(f"❌ TTS Create Audio Error: {e}"); return None
460
 
461
- def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
462
  st.subheader("πŸ“œ PDF Processing Results")
463
  if pdf_file is None: st.info("⬆️ Upload a PDF file and click 'Process PDF' to begin."); return
464
  audio_processor = AudioProcessor()
@@ -471,12 +494,12 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
471
 
472
  def process_page_sync(page_num, page_text):
473
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
474
- try:
475
- audio_path = asyncio.run(run_async_audio()) # Use asyncio.run in thread
476
  if audio_path:
477
  with results_lock: audios[page_num] = audio_path
478
  except Exception as page_e: print(f"❌ Err process page {page_num+1}: {page_e}")
479
 
 
480
  for i in range(pages_to_process):
481
  try: # Start try block for page processing
482
  page = reader.pages[i]
@@ -488,12 +511,12 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
488
  thread.start() # Start thread
489
  else: # Handle empty extraction
490
  texts[i] = "[πŸ“„ No text extracted or page empty]"
491
- # print(f"Page {i+1}: No text extracted.") # Verbose
492
  # Correctly indented except block
493
  except Exception as extract_e:
494
  texts[i] = f"[❌ Error extract: {extract_e}]" # Store error message
495
  print(f"Error page {i+1} extract: {extract_e}") # Log error
496
 
 
497
  progress_bar = st.progress(0.0, text="✨ Transmuting pages to sound...")
498
  total_threads = len(page_threads); start_join_time = time.time()
499
  while any(t.is_alive() for t in page_threads):
@@ -503,6 +526,7 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
503
  time.sleep(0.5)
504
  progress_bar.progress(1.0, text="βœ… Processing complete.")
505
 
 
506
  st.write("🎢 Results:")
507
  for i in range(pages_to_process):
508
  with st.expander(f"Page {i+1}"):
@@ -512,19 +536,19 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
512
  else:
513
  page_text = texts.get(i,"")
514
  if page_text.strip() and not page_text.startswith("["): st.caption("πŸ”‡ Audio generation failed or timed out.")
515
- # else: st.caption("πŸ”‡ No text to generate audio from.") # Implicit
516
 
517
  except ImportError: st.error("⚠️ PyPDF2 library needed.")
518
  except Exception as pdf_e: st.error(f"❌ Error reading PDF '{pdf_file.name}': {pdf_e}"); st.exception(pdf_e)
519
 
 
520
  # ==============================================================================
521
- # 8. πŸ•ΈοΈ WebSocket Server Logic (Re-added for Chat/Presence)
522
  # ==============================================================================
523
 
524
  async def register_client(websocket):
525
  """Adds client to tracking structures, ensuring thread safety."""
526
  client_id = str(websocket.id);
527
- with clients_lock:
528
  connected_clients.add(client_id);
529
  if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict);
530
  st.session_state.active_connections[client_id] = websocket;
@@ -540,49 +564,56 @@ async def unregister_client(websocket):
540
 
541
  async def send_safely(websocket, message, client_id):
542
  """Wrapper to send message and handle potential connection errors."""
543
- try: await websocket.send(message)
544
- except websockets.ConnectionClosed: print(f"❌ WS Send failed (Closed) client {client_id}"); raise # Raise to be caught by gather
545
- except RuntimeError as e: print(f"❌ WS Send failed (Runtime {e}) client {client_id}"); raise
546
- except Exception as e: print(f"❌ WS Send failed (Other {e}) client {client_id}"); raise
 
 
 
 
 
 
 
547
 
548
  async def broadcast_message(message, exclude_id=None):
549
  """Sends a message to all connected clients except the excluded one."""
550
  # Create local copies under lock for thread safety
551
  with clients_lock:
552
- if not connected_clients: return
 
553
  current_client_ids = list(connected_clients)
554
  # Ensure active_connections exists and make a copy
555
  if 'active_connections' in st.session_state:
556
  active_connections_copy = st.session_state.active_connections.copy()
557
  else:
558
- active_connections_copy = {} # Should not happen if init_session_state is correct
559
 
560
  tasks = []
561
  for client_id in current_client_ids:
562
- if client_id == exclude_id: continue
 
563
  websocket = active_connections_copy.get(client_id) # Use copy
564
  if websocket:
565
  tasks.append(asyncio.create_task(send_safely(websocket, message, client_id)))
566
 
567
  if tasks:
568
- results = await asyncio.gather(*tasks, return_exceptions=True)
569
- # Optional: Check results for exceptions if specific error handling per client is needed
570
-
571
 
572
  async def broadcast_world_update():
573
  """Broadcasts the current world state (from cache) to all clients."""
574
- # Uses the cached state manager
575
- world_state_copy = get_current_world_state_copy()
576
  update_msg = json.dumps({"type": "initial_state", "payload": world_state_copy})
577
  print(f"πŸ“‘ Broadcasting full world update ({len(world_state_copy)} objects)...")
578
- await broadcast_message(update_msg)
579
 
580
  async def websocket_handler(websocket, path):
581
- """Handles WebSocket connections and messages (primarily for Chat & 3D Sync)."""
582
  await register_client(websocket); client_id = str(websocket.id);
 
583
  username = st.session_state.get('username', f"User_{client_id[:4]}")
584
 
585
- try: # Send initial world state
586
  initial_state_payload = get_current_world_state_copy() # Get state using cached helper
587
  initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload});
588
  await websocket.send(initial_state_msg)
@@ -595,7 +626,7 @@ async def websocket_handler(websocket, path):
595
  async for message in websocket:
596
  try:
597
  data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
598
- sender_username = payload.get("username", username) # Get username from payload
599
 
600
  # --- Handle Different Message Types ---
601
  manager = get_world_state_manager() # Get state manager for world updates
@@ -614,7 +645,8 @@ async def websocket_handler(websocket, path):
614
  # Broadcast placement to others
615
  broadcast_payload = json.dumps({"type": "object_placed", "payload": {"object_data": obj_data, "username": sender_username}});
616
  await broadcast_message(broadcast_payload, exclude_id=client_id)
617
- run_async(lambda: add_action_log(f"Placed {obj_data['type']} ({obj_data['obj_id'][:6]}) by {sender_username}", TOOLS_MAP.get(obj_data['type'], '❓')))
 
618
  else: print(f"⚠️ WS Invalid place_object payload: {payload}")
619
 
620
  elif msg_type == "delete_object":
@@ -626,7 +658,7 @@ async def websocket_handler(websocket, path):
626
  if removed:
627
  broadcast_payload = json.dumps({"type": "object_deleted", "payload": {"obj_id": obj_id, "username": sender_username}});
628
  await broadcast_message(broadcast_payload, exclude_id=client_id)
629
- run_async(lambda: add_action_log(f"Deleted obj ({obj_id[:6]}) by {sender_username}", "πŸ—‘οΈ"))
630
  else: print(f"⚠️ WS Invalid delete_object payload: {payload}")
631
 
632
  elif msg_type == "player_position":
@@ -644,7 +676,7 @@ async def websocket_handler(websocket, path):
644
  except Exception as e: print(f"❌ WS Unexpected handler error {client_id}: {e}")
645
  finally:
646
  await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
647
- await unregister_client(websocket)
648
 
649
 
650
  async def run_websocket_server():
@@ -685,7 +717,6 @@ def start_websocket_server_thread():
685
  st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5)
686
  if not st.session_state.server_task.is_alive(): print("### ❌ Server thread failed to stay alive!")
687
 
688
-
689
  # ==============================================================================
690
  # 9. 🎨 Streamlit UI Layout Functions
691
  # ==============================================================================
@@ -699,32 +730,37 @@ def render_sidebar():
699
 
700
  # World Save Button
701
  current_file = st.session_state.get('current_world_file')
702
- save_name_value = st.session_state.get('world_save_name_input', "MyDreamscape" if not current_file else parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file)).get("name", current_file))
703
- world_save_name = st.text_input("World Name:", key="world_save_name_input", value=save_name_value, help="Enter name to save.")
 
 
 
 
 
 
704
 
705
  if st.button("πŸ’Ύ Save Current World View", key="sidebar_save_world"):
706
- if not world_save_name.strip(): st.warning("⚠️ Please enter a World Name.")
 
707
  else:
708
- # Save current state (which is managed by cache resource, updated by WS)
709
  filename_to_save = ""; is_overwrite = False
710
  if current_file:
711
- try: # Check if name matches current loaded file's parsed name
712
  parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
713
  if world_save_name == parsed_current.get('name', ''): filename_to_save = current_file; is_overwrite = True
714
- except Exception: pass # Fallback to new save if parsing fails
715
-
716
  if not filename_to_save: filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
717
 
718
  op_text = f"Overwriting {filename_to_save}..." if is_overwrite else f"Saving as {filename_to_save}..."
719
  with st.spinner(op_text):
720
- if save_world_state_to_md(filename_to_save): # Saves state from cached resource
 
721
  action = "Overwritten" if is_overwrite else "Saved new"
722
  st.success(f"World {action}: {filename_to_save}"); add_action_log(f"Saved world: {filename_to_save}", emoji="πŸ’Ύ")
723
  st.session_state.current_world_file = filename_to_save # Track saved file
724
  st.rerun()
725
  else: st.error("❌ Failed to save world state.")
726
 
727
-
728
  # --- World Load ---
729
  st.markdown("---")
730
  st.header("2. πŸ“‚ Load World")
@@ -754,12 +790,12 @@ def render_sidebar():
754
  if btn_load:
755
  print(f"πŸ–±οΈ Load button clicked for: {f_basename}")
756
  with st.spinner(f"Loading {f_basename}..."):
757
- # load_world_state_from_md now updates the cached resource directly
758
  if load_world_state_from_md(f_basename):
759
  run_async(broadcast_world_update) # Broadcast the newly loaded state
760
  add_action_log(f"Loading world: {f_basename}", emoji="πŸ“‚")
761
  st.toast("World loaded!", icon="βœ…")
762
- st.rerun() # Rerun to update UI and ensure clients get state via WS
763
  else: st.error(f"❌ Failed to load world file: {f_basename}")
764
 
765
  # --- Build Tools ---
@@ -780,7 +816,8 @@ def render_sidebar():
780
  st.session_state.selected_object = selected_tool
781
  tool_emoji = TOOLS_MAP.get(selected_tool, '❓')
782
  add_action_log(f"Tool selected: {selected_tool}", emoji=tool_emoji)
783
- try: streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
 
784
  except Exception as e: print(f"❌ JS tool update error: {e}")
785
  st.rerun()
786
 
@@ -803,7 +840,8 @@ def render_sidebar():
803
  current_username = st.session_state.get('username', "DefaultUser")
804
  username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
805
  current_index = 0;
806
- try: # Safely find index
 
807
  if current_username in username_options: current_index = username_options.index(current_username)
808
  except ValueError: pass # Keep index 0
809
 
@@ -822,10 +860,8 @@ def render_main_content():
822
  """Renders the main content area with tabs."""
823
  st.title(f"{Site_Name} - User: {st.session_state.username}")
824
 
825
- # NOTE: No longer need to check/send 'world_to_load_data' here.
826
- # The load button triggers load_world_state_from_md which updates the cache,
827
- # then triggers broadcast_world_update (via run_async), and reruns.
828
- # The WS handler sends initial state from the cache on new connections.
829
 
830
  # Define Tabs
831
  tab_world, tab_chat, tab_pdf, tab_files = st.tabs(["πŸ—οΈ World Builder", "πŸ—£οΈ Chat", "πŸ“š PDF Tools", "πŸ“‚ Files & Settings"])
@@ -849,19 +885,19 @@ def render_main_content():
849
  try: # Get WS URL (Best effort)
850
  from streamlit.web.server.server import Server
851
  session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id)
852
- host_attr = getattr(session_info.ws.stream.request, 'host', None) or getattr(getattr(session_info, 'client', None), 'request', None)
853
  if host_attr: server_host = host_attr.host.split(':')[0]; ws_url = f"ws://{server_host}:8765"
854
  else: raise AttributeError("Host attribute not found")
855
  except Exception as e: print(f"⚠️ WS URL detection failed ({e}), using localhost.")
856
 
857
- # Inject only necessary state for JS init
858
  js_injection_script = f"""<script>
859
  window.USERNAME = {json.dumps(st.session_state.username)};
860
  window.WEBSOCKET_URL = {json.dumps(ws_url)}; // Needed by JS to connect
861
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
862
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
863
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
864
- // Initial world state is sent via WebSocket 'initial_state' message now
865
  console.log("🐍 Streamlit State Injected:", {{ username: window.USERNAME, websocketUrl: window.WEBSOCKET_URL, selectedObject: window.SELECTED_OBJECT_TYPE }});
866
  </script>"""
867
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
@@ -889,7 +925,6 @@ def render_main_content():
889
  st.session_state.last_message = message_to_send
890
  voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
891
  ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}})
892
- # Use run_async for background tasks
893
  run_async(broadcast_message, ws_message) # Broadcast Chat via WS
894
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice) # Save async
895
  add_action_log(f"Sent chat: {message_to_send[:20]}...", emoji="πŸ’¬")
@@ -915,7 +950,7 @@ def render_main_content():
915
  st.subheader("πŸ’Ύ World Scroll Management")
916
  current_file_basename = st.session_state.get('current_world_file', None)
917
 
918
- # Save Current Version Button
919
  if current_file_basename:
920
  full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
921
  save_label = f"Save Changes to '{current_file_basename}'"
@@ -924,25 +959,24 @@ def render_main_content():
924
  if not os.path.exists(full_path): st.error(f"❌ Cannot save, file missing.")
925
  else:
926
  with st.spinner(f"Saving changes to {current_file_basename}..."):
927
- # Save the current state from the cached resource
928
  if save_world_state_to_md(current_file_basename):
929
  st.success("βœ… Current world saved!"); add_action_log(f"Saved world: {current_file_basename}", emoji="πŸ’Ύ")
930
  else: st.error("❌ Failed to save world state.")
931
  else: st.info("➑️ Load a world from the sidebar to enable 'Save Changes'.")
932
 
933
- # Save As New Version Section
934
  st.subheader("✨ Save As New Scroll")
935
  new_name_files = st.text_input("New Scroll Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyDreamscape'))
936
  if st.button("πŸ’Ύ Save Current View as New Scroll", key="save_new_version_files"):
937
  if new_name_files.strip():
938
  with st.spinner(f"Saving new version '{new_name_files}'..."):
939
  new_filename_base = generate_world_save_filename(st.session_state.username, new_name_files)
940
- # Save the current state from the cached resource to a NEW file
941
  if save_world_state_to_md(new_filename_base):
942
  st.success(f"βœ… Saved as {new_filename_base}")
943
  st.session_state.current_world_file = new_filename_base; st.session_state.new_world_name = "MyDreamscape";
944
  add_action_log(f"Saved new world: {new_filename_base}", emoji="✨")
945
- st.rerun()
946
  else: st.error("❌ Failed to save new version.")
947
  else: st.warning("⚠️ Please enter a name.")
948
 
@@ -987,9 +1021,8 @@ def render_main_content():
987
  else:
988
  st.caption("🌬️ No archives found.")
989
 
990
-
991
  # ==============================================================================
992
- # Main Execution Logic
993
  # ==============================================================================
994
 
995
  def initialize_app():
@@ -1001,21 +1034,20 @@ def initialize_app():
1001
  if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
1002
  else: st.session_state.username = random.choice(list(FUN_USERNAMES.keys())) if FUN_USERNAMES else "User"; st.session_state.tts_voice = FUN_USERNAMES.get(st.session_state.username, DEFAULT_TTS_VOICE); save_username(st.session_state.username)
1003
 
1004
- # Ensure WebSocket server thread is running
1005
  server_thread = st.session_state.get('server_task'); server_alive = server_thread is not None and server_thread.is_alive()
1006
  if not st.session_state.get('server_running_flag', False) and not server_alive: start_websocket_server_thread()
1007
  elif server_alive and not st.session_state.get('server_running_flag', False): st.session_state.server_running_flag = True
1008
 
1009
  # Trigger the cached resource initialization/retrieval
 
1010
  try:
1011
- manager = get_world_state_manager()
1012
- # Set initial current_world_file if needed (based on what cache loaded)
1013
- if st.session_state.get('current_world_file') is None:
1014
- if manager["state"]: # If the cache loaded state from a file
1015
- saved_worlds = get_saved_worlds()
1016
- if saved_worlds:
1017
- st.session_state.current_world_file = os.path.basename(saved_worlds[0]['filename'])
1018
- print(f"🐍 Set initial session 'current_world_file' to: {st.session_state.current_world_file}")
1019
  except Exception as e:
1020
  st.error(f"❌ Fatal error initializing world state manager: {e}"); st.exception(e); st.stop()
1021
 
 
1
+ # app.py (Full Code - Corrected Multi-statement/Indentation Errors)
2
  import streamlit as st
3
  import asyncio
4
  import websockets # Re-added
 
22
  import json
23
  import zipfile
24
  from dotenv import load_dotenv
25
+ # from streamlit_marquee import streamlit_marquee # Import if needed
26
  from collections import defaultdict, Counter, deque
27
+ from streamlit_js_eval import streamlit_js_eval # Correct import
28
  from PIL import Image
29
 
30
  # ==============================================================================
 
149
  try: dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
150
  except Exception: dt_obj = None
151
  return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
152
+
153
+ # Fallback for unknown format or failed parse
154
+ # print(f"Using fallback parsing for filename: {basename}") # Debug log
155
+ dt_fallback = None # Initialize on its own line
156
+ try: # Start try block on its own line
157
+ # Indented block under try
158
+ mtime = os.path.getmtime(filename)
159
+ dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
160
+ except Exception: # Statement aligned with try
161
+ # Indented block under except
162
+ pass # Statement on its own line
163
+ # Return statement aligned with the start of the fallback logic block
164
  return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
165
 
166
+
167
  def load_initial_world_from_file():
168
  """Loads the state from the most recent MD file found."""
169
  print(f"[{time.time():.1f}] ⏳ Attempting to load initial world state from files...")
 
200
  "lock": threading.Lock(),
201
  "state": load_initial_world_from_file() # Load initial state here
202
  }
203
+ # Set initial current_world_file if state was loaded successfully
204
+ if manager["state"]:
205
+ saved_worlds = get_saved_worlds()
206
+ if saved_worlds:
207
+ latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
208
+ if 'current_world_file' not in st.session_state: # Initialize only if not set
209
+ st.session_state.current_world_file = latest_world_file_basename
210
+ print(f"🐍 Set initial current_world_file state to: {latest_world_file_basename}")
211
+
212
  return manager
213
 
214
  def get_current_world_state_copy():
 
228
 
229
  def save_world_state_to_md(target_filename_base):
230
  """Saves the current cached world state to a specific MD file."""
231
+ manager = get_world_state_manager() # Get resource
232
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
233
  print(f"πŸ’Ύ Acquiring lock to save world state to: {save_path}...")
234
  success = False
235
+ with manager["lock"]: # Use resource's lock
236
+ world_data_dict = dict(manager["state"]) # Get copy from resource state
237
  print(f"πŸ’Ύ Saving {len(world_data_dict)} objects...")
238
  parsed_info = parse_world_filename(save_path)
239
  timestamp_save = get_current_time_str()
 
255
 
256
  def load_world_state_from_md(filename_base):
257
  """Loads world state from MD, updates cached state, returns success bool."""
258
+ manager = get_world_state_manager() # Get resource
259
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
260
  print(f"πŸ“œ Loading world state from MD file: {load_path}...")
261
  if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return False
 
266
  world_data_dict = json.loads(json_match.group(1))
267
 
268
  print(f"βš™οΈ Acquiring lock to update cached world state from {filename_base}...")
269
+ with manager["lock"]: # Use resource's lock
270
+ manager["state"].clear() # Clear the existing cached state dict
271
+ for k, v in world_data_dict.items(): manager["state"][str(k)] = v # Update with loaded data
272
  loaded_count = len(manager["state"])
273
  print(f"βœ… Loaded {loaded_count} objects into cached state. Lock released.")
274
  st.session_state.current_world_file = filename_base # Track loaded file
 
302
  'download_link_cache': {}, 'username': None, 'autosend': False,
303
  'last_message': "",
304
  'selected_object': 'None',
 
305
  'current_world_file': None, # Track loaded world filename (basename)
306
  'new_world_name': "MyDreamscape",
307
  'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
308
+ # Removed temporary state holders 'world_to_load_data', 'js_object_placed_data'
309
  }
310
  for k, v in defaults.items():
311
  if k not in st.session_state:
 
331
  # ==============================================================================
332
  # 7. 🎧 Audio / TTS / Chat / File Handling Helpers
333
  # ==============================================================================
334
+ def clean_text_for_tts(text):
 
335
  if not isinstance(text, str): return "No text"
336
  text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
337
  text = ' '.join(text.split()); return text[:250] or "No text"
338
+ def create_file(content, username, file_type="md", save_path=None):
339
  if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
340
  ensure_dir(os.path.dirname(save_path))
341
  try:
342
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
343
  except Exception as e: print(f"❌ Error creating file {save_path}: {e}"); return None
344
+ def get_download_link(file_path, file_type="md"):
345
  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>"
346
  try: mtime = os.path.getmtime(file_path)
347
  except OSError: mtime = 0
 
356
  st.session_state.download_link_cache[cache_key] = link_html
357
  except Exception as e: print(f"❌ Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
358
  return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
359
+ async def async_edge_tts_generate(text, voice, username):
360
  if not text: return None
361
  cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
362
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
 
371
  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
372
  else: print(f"❌ Audio file {save_path} failed generation."); return None
373
  except Exception as e: print(f"❌ Edge TTS Error: {e}"); return None
374
+ def play_and_download_audio(file_path):
375
  if file_path and os.path.exists(file_path):
376
+ try:
377
+ st.audio(file_path)
378
+ file_type = file_path.split('.')[-1]
379
+ st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
380
  except Exception as e: st.error(f"❌ Audio display error for {os.path.basename(file_path)}: {e}")
381
+ async def save_chat_entry(username, message, voice, is_markdown=False):
382
  if not message.strip(): return None, None
383
+ timestamp_str = get_current_time_str();
384
+ entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
385
  md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
386
  md_file = create_file(entry, username, "md", save_path=md_file_path)
387
  if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
388
  st.session_state.chat_history.append(entry)
389
  audio_file = None;
390
+ if st.session_state.get('enable_audio', True):
391
+ tts_message = message
392
+ audio_file = await async_edge_tts_generate(tts_message, voice, username)
393
  return md_file, audio_file
394
+ async def load_chat_history():
395
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
396
  if not st.session_state.chat_history:
397
  ensure_dir(CHAT_DIR); print("πŸ“œ Loading chat history from files...")
 
404
  st.session_state.chat_history = temp_history
405
  print(f"βœ… Loaded {loaded_count} chat entries from files.")
406
  return st.session_state.chat_history
407
+ def create_zip_of_files(files_to_zip, prefix="Archive"):
408
  if not files_to_zip: st.warning("πŸ’¨ Nothing to gather into an archive."); return None
409
  timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
410
  try:
 
415
  else: print(f"πŸ’¨ Skip zip missing file: {f}")
416
  print("βœ… Zip archive created successfully."); st.success(f"Created {zip_name}"); return zip_name
417
  except Exception as e: print(f"❌ Zip creation failed: {e}"); st.error(f"Zip creation failed: {e}"); return None
418
+ def delete_files(file_patterns, exclude_files=None):
419
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
420
  current_world_base = st.session_state.get('current_world_file')
421
  if current_world_base: protected.append(current_world_base)
 
432
  if os.path.isfile(f_path) and basename not in protected:
433
  try: os.remove(f_path); print(f"πŸ—‘οΈ Deleted: {f_path}"); deleted_count += 1
434
  except Exception as e: print(f"❌ Failed delete {f_path}: {e}"); errors += 1
435
+ #else: print(f"🚫 Skipping protected/directory: {f_path}")
436
  except Exception as glob_e: print(f"❌ Error matching pattern {pattern}: {glob_e}"); errors += 1
437
  msg = f"βœ… Successfully deleted {deleted_count} files." if errors == 0 and deleted_count > 0 else f"Deleted {deleted_count} files."
438
  if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
439
  elif deleted_count > 0: st.success(msg)
440
  else: st.info("πŸ’¨ No matching unprotected files found to delete.")
441
  st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
442
+ async def save_pasted_image(image, username):
443
  if not image: return None
444
  try: img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]; timestamp = format_timestamp_prefix(username); filename = f"{timestamp}_pasted_{img_hash}.png"; filepath = os.path.join(MEDIA_DIR, filename); image.save(filepath, "PNG"); print(f"πŸ–ΌοΈ Pasted image saved: {filepath}"); return filepath
445
  except Exception as e: print(f"❌ Failed image save: {e}"); return None
446
+ def paste_image_component():
447
  pasted_img = None; img_type = None
448
  paste_input_value = st.text_area("πŸ“‹ Paste Image Data Here", key="paste_input_area", height=50, value=st.session_state.get('paste_image_base64_input', ""), help="Paste image data directly (e.g., from clipboard)")
449
  if st.button("πŸ–ΌοΈ Process Pasted Image", key="process_paste_button"):
 
462
  try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
463
  except Exception: return None
464
  return None
465
+ # --- PDF Processing ---
466
+ class AudioProcessor:
467
  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 {}
468
  def _save_metadata(self):
469
  try:
 
481
  else: return None
482
  except Exception as e: print(f"❌ TTS Create Audio Error: {e}"); return None
483
 
484
+ def process_pdf_tab(pdf_file, max_pages, voice):
485
  st.subheader("πŸ“œ PDF Processing Results")
486
  if pdf_file is None: st.info("⬆️ Upload a PDF file and click 'Process PDF' to begin."); return
487
  audio_processor = AudioProcessor()
 
494
 
495
  def process_page_sync(page_num, page_text):
496
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
497
+ try: audio_path = asyncio.run(run_async_audio()) # Use asyncio.run in thread
 
498
  if audio_path:
499
  with results_lock: audios[page_num] = audio_path
500
  except Exception as page_e: print(f"❌ Err process page {page_num+1}: {page_e}")
501
 
502
+ # Start threads
503
  for i in range(pages_to_process):
504
  try: # Start try block for page processing
505
  page = reader.pages[i]
 
511
  thread.start() # Start thread
512
  else: # Handle empty extraction
513
  texts[i] = "[πŸ“„ No text extracted or page empty]"
 
514
  # Correctly indented except block
515
  except Exception as extract_e:
516
  texts[i] = f"[❌ Error extract: {extract_e}]" # Store error message
517
  print(f"Error page {i+1} extract: {extract_e}") # Log error
518
 
519
+ # Wait for threads and display progress
520
  progress_bar = st.progress(0.0, text="✨ Transmuting pages to sound...")
521
  total_threads = len(page_threads); start_join_time = time.time()
522
  while any(t.is_alive() for t in page_threads):
 
526
  time.sleep(0.5)
527
  progress_bar.progress(1.0, text="βœ… Processing complete.")
528
 
529
+ # Display results
530
  st.write("🎢 Results:")
531
  for i in range(pages_to_process):
532
  with st.expander(f"Page {i+1}"):
 
536
  else:
537
  page_text = texts.get(i,"")
538
  if page_text.strip() and not page_text.startswith("["): st.caption("πŸ”‡ Audio generation failed or timed out.")
 
539
 
540
  except ImportError: st.error("⚠️ PyPDF2 library needed.")
541
  except Exception as pdf_e: st.error(f"❌ Error reading PDF '{pdf_file.name}': {pdf_e}"); st.exception(pdf_e)
542
 
543
+
544
  # ==============================================================================
545
+ # 8. πŸ•ΈοΈ WebSocket Server Logic
546
  # ==============================================================================
547
 
548
  async def register_client(websocket):
549
  """Adds client to tracking structures, ensuring thread safety."""
550
  client_id = str(websocket.id);
551
+ with clients_lock: # Use the dedicated lock for client structures
552
  connected_clients.add(client_id);
553
  if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict);
554
  st.session_state.active_connections[client_id] = websocket;
 
564
 
565
  async def send_safely(websocket, message, client_id):
566
  """Wrapper to send message and handle potential connection errors."""
567
+ try:
568
+ await websocket.send(message)
569
+ except websockets.ConnectionClosed:
570
+ print(f"❌ WS Send failed (Closed) client {client_id}")
571
+ raise # Re-raise to be caught by gather
572
+ except RuntimeError as e:
573
+ print(f"❌ WS Send failed (Runtime {e}) client {client_id}")
574
+ raise
575
+ except Exception as e:
576
+ print(f"❌ WS Send failed (Other {e}) client {client_id}")
577
+ raise
578
 
579
  async def broadcast_message(message, exclude_id=None):
580
  """Sends a message to all connected clients except the excluded one."""
581
  # Create local copies under lock for thread safety
582
  with clients_lock:
583
+ if not connected_clients:
584
+ return
585
  current_client_ids = list(connected_clients)
586
  # Ensure active_connections exists and make a copy
587
  if 'active_connections' in st.session_state:
588
  active_connections_copy = st.session_state.active_connections.copy()
589
  else:
590
+ active_connections_copy = {} # Should not happen if init runs first
591
 
592
  tasks = []
593
  for client_id in current_client_ids:
594
+ if client_id == exclude_id:
595
+ continue
596
  websocket = active_connections_copy.get(client_id) # Use copy
597
  if websocket:
598
  tasks.append(asyncio.create_task(send_safely(websocket, message, client_id)))
599
 
600
  if tasks:
601
+ await asyncio.gather(*tasks, return_exceptions=True) # Wait and ignore errors here
 
 
602
 
603
  async def broadcast_world_update():
604
  """Broadcasts the current world state (from cache) to all clients."""
605
+ world_state_copy = get_current_world_state_copy() # Safely get a copy
 
606
  update_msg = json.dumps({"type": "initial_state", "payload": world_state_copy})
607
  print(f"πŸ“‘ Broadcasting full world update ({len(world_state_copy)} objects)...")
608
+ await broadcast_message(update_msg) # Send to all connected clients
609
 
610
  async def websocket_handler(websocket, path):
611
+ """Handles WebSocket connections and messages using cached world state."""
612
  await register_client(websocket); client_id = str(websocket.id);
613
+ # Use username from main session state - might require adjustments if session state isn't thread-safe across contexts
614
  username = st.session_state.get('username', f"User_{client_id[:4]}")
615
 
616
+ try: # Send initial state
617
  initial_state_payload = get_current_world_state_copy() # Get state using cached helper
618
  initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload});
619
  await websocket.send(initial_state_msg)
 
626
  async for message in websocket:
627
  try:
628
  data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
629
+ sender_username = payload.get("username", username) # Username sent from client
630
 
631
  # --- Handle Different Message Types ---
632
  manager = get_world_state_manager() # Get state manager for world updates
 
645
  # Broadcast placement to others
646
  broadcast_payload = json.dumps({"type": "object_placed", "payload": {"object_data": obj_data, "username": sender_username}});
647
  await broadcast_message(broadcast_payload, exclude_id=client_id)
648
+ # Use run_async for session state update if needed from thread
649
+ # run_async(lambda: add_action_log(f"Placed {obj_data['type']} ({obj_data['obj_id'][:6]}) by {sender_username}", TOOLS_MAP.get(obj_data['type'], '❓')))
650
  else: print(f"⚠️ WS Invalid place_object payload: {payload}")
651
 
652
  elif msg_type == "delete_object":
 
658
  if removed:
659
  broadcast_payload = json.dumps({"type": "object_deleted", "payload": {"obj_id": obj_id, "username": sender_username}});
660
  await broadcast_message(broadcast_payload, exclude_id=client_id)
661
+ # run_async(lambda: add_action_log(f"Deleted obj ({obj_id[:6]}) by {sender_username}", "πŸ—‘οΈ"))
662
  else: print(f"⚠️ WS Invalid delete_object payload: {payload}")
663
 
664
  elif msg_type == "player_position":
 
676
  except Exception as e: print(f"❌ WS Unexpected handler error {client_id}: {e}")
677
  finally:
678
  await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
679
+ await unregister_client(websocket) # Cleanup
680
 
681
 
682
  async def run_websocket_server():
 
717
  st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5)
718
  if not st.session_state.server_task.is_alive(): print("### ❌ Server thread failed to stay alive!")
719
 
 
720
  # ==============================================================================
721
  # 9. 🎨 Streamlit UI Layout Functions
722
  # ==============================================================================
 
730
 
731
  # World Save Button
732
  current_file = st.session_state.get('current_world_file')
733
+ current_world_name = "Live State"
734
+ default_save_name = st.session_state.get('new_world_name', 'MyDreamscape')
735
+ if current_file:
736
+ try: parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file)); current_world_name = parsed.get("name", current_file)
737
+ except Exception: pass
738
+ default_save_name = current_world_name
739
+
740
+ world_save_name = st.text_input("World Name:", key="world_save_name_input", value=default_save_name, help="Enter name to save.")
741
 
742
  if st.button("πŸ’Ύ Save Current World View", key="sidebar_save_world"):
743
+ if not world_save_name.strip():
744
+ st.warning("⚠️ Please enter a World Name.")
745
  else:
 
746
  filename_to_save = ""; is_overwrite = False
747
  if current_file:
748
+ try:
749
  parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
750
  if world_save_name == parsed_current.get('name', ''): filename_to_save = current_file; is_overwrite = True
751
+ except Exception: pass
 
752
  if not filename_to_save: filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
753
 
754
  op_text = f"Overwriting {filename_to_save}..." if is_overwrite else f"Saving as {filename_to_save}..."
755
  with st.spinner(op_text):
756
+ # Save uses the current state from the cached resource
757
+ if save_world_state_to_md(filename_to_save):
758
  action = "Overwritten" if is_overwrite else "Saved new"
759
  st.success(f"World {action}: {filename_to_save}"); add_action_log(f"Saved world: {filename_to_save}", emoji="πŸ’Ύ")
760
  st.session_state.current_world_file = filename_to_save # Track saved file
761
  st.rerun()
762
  else: st.error("❌ Failed to save world state.")
763
 
 
764
  # --- World Load ---
765
  st.markdown("---")
766
  st.header("2. πŸ“‚ Load World")
 
790
  if btn_load:
791
  print(f"πŸ–±οΈ Load button clicked for: {f_basename}")
792
  with st.spinner(f"Loading {f_basename}..."):
793
+ # load_world_state_from_md updates the cached resource
794
  if load_world_state_from_md(f_basename):
795
  run_async(broadcast_world_update) # Broadcast the newly loaded state
796
  add_action_log(f"Loading world: {f_basename}", emoji="πŸ“‚")
797
  st.toast("World loaded!", icon="βœ…")
798
+ st.rerun() # Rerun to update UI
799
  else: st.error(f"❌ Failed to load world file: {f_basename}")
800
 
801
  # --- Build Tools ---
 
816
  st.session_state.selected_object = selected_tool
817
  tool_emoji = TOOLS_MAP.get(selected_tool, '❓')
818
  add_action_log(f"Tool selected: {selected_tool}", emoji=tool_emoji)
819
+ try: # Use streamlit_js_eval, not sync
820
+ streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
821
  except Exception as e: print(f"❌ JS tool update error: {e}")
822
  st.rerun()
823
 
 
840
  current_username = st.session_state.get('username', "DefaultUser")
841
  username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
842
  current_index = 0;
843
+ # Corrected try-except for finding index
844
+ try:
845
  if current_username in username_options: current_index = username_options.index(current_username)
846
  except ValueError: pass # Keep index 0
847
 
 
860
  """Renders the main content area with tabs."""
861
  st.title(f"{Site_Name} - User: {st.session_state.username}")
862
 
863
+ # NOTE: World loading is handled by button click -> load_world_state_from_md -> broadcast -> rerun
864
+ # Initial world state is sent by WS handler on connect
 
 
865
 
866
  # Define Tabs
867
  tab_world, tab_chat, tab_pdf, tab_files = st.tabs(["πŸ—οΈ World Builder", "πŸ—£οΈ Chat", "πŸ“š PDF Tools", "πŸ“‚ Files & Settings"])
 
885
  try: # Get WS URL (Best effort)
886
  from streamlit.web.server.server import Server
887
  session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id)
888
+ host_attr = getattr(session_info.ws.stream.request, 'host', getattr(getattr(session_info, 'client', None), 'request', None))
889
  if host_attr: server_host = host_attr.host.split(':')[0]; ws_url = f"ws://{server_host}:8765"
890
  else: raise AttributeError("Host attribute not found")
891
  except Exception as e: print(f"⚠️ WS URL detection failed ({e}), using localhost.")
892
 
893
+ # Initial world state sent via WS, only need constants/UI state here
894
  js_injection_script = f"""<script>
895
  window.USERNAME = {json.dumps(st.session_state.username)};
896
  window.WEBSOCKET_URL = {json.dumps(ws_url)}; // Needed by JS to connect
897
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
898
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
899
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
900
+ // window.INITIAL_WORLD_OBJECTS not needed here, comes via WebSocket
901
  console.log("🐍 Streamlit State Injected:", {{ username: window.USERNAME, websocketUrl: window.WEBSOCKET_URL, selectedObject: window.SELECTED_OBJECT_TYPE }});
902
  </script>"""
903
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
 
925
  st.session_state.last_message = message_to_send
926
  voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
927
  ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}})
 
928
  run_async(broadcast_message, ws_message) # Broadcast Chat via WS
929
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice) # Save async
930
  add_action_log(f"Sent chat: {message_to_send[:20]}...", emoji="πŸ’¬")
 
950
  st.subheader("πŸ’Ύ World Scroll Management")
951
  current_file_basename = st.session_state.get('current_world_file', None)
952
 
953
+ # Button to save changes to the currently loaded file
954
  if current_file_basename:
955
  full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
956
  save_label = f"Save Changes to '{current_file_basename}'"
 
959
  if not os.path.exists(full_path): st.error(f"❌ Cannot save, file missing.")
960
  else:
961
  with st.spinner(f"Saving changes to {current_file_basename}..."):
962
+ # Save the state currently held in the cached resource
963
  if save_world_state_to_md(current_file_basename):
964
  st.success("βœ… Current world saved!"); add_action_log(f"Saved world: {current_file_basename}", emoji="πŸ’Ύ")
965
  else: st.error("❌ Failed to save world state.")
966
  else: st.info("➑️ Load a world from the sidebar to enable 'Save Changes'.")
967
 
 
968
  st.subheader("✨ Save As New Scroll")
969
  new_name_files = st.text_input("New Scroll Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyDreamscape'))
970
  if st.button("πŸ’Ύ Save Current View as New Scroll", key="save_new_version_files"):
971
  if new_name_files.strip():
972
  with st.spinner(f"Saving new version '{new_name_files}'..."):
973
  new_filename_base = generate_world_save_filename(st.session_state.username, new_name_files)
974
+ # Save the state currently held in the cached resource to a NEW file
975
  if save_world_state_to_md(new_filename_base):
976
  st.success(f"βœ… Saved as {new_filename_base}")
977
  st.session_state.current_world_file = new_filename_base; st.session_state.new_world_name = "MyDreamscape";
978
  add_action_log(f"Saved new world: {new_filename_base}", emoji="✨")
979
+ st.rerun() # Rerun to update sidebar list
980
  else: st.error("❌ Failed to save new version.")
981
  else: st.warning("⚠️ Please enter a name.")
982
 
 
1021
  else:
1022
  st.caption("🌬️ No archives found.")
1023
 
 
1024
  # ==============================================================================
1025
+ # 10. πŸš€ Main Execution Logic
1026
  # ==============================================================================
1027
 
1028
  def initialize_app():
 
1034
  if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
1035
  else: st.session_state.username = random.choice(list(FUN_USERNAMES.keys())) if FUN_USERNAMES else "User"; st.session_state.tts_voice = FUN_USERNAMES.get(st.session_state.username, DEFAULT_TTS_VOICE); save_username(st.session_state.username)
1036
 
1037
+ # Start WebSocket server thread if needed
1038
  server_thread = st.session_state.get('server_task'); server_alive = server_thread is not None and server_thread.is_alive()
1039
  if not st.session_state.get('server_running_flag', False) and not server_alive: start_websocket_server_thread()
1040
  elif server_alive and not st.session_state.get('server_running_flag', False): st.session_state.server_running_flag = True
1041
 
1042
  # Trigger the cached resource initialization/retrieval
1043
+ # This ensures the initial state is loaded from file if the resource is created now
1044
  try:
1045
+ get_world_state_manager()
1046
+ # Set initial current_world_file state if needed (based on what cache loaded)
1047
+ if st.session_state.get('current_world_file') is None and '_initial_world_file_loaded' in st.session_state:
1048
+ st.session_state.current_world_file = st.session_state.pop('_initial_world_file_loaded') # Use and remove temp flag
1049
+ print(f"🐍 Set initial session 'current_world_file' to: {st.session_state.current_world_file}")
1050
+
 
 
1051
  except Exception as e:
1052
  st.error(f"❌ Fatal error initializing world state manager: {e}"); st.exception(e); st.stop()
1053