awacke1 commited on
Commit
c7e4e3d
Β·
verified Β·
1 Parent(s): 52cbf87

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +311 -280
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # app.py (Re-integrated WebSockets for 3D Sync)
2
  import streamlit as st
3
  import asyncio
4
  import websockets # Re-added
@@ -28,11 +28,20 @@ from streamlit_js_eval import streamlit_js_eval # Keep for UI interaction if nee
28
  from PIL import Image
29
 
30
  # ==============================================================================
31
- # Configuration & Constants
32
  # ==============================================================================
33
- st.set_page_config(page_title="πŸ—οΈ Live World Builder ⚑", page_icon="πŸ—οΈ", layout="wide", initial_sidebar_state="expanded")
 
34
  nest_asyncio.apply()
35
 
 
 
 
 
 
 
 
 
36
  # General Constants
37
  Site_Name = 'πŸ—οΈ Live World Builder ⚑'
38
  MEDIA_DIR = "."
@@ -81,42 +90,78 @@ clients_lock = threading.Lock()
81
  connected_clients = set() # Holds client_id strings (websocket.id)
82
 
83
  # ==============================================================================
84
- # Utility Functions (Keep relevant ones)
85
  # ==============================================================================
86
  def get_current_time_str(tz='UTC'):
87
- try: timezone = pytz.timezone(tz); now_aware = datetime.now(timezone)
88
- except Exception: now_aware = datetime.now(pytz.utc)
 
 
 
 
 
 
 
89
  return now_aware.strftime('%Y%m%d_%H%M%S')
90
 
91
  def clean_filename_part(text, max_len=25):
 
92
  if not isinstance(text, str): text = "invalid_name"
93
- text = re.sub(r'\s+', '_', text); text = re.sub(r'[^\w\-.]', '', text)
 
94
  return text[:max_len]
95
 
96
  def run_async(async_func, *args, **kwargs):
97
  """Runs an async function safely from a sync context using create_task or asyncio.run."""
98
- try: loop = asyncio.get_running_loop(); return loop.create_task(async_func(*args, **kwargs))
 
 
99
  except RuntimeError:
100
  try: return asyncio.run(async_func(*args, **kwargs))
101
  except Exception as e: print(f"❌ Error run_async new loop: {e}"); return None
102
  except Exception as e: print(f"❌ Error run_async schedule task: {e}"); return None
103
 
104
- def ensure_dir(dir_path): os.makedirs(dir_path, exist_ok=True)
 
 
105
 
106
  # ==============================================================================
107
- # World State Manager (Using st.cache_resource)
108
  # ==============================================================================
109
 
110
- # Function to load initial state from the most recent file
111
- # Separated from the cache resource function itself
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  def load_initial_world_from_file():
113
  """Loads the state from the most recent MD file found."""
114
- print(f"[{time.time():.1f}] Attempting to load initial world state from files...")
115
  loaded_state = defaultdict(dict)
116
- saved_worlds = get_saved_worlds() # Assumes get_saved_worlds is defined below
117
  if saved_worlds:
118
  latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
119
- print(f"Found most recent file: {latest_world_file_basename}")
120
  load_path = os.path.join(SAVED_WORLDS_DIR, latest_world_file_basename)
121
  if os.path.exists(load_path):
122
  try:
@@ -125,11 +170,13 @@ def load_initial_world_from_file():
125
  if json_match:
126
  world_data_dict = json.loads(json_match.group(1))
127
  for k, v in world_data_dict.items(): loaded_state[str(k)] = v
128
- print(f"Successfully loaded {len(loaded_state)} objects for initial state.")
129
- else: print("No JSON block found in initial file.")
130
- except Exception as e: print(f"Error parsing initial world file {latest_world_file_basename}: {e}")
131
- else: print(f"Most recent file {latest_world_file_basename} not found at path {load_path}.")
132
- else: print("No saved world files found to load initial state.")
 
 
133
  return loaded_state
134
 
135
  @st.cache_resource(ttl=3600) # Cache resource for 1 hour
@@ -138,20 +185,12 @@ def get_world_state_manager():
138
  Initializes and returns the shared world state dictionary and its lock.
139
  Loads initial state from the most recent file on first creation.
140
  """
141
- print(f"[{time.time():.1f}] --- Initializing/Retrieving Shared World State Resource ---")
142
  manager = {
143
  "lock": threading.Lock(),
144
  "state": load_initial_world_from_file() # Load initial state here
145
  }
146
- # Set initial current_world_file if state was loaded successfully
147
- if manager["state"]:
148
- saved_worlds = get_saved_worlds()
149
- if saved_worlds:
150
- latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
151
- if 'current_world_file' not in st.session_state: # Initialize only if not set
152
- st.session_state.current_world_file = latest_world_file_basename
153
- print(f"Set initial current_world_file state to: {latest_world_file_basename}")
154
-
155
  return manager
156
 
157
  def get_current_world_state_copy():
@@ -161,7 +200,7 @@ def get_current_world_state_copy():
161
  return dict(manager["state"]) # Return a copy
162
 
163
  # ==============================================================================
164
- # World State File Handling (Refactored for Cached State)
165
  # ==============================================================================
166
  def generate_world_save_filename(username="User", world_name="World"):
167
  timestamp = get_current_time_str(); clean_user = clean_filename_part(username, 15);
@@ -169,50 +208,14 @@ def generate_world_save_filename(username="User", world_name="World"):
169
  rand_hash = hashlib.md5(str(time.time()).encode()+username.encode()+world_name.encode()).hexdigest()[:4]
170
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
171
 
172
- def parse_world_filename(filename):
173
- """Extracts info from filename if possible, otherwise returns defaults."""
174
- basename = os.path.basename(filename)
175
- # Check prefix and suffix
176
- if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
177
- core_name = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
178
- parts = core_name.split('_')
179
- # Check if structure matches Name_by_User_Timestamp_Hash
180
- if len(parts) >= 5 and parts[-3] == "by":
181
- timestamp_str = parts[-2]
182
- username = parts[-4]
183
- world_name = " ".join(parts[:-4]) # Join potential name parts
184
- dt_obj = None
185
- try: # Try parsing timestamp
186
- dt_obj = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S')
187
- dt_obj = pytz.utc.localize(dt_obj) # Assume UTC
188
- except (ValueError, pytz.exceptions.AmbiguousTimeError, pytz.exceptions.NonExistentTimeError):
189
- dt_obj = None # Parsing failed
190
- # Return result if standard format parsed okay
191
- return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
192
-
193
- # Fallback for unknown format or if parsing above failed
194
- # print(f"Using fallback parsing for filename: {basename}") # Debug log
195
- dt_fallback = None # Initialize on its own line
196
- try: # Start try block on its own line
197
- # Indented block under try
198
- mtime = os.path.getmtime(filename)
199
- dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
200
- except Exception: # Except aligned with try
201
- # Indented block under except
202
- pass # Ignore errors getting mtime
203
- # Return statement aligned with the function's fallback logic scope
204
- return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
205
-
206
-
207
-
208
  def save_world_state_to_md(target_filename_base):
209
  """Saves the current cached world state to a specific MD file."""
210
- manager = get_world_state_manager() # Get resource
211
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
212
  print(f"πŸ’Ύ Acquiring lock to save world state to: {save_path}...")
213
  success = False
214
- with manager["lock"]: # Use resource's lock
215
- world_data_dict = dict(manager["state"]) # Get copy from resource state
216
  print(f"πŸ’Ύ Saving {len(world_data_dict)} objects...")
217
  parsed_info = parse_world_filename(save_path)
218
  timestamp_save = get_current_time_str()
@@ -234,7 +237,7 @@ def save_world_state_to_md(target_filename_base):
234
 
235
  def load_world_state_from_md(filename_base):
236
  """Loads world state from MD, updates cached state, returns success bool."""
237
- manager = get_world_state_manager() # Get resource
238
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
239
  print(f"πŸ“œ Loading world state from MD file: {load_path}...")
240
  if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return False
@@ -245,31 +248,19 @@ def load_world_state_from_md(filename_base):
245
  world_data_dict = json.loads(json_match.group(1))
246
 
247
  print(f"βš™οΈ Acquiring lock to update cached world state from {filename_base}...")
248
- with manager["lock"]: # Use resource's lock
249
- manager["state"].clear() # Clear the existing cached state dict
250
- for k, v in world_data_dict.items(): manager["state"][str(k)] = v # Update with loaded data
251
  loaded_count = len(manager["state"])
252
  print(f"βœ… Loaded {loaded_count} objects into cached state. Lock released.")
253
  st.session_state.current_world_file = filename_base # Track loaded file
254
- # Consider clearing the cache resource if behavior is unexpected after loading?
255
- # get_world_state_manager.clear() # <-- Use this if needed to force re-init on next access
256
  return True
257
 
258
  except json.JSONDecodeError as e: st.error(f"Invalid JSON in {filename_base}: {e}"); return False
259
  except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return False
260
 
261
- def get_saved_worlds():
262
- """Scans the saved worlds directory for world MD files and parses them."""
263
- try:
264
- ensure_dir(SAVED_WORLDS_DIR);
265
- world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
266
- parsed_worlds = [parse_world_filename(f) for f in world_files]
267
- parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
268
- return parsed_worlds
269
- except Exception as e: print(f"❌ Error scanning saved worlds: {e}"); st.error(f"Could not scan saved worlds: {e}"); return []
270
-
271
  # ==============================================================================
272
- # User State & Session Init
273
  # ==============================================================================
274
  def save_username(username):
275
  try:
@@ -293,11 +284,11 @@ def init_session_state():
293
  'download_link_cache': {}, 'username': None, 'autosend': False,
294
  'last_message': "",
295
  'selected_object': 'None',
296
- # Removed 'initial_world_state_loaded' flag, cache resource handles init
297
  'current_world_file': None, # Track loaded world filename (basename)
298
  'new_world_name': "MyDreamscape",
299
  'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
300
- # Removed 'world_to_load_data' and 'js_object_placed_data' - WS handles live updates
301
  }
302
  for k, v in defaults.items():
303
  if k not in st.session_state:
@@ -311,7 +302,7 @@ def init_session_state():
311
  if not isinstance(st.session_state.action_log, deque): st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
312
 
313
  # ==============================================================================
314
- # Action Log Helper
315
  # ==============================================================================
316
  def add_action_log(message, emoji="➑️"):
317
  """Adds a timestamped message with emoji to the session's action log."""
@@ -321,20 +312,20 @@ def add_action_log(message, emoji="➑️"):
321
  st.session_state.action_log.appendleft(f"{emoji} [{timestamp}] {message}")
322
 
323
  # ==============================================================================
324
- # Audio / TTS / Chat / File Handling Helpers (Keep implementations)
325
  # ==============================================================================
326
- # --- Text & File Helpers ---
327
- def clean_text_for_tts(text):
328
  if not isinstance(text, str): return "No text"
329
  text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
330
  text = ' '.join(text.split()); return text[:250] or "No text"
331
- def create_file(content, username, file_type="md", save_path=None):
332
  if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
333
  ensure_dir(os.path.dirname(save_path))
334
  try:
335
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
336
  except Exception as e: print(f"❌ Error creating file {save_path}: {e}"); return None
337
- def get_download_link(file_path, file_type="md"):
338
  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>"
339
  try: mtime = os.path.getmtime(file_path)
340
  except OSError: mtime = 0
@@ -349,8 +340,7 @@ def get_download_link(file_path, file_type="md"):
349
  st.session_state.download_link_cache[cache_key] = link_html
350
  except Exception as e: print(f"❌ Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
351
  return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
352
- # --- Audio / TTS ---
353
- async def async_edge_tts_generate(text, voice, username):
354
  if not text: return None
355
  cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
356
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
@@ -365,28 +355,21 @@ async def async_edge_tts_generate(text, voice, username):
365
  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
366
  else: print(f"❌ Audio file {save_path} failed generation."); return None
367
  except Exception as e: print(f"❌ Edge TTS Error: {e}"); return None
368
- def play_and_download_audio(file_path):
369
  if file_path and os.path.exists(file_path):
370
- try:
371
- st.audio(file_path)
372
- file_type = file_path.split('.')[-1]
373
- st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
374
  except Exception as e: st.error(f"❌ Audio display error for {os.path.basename(file_path)}: {e}")
375
- # --- Chat ---
376
- async def save_chat_entry(username, message, voice, is_markdown=False):
377
  if not message.strip(): return None, None
378
- timestamp_str = get_current_time_str();
379
- entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
380
  md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
381
  md_file = create_file(entry, username, "md", save_path=md_file_path)
382
  if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
383
  st.session_state.chat_history.append(entry)
384
  audio_file = None;
385
- if st.session_state.get('enable_audio', True):
386
- tts_message = message
387
- audio_file = await async_edge_tts_generate(tts_message, voice, username)
388
  return md_file, audio_file
389
- async def load_chat_history():
390
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
391
  if not st.session_state.chat_history:
392
  ensure_dir(CHAT_DIR); print("πŸ“œ Loading chat history from files...")
@@ -399,8 +382,7 @@ async def load_chat_history():
399
  st.session_state.chat_history = temp_history
400
  print(f"βœ… Loaded {loaded_count} chat entries from files.")
401
  return st.session_state.chat_history
402
- # --- File Management ---
403
- def create_zip_of_files(files_to_zip, prefix="Archive"):
404
  if not files_to_zip: st.warning("πŸ’¨ Nothing to gather into an archive."); return None
405
  timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
406
  try:
@@ -411,10 +393,10 @@ def create_zip_of_files(files_to_zip, prefix="Archive"):
411
  else: print(f"πŸ’¨ Skip zip missing file: {f}")
412
  print("βœ… Zip archive created successfully."); st.success(f"Created {zip_name}"); return zip_name
413
  except Exception as e: print(f"❌ Zip creation failed: {e}"); st.error(f"Zip creation failed: {e}"); return None
414
- def delete_files(file_patterns, exclude_files=None):
415
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
416
  current_world_base = st.session_state.get('current_world_file')
417
- if current_world_base: protected.append(current_world_base) # Protect loaded world
418
  if exclude_files: protected.extend(exclude_files)
419
  deleted_count = 0; errors = 0
420
  for pattern in file_patterns:
@@ -428,19 +410,18 @@ def delete_files(file_patterns, exclude_files=None):
428
  if os.path.isfile(f_path) and basename not in protected:
429
  try: os.remove(f_path); print(f"πŸ—‘οΈ Deleted: {f_path}"); deleted_count += 1
430
  except Exception as e: print(f"❌ Failed delete {f_path}: {e}"); errors += 1
431
- # else: print(f"🚫 Skipping protected/directory: {f_path}") # Debugging
432
  except Exception as glob_e: print(f"❌ Error matching pattern {pattern}: {glob_e}"); errors += 1
433
  msg = f"βœ… Successfully deleted {deleted_count} files." if errors == 0 and deleted_count > 0 else f"Deleted {deleted_count} files."
434
  if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
435
  elif deleted_count > 0: st.success(msg)
436
  else: st.info("πŸ’¨ No matching unprotected files found to delete.")
437
  st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
438
- # --- Image Handling ---
439
- async def save_pasted_image(image, username):
440
  if not image: return None
441
  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
442
  except Exception as e: print(f"❌ Failed image save: {e}"); return None
443
- def paste_image_component():
444
  pasted_img = None; img_type = None
445
  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)")
446
  if st.button("πŸ–ΌοΈ Process Pasted Image", key="process_paste_button"):
@@ -451,7 +432,7 @@ def paste_image_component():
451
  st.image(pasted_img, caption=f"πŸ–ΌοΈ Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str
452
  st.session_state.paste_image_base64_input = ""
453
  st.rerun()
454
- except ImportError: st.error("⚠️ Pillow library needed for image pasting.")
455
  except Exception as e: st.error(f"❌ Img decode err: {e}"); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = paste_input_value
456
  else: st.warning("⚠️ No valid image data pasted."); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = paste_input_value
457
  processed_b64 = st.session_state.get('paste_image_base64', '')
@@ -459,8 +440,7 @@ def paste_image_component():
459
  try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
460
  except Exception: return None
461
  return None
462
- # --- PDF Processing ---
463
- class AudioProcessor: # Keep as is
464
  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 {}
465
  def _save_metadata(self):
466
  try:
@@ -478,7 +458,7 @@ class AudioProcessor: # Keep as is
478
  else: return None
479
  except Exception as e: print(f"❌ TTS Create Audio Error: {e}"); return None
480
 
481
- def process_pdf_tab(pdf_file, max_pages, voice): # Corrected version
482
  st.subheader("πŸ“œ PDF Processing Results")
483
  if pdf_file is None: st.info("⬆️ Upload a PDF file and click 'Process PDF' to begin."); return
484
  audio_processor = AudioProcessor()
@@ -497,7 +477,6 @@ def process_pdf_tab(pdf_file, max_pages, voice): # Corrected version
497
  with results_lock: audios[page_num] = audio_path
498
  except Exception as page_e: print(f"❌ Err process page {page_num+1}: {page_e}")
499
 
500
- # Start threads
501
  for i in range(pages_to_process):
502
  try: # Start try block for page processing
503
  page = reader.pages[i]
@@ -515,7 +494,6 @@ def process_pdf_tab(pdf_file, max_pages, voice): # Corrected version
515
  texts[i] = f"[❌ Error extract: {extract_e}]" # Store error message
516
  print(f"Error page {i+1} extract: {extract_e}") # Log error
517
 
518
- # Wait for threads and display progress
519
  progress_bar = st.progress(0.0, text="✨ Transmuting pages to sound...")
520
  total_threads = len(page_threads); start_join_time = time.time()
521
  while any(t.is_alive() for t in page_threads):
@@ -525,7 +503,6 @@ def process_pdf_tab(pdf_file, max_pages, voice): # Corrected version
525
  time.sleep(0.5)
526
  progress_bar.progress(1.0, text="βœ… Processing complete.")
527
 
528
- # Display results
529
  st.write("🎢 Results:")
530
  for i in range(pages_to_process):
531
  with st.expander(f"Page {i+1}"):
@@ -540,75 +517,126 @@ def process_pdf_tab(pdf_file, max_pages, voice): # Corrected version
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
- # WebSocket Server Logic (Re-added for Chat/Presence)
546
  # ==============================================================================
547
 
548
  async def register_client(websocket):
 
549
  client_id = str(websocket.id);
550
- with clients_lock: connected_clients.add(client_id);
551
- if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict);
552
- st.session_state.active_connections[client_id] = websocket;
 
553
  print(f"βœ… Client registered: {client_id}. Total: {len(connected_clients)}")
554
 
555
  async def unregister_client(websocket):
 
556
  client_id = str(websocket.id);
557
  with clients_lock:
558
  connected_clients.discard(client_id);
559
  if 'active_connections' in st.session_state: st.session_state.active_connections.pop(client_id, None);
560
- print(f"❌ Client unregistered: {client_id}. Remaining: {len(connected_clients)}")
561
 
562
  async def send_safely(websocket, message, client_id):
563
  """Wrapper to send message and handle potential connection errors."""
564
  try: await websocket.send(message)
565
- except websockets.ConnectionClosed: print(f"❌ WS Send failed (Closed) client {client_id}"); raise
566
  except RuntimeError as e: print(f"❌ WS Send failed (Runtime {e}) client {client_id}"); raise
567
  except Exception as e: print(f"❌ WS Send failed (Other {e}) client {client_id}"); raise
568
 
569
  async def broadcast_message(message, exclude_id=None):
570
  """Sends a message to all connected clients except the excluded one."""
 
571
  with clients_lock:
572
  if not connected_clients: return
573
  current_client_ids = list(connected_clients)
574
- active_connections_copy = st.session_state.active_connections.copy()
 
 
 
 
575
 
576
  tasks = []
577
  for client_id in current_client_ids:
578
  if client_id == exclude_id: continue
579
- websocket = active_connections_copy.get(client_id)
580
- if websocket: tasks.append(asyncio.create_task(send_safely(websocket, message, client_id)))
 
 
 
 
 
 
581
 
582
- if tasks: await asyncio.gather(*tasks, return_exceptions=True) # Wait and ignore errors here
 
 
 
 
 
 
583
 
584
  async def websocket_handler(websocket, path):
585
- """Handles WebSocket connections and messages (primarily for Chat)."""
586
  await register_client(websocket); client_id = str(websocket.id);
587
  username = st.session_state.get('username', f"User_{client_id[:4]}")
588
 
589
- try: # Announce join
590
- print(f"✨ {username} ({client_id}) connected.")
 
 
 
 
591
  await broadcast_message(json.dumps({"type": "user_join", "payload": {"username": username, "id": client_id}}), exclude_id=client_id)
592
- except Exception as e: print(f"❌ Error during join announcement {client_id}: {e}")
593
 
594
- try: # Message loop
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) # Username sent from client
 
 
 
599
 
600
  if msg_type == "chat_message":
601
  chat_text = payload.get('message', ''); voice = payload.get('voice', FUN_USERNAMES.get(sender_username, DEFAULT_TTS_VOICE));
602
  print(f"πŸ’¬ WS Recv Chat from {sender_username}: {chat_text[:30]}...")
603
- # Schedule save/TTS locally
604
- run_async(save_chat_entry, sender_username, chat_text, voice)
605
- # Broadcast message to other clients (they will update their UI)
606
- await broadcast_message(message, exclude_id=client_id)
607
- elif msg_type == "ping": # Handle keepalive pings
608
- await websocket.send(json.dumps({"type": "pong"}))
609
- # Add other message types if needed (e.g., simple presence)
610
- else:
611
- print(f"⚠️ WS Recv unknown message type from {client_id}: {msg_type}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
 
613
  except json.JSONDecodeError: print(f"⚠️ WS Invalid JSON from {client_id}: {message[:100]}...")
614
  except Exception as e: print(f"❌ WS Error processing msg from {client_id}: {e}")
@@ -616,18 +644,19 @@ async def websocket_handler(websocket, path):
616
  except Exception as e: print(f"❌ WS Unexpected handler error {client_id}: {e}")
617
  finally:
618
  await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
619
- await unregister_client(websocket) # Cleanup
 
620
 
621
  async def run_websocket_server():
622
  """Coroutine to run the WebSocket server."""
623
- if st.session_state.get('server_running_flag', False): return # Prevent multiple starts
624
  st.session_state['server_running_flag'] = True; print("βš™οΈ Attempting start WS server 0.0.0.0:8765...")
625
  stop_event = asyncio.Event(); st.session_state['websocket_stop_event'] = stop_event
626
  server = None
627
  try:
628
  server = await websockets.serve(websocket_handler, "0.0.0.0", 8765); st.session_state['server_instance'] = server
629
  print(f"βœ… WS server started: {server.sockets[0].getsockname()}. Waiting for stop signal...")
630
- await stop_event.wait() # Keep server running
631
  except OSError as e: print(f"### ❌ FAILED START WS SERVER: {e}"); st.session_state['server_running_flag'] = False;
632
  except Exception as e: print(f"### ❌ UNEXPECTED WS SERVER ERROR: {e}"); st.session_state['server_running_flag'] = False;
633
  finally:
@@ -658,7 +687,7 @@ def start_websocket_server_thread():
658
 
659
 
660
  # ==============================================================================
661
- # Streamlit UI Layout Functions
662
  # ==============================================================================
663
 
664
  def render_sidebar():
@@ -668,26 +697,35 @@ def render_sidebar():
668
  st.header("1. πŸ’Ύ World Management")
669
  st.caption("πŸ’Ύ Save the current view or ✨ load a past creation.")
670
 
671
- # World Save Button (simplified logic)
672
  current_file = st.session_state.get('current_world_file')
673
  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))
674
  world_save_name = st.text_input("World Name:", key="world_save_name_input", value=save_name_value, help="Enter name to save.")
675
 
676
- if st.button("πŸ’Ύ Save Current View", key="sidebar_save_world"):
677
  if not world_save_name.strip(): st.warning("⚠️ Please enter a World Name.")
678
  else:
679
- # Store info needed for callback after JS returns data
680
- st.session_state.pending_save_filename = generate_world_save_filename(st.session_state.username, world_save_name)
681
- st.session_state.pending_save_op_text = f"Saving {world_save_name}..."
682
- # Trigger JS to get state and call Python function `handle_js_save_data`
683
- try:
684
- print("🐍 Requesting world state from JS for saving...")
685
- # JS function `getWorldStateForSave` now calls Python func `handle_js_save_data`
686
- streamlit_js_eval("getWorldStateForSave(true);", key="trigger_js_save")
687
- st.info("πŸ’Ύ Requesting state from view...")
688
- except Exception as e: st.error(f"❌ Failed to request save state: {e}")
689
-
690
- # World Load Section
 
 
 
 
 
 
 
 
 
691
  st.markdown("---")
692
  st.header("2. πŸ“‚ Load World")
693
  st.caption("πŸ“œ Unfurl a previously woven dreamscape.")
@@ -700,8 +738,6 @@ def render_sidebar():
700
  with cols_header[1]: st.write("**Load**")
701
  with cols_header[2]: st.write("**DL**")
702
 
703
- display_limit = 15
704
- # Use a container for scrollability if list is long
705
  list_container = st.container(height=300 if len(saved_worlds) > 7 else None)
706
  with list_container:
707
  for world_info in saved_worlds:
@@ -717,15 +753,16 @@ def render_sidebar():
717
 
718
  if btn_load:
719
  print(f"πŸ–±οΈ Load button clicked for: {f_basename}")
720
- world_dict = load_world_from_md(f_basename)
721
- if world_dict is not None:
722
- st.session_state.world_to_load_data = world_dict # Queue data for JS
723
- st.session_state.current_world_file = f_basename
724
- add_action_log(f"Loading world: {f_basename}", emoji="πŸ“‚")
725
- st.rerun()
726
- else: st.error(f"❌ Failed to parse world file: {f_basename}")
727
-
728
- # Build Tools Section
 
729
  st.markdown("---")
730
  st.header("3. πŸ› οΈ Build Tools")
731
  st.caption("Select your creative instrument.")
@@ -748,7 +785,7 @@ def render_sidebar():
748
  st.rerun()
749
 
750
 
751
- # Action Log Section
752
  st.markdown("---")
753
  st.header("4. πŸ“ Action Log")
754
  st.caption("πŸ“œ A chronicle of your recent creative acts.")
@@ -759,22 +796,22 @@ def render_sidebar():
759
  else: st.caption("🌬️ The log awaits your first action...")
760
 
761
 
762
- # Voice/User Section
763
  st.markdown("---")
764
  st.header("5. πŸ‘€ Voice & User")
765
  st.caption("🎭 Choose your persona in this realm.")
766
  current_username = st.session_state.get('username', "DefaultUser")
767
  username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
768
  current_index = 0;
769
- try:
770
  if current_username in username_options: current_index = username_options.index(current_username)
771
- except ValueError: pass # Keep index 0 if not found
772
 
773
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
774
  if new_username != st.session_state.get('username'):
775
  old_username = st.session_state.username
776
  change_msg = json.dumps({"type":"user_rename", "payload": {"old_username": old_username, "new_username": new_username}})
777
- run_async(broadcast_message, change_msg) # Broadcast name change if WS is running
778
  st.session_state.username = new_username; st.session_state.tts_voice = FUN_USERNAMES.get(new_username, DEFAULT_TTS_VOICE); save_username(st.session_state.username)
779
  add_action_log(f"Persona changed to {new_username}", emoji="🎭")
780
  st.rerun()
@@ -785,70 +822,10 @@ def render_main_content():
785
  """Renders the main content area with tabs."""
786
  st.title(f"{Site_Name} - User: {st.session_state.username}")
787
 
788
- # 1. Check if world data needs to be sent to JS (loaded via sidebar button)
789
- world_data_to_load = st.session_state.pop('world_to_load_data', None)
790
- if world_data_to_load is not None:
791
- print(f"🐍 Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
792
- try:
793
- streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
794
- st.toast("🌌 World loaded in 3D view.", icon="πŸ”„")
795
- except Exception as e: st.error(f"❌ Failed to send loaded world state to JS: {e}")
796
-
797
- # 2. Set up JS communication handlers (Object Placement & Save Data Callback)
798
- streamlit_js_eval(
799
- js_code="""
800
- // Ensure functions are defined only once per page load
801
- if (!window.streamlitPythonCallSetupDone) {
802
- console.log('🐍 Setting up JS->Python communication functions...');
803
- window.sendPlacedObjectToPython = (objectData) => {
804
- console.log('JS sending placed object:', objectData);
805
- streamlit_js_eval(python_code='handle_js_object_placed(data=' + JSON.stringify(objectData) + ')', key='js_place_event_handler');
806
- };
807
- window.sendSaveDataToPython = (jsonData) => {
808
- console.log('JS sending saved world state back to Python...');
809
- streamlit_js_eval(python_code='handle_js_save_data(data=' + JSON.stringify(jsonData) + ')', key='js_save_state_handler');
810
- };
811
- window.streamlitPythonCallSetupDone = true;
812
- }
813
- """,
814
- key="setup_js_comms" # Key for the setup code itself
815
- )
816
-
817
- # 3. Process data received from JS callbacks (if any were triggered)
818
- # Check for placement data
819
- if 'js_place_event_handler' in st.session_state:
820
- placed_data = st.session_state.pop('js_object_placed_data', None)
821
- if placed_data: print(f"🐍 Python processed placed object: {placed_data.get('obj_id')}")
822
- del st.session_state['js_place_event_handler'] # Clear trigger
823
-
824
- # Check for save data
825
- if 'js_save_state_handler' in st.session_state:
826
- saved_data_str = st.session_state.pop('js_save_state_data', None)
827
- del st.session_state['js_save_state_handler']
828
- if saved_data_str:
829
- print("🐍 Python processing saved world data received from JS...")
830
- try:
831
- world_data_dict = json.loads(saved_data_str)
832
- if isinstance(world_data_dict, dict):
833
- save_filename = st.session_state.get("pending_save_filename") # Get filename set by button
834
- save_op_text = st.session_state.get("pending_save_op_text", f"Saving {save_filename}...")
835
- is_overwrite = st.session_state.get("pending_save_is_overwrite", False)
836
- if save_filename:
837
- with st.spinner(save_op_text):
838
- if save_world_to_md(save_filename, world_data_dict):
839
- action = "Overwritten" if is_overwrite else "Saved new"; st.success(f"World {action}: {save_filename}");
840
- add_action_log(f"Saved world: {save_filename}", emoji="πŸ’Ύ")
841
- st.session_state.current_world_file = save_filename # Update current file
842
- # Clear pending save state
843
- st.session_state.pop("pending_save_filename", None); st.session_state.pop("pending_save_op_text", None); st.session_state.pop("pending_save_is_overwrite", None)
844
- st.rerun() # Refresh lists
845
- else: st.error("❌ Failed to save world state to file.")
846
- else: st.error("❌ Save triggered but filename was missing in state.")
847
- else: st.error("❌ Invalid save data format received.")
848
- except json.JSONDecodeError: st.error("❌ Failed to decode save data from JS.")
849
- except Exception as e: st.error(f"❌ Error processing save data: {e}"); st.exception(e)
850
- else: print("⚠️ Save callback triggered, but no save data found.")
851
-
852
 
853
  # Define Tabs
854
  tab_world, tab_chat, tab_pdf, tab_files = st.tabs(["πŸ—οΈ World Builder", "πŸ—£οΈ Chat", "πŸ“š PDF Tools", "πŸ“‚ Files & Settings"])
@@ -856,7 +833,7 @@ def render_main_content():
856
  # --- World Builder Tab ---
857
  with tab_world:
858
  st.header("🌌 Shared Dreamscape")
859
- st.caption("✨ Weave reality with sidebar tools. Save your creations!")
860
  current_file_basename = st.session_state.get('current_world_file', None)
861
  if current_file_basename:
862
  full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
@@ -868,20 +845,24 @@ def render_main_content():
868
  html_file_path = 'index.html'
869
  try:
870
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
871
- # Inject state needed by JS
872
- initial_world_data = {} # Default empty, loadWorldState handles initial load
873
- if world_data_to_load is None and st.session_state.get('current_world_file'):
874
- # If a file is selected, but wasn't just loaded (e.g., page refresh), load its data for initial injection
875
- loaded_dict = load_world_from_md(st.session_state.current_world_file)
876
- if loaded_dict: initial_world_data = loaded_dict
877
-
 
 
 
878
  js_injection_script = f"""<script>
879
  window.USERNAME = {json.dumps(st.session_state.username)};
 
880
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
881
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
882
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
883
- window.INITIAL_WORLD_OBJECTS = {json.dumps(initial_world_data)}; /* Used only if loadWorldState isn't called */
884
- console.log("🐍 Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjectsInject: {len(initial_world_data)} }});
885
  </script>"""
886
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
887
  components.html(html_content_with_state, height=700, scrolling=False)
@@ -907,8 +888,10 @@ def render_main_content():
907
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
908
  st.session_state.last_message = message_to_send
909
  voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
910
- # Use run_async for background save/TTS generation
911
- run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
 
 
912
  add_action_log(f"Sent chat: {message_to_send[:20]}...", emoji="πŸ’¬")
913
  # Rerun is handled implicitly by button + on_click
914
  elif send_button_clicked: st.toast("Message empty or same as last.")
@@ -930,8 +913,50 @@ def render_main_content():
930
  st.caption("βš™οΈ Manage saved scrolls and application settings.")
931
 
932
  st.subheader("πŸ’Ύ World Scroll Management")
933
- # Save buttons moved to sidebar for this iteration
934
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935
  st.subheader("πŸ—‘οΈ Archive Maintenance")
936
  st.caption("🧹 Cleanse the old to make way for the new.")
937
  st.warning("Deletion is permanent!", icon="⚠️")
@@ -945,6 +970,7 @@ def render_main_content():
945
  with col_del4:
946
  if st.button("πŸ—‘οΈ All Gen", key="del_all_gen"): delete_files([os.path.join(CHAT_DIR, "*.md"), os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3"), os.path.join(SAVED_WORLDS_DIR, "*.md"), os.path.join(MEDIA_DIR, "*.zip")]); st.session_state.chat_history = []; st.session_state.audio_cache = {}; st.session_state.current_world_file = None; add_action_log("Cleared All Generated", emoji="πŸ”₯"); st.rerun()
947
 
 
948
  st.subheader("πŸ“¦ Download Archives")
949
  st.caption("Bundle your creations for safekeeping or sharing.")
950
  col_zip1, col_zip2, col_zip3 = st.columns(3)
@@ -961,35 +987,40 @@ def render_main_content():
961
  else:
962
  st.caption("🌬️ No archives found.")
963
 
 
964
  # ==============================================================================
965
  # Main Execution Logic
966
  # ==============================================================================
967
 
968
  def initialize_app():
969
- """Handles session init and initial world load setup."""
970
  init_session_state()
971
- # Load username
972
  if not st.session_state.username:
973
  loaded_user = load_username()
974
  if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
975
  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)
976
 
977
- # Queue initial world state load IF no world is selected AND no load is pending
978
- if st.session_state.get('world_to_load_data') is None and st.session_state.get('current_world_file') is None:
979
- print("🐍 Attempting initial load of most recent world...")
980
- saved_worlds = get_saved_worlds()
981
- if saved_worlds:
982
- latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
983
- print(f"🐍 Queueing most recent world for load: {latest_world_file_basename}")
984
- world_dict = load_world_from_md(latest_world_file_basename)
985
- if world_dict is not None:
986
- st.session_state.world_to_load_data = world_dict # Queue data
987
- st.session_state.current_world_file = latest_world_file_basename # Set as current
988
- else: print("❌ Failed to load most recent world."); st.session_state.world_to_load_data = {} # Send empty state
989
- else: print("🌫️ No saved worlds found."); st.session_state.world_to_load_data = {} # Send empty state
 
 
 
 
990
 
991
 
992
  if __name__ == "__main__":
993
- initialize_app() # Initialize state, user, queue initial world load data
994
- render_sidebar() # Render sidebar UI
995
- render_main_content() # Render main UI (includes logic to send queued world data to JS)
 
1
+ # app.py (Re-integrated WebSockets for 3D Sync - Cleaned)
2
  import streamlit as st
3
  import asyncio
4
  import websockets # Re-added
 
28
  from PIL import Image
29
 
30
  # ==============================================================================
31
+ # 1. βš™οΈ Configuration & Constants
32
  # ==============================================================================
33
+
34
+ # πŸ› οΈ Patch asyncio for nesting
35
  nest_asyncio.apply()
36
 
37
+ # 🎨 Page Config
38
+ st.set_page_config(
39
+ page_title="πŸ—οΈ Live World Builder ⚑",
40
+ page_icon="πŸ—οΈ",
41
+ layout="wide",
42
+ initial_sidebar_state="expanded"
43
+ )
44
+
45
  # General Constants
46
  Site_Name = 'πŸ—οΈ Live World Builder ⚑'
47
  MEDIA_DIR = "."
 
90
  connected_clients = set() # Holds client_id strings (websocket.id)
91
 
92
  # ==============================================================================
93
+ # 2. ✨ Utility Functions
94
  # ==============================================================================
95
  def get_current_time_str(tz='UTC'):
96
+ """Gets formatted timestamp string in specified timezone (default UTC)."""
97
+ try:
98
+ timezone = pytz.timezone(tz)
99
+ now_aware = datetime.now(timezone)
100
+ except pytz.UnknownTimeZoneError:
101
+ now_aware = datetime.now(pytz.utc)
102
+ except Exception as e:
103
+ print(f"❌ Timezone error ({tz}), using UTC. Error: {e}")
104
+ now_aware = datetime.now(pytz.utc)
105
  return now_aware.strftime('%Y%m%d_%H%M%S')
106
 
107
  def clean_filename_part(text, max_len=25):
108
+ """Cleans a string part for use in a filename."""
109
  if not isinstance(text, str): text = "invalid_name"
110
+ text = re.sub(r'\s+', '_', text)
111
+ text = re.sub(r'[^\w\-.]', '', text)
112
  return text[:max_len]
113
 
114
  def run_async(async_func, *args, **kwargs):
115
  """Runs an async function safely from a sync context using create_task or asyncio.run."""
116
+ try:
117
+ loop = asyncio.get_running_loop()
118
+ return loop.create_task(async_func(*args, **kwargs))
119
  except RuntimeError:
120
  try: return asyncio.run(async_func(*args, **kwargs))
121
  except Exception as e: print(f"❌ Error run_async new loop: {e}"); return None
122
  except Exception as e: print(f"❌ Error run_async schedule task: {e}"); return None
123
 
124
+ def ensure_dir(dir_path):
125
+ """Creates directory if it doesn't exist."""
126
+ os.makedirs(dir_path, exist_ok=True)
127
 
128
  # ==============================================================================
129
+ # 3. 🌍 World State Manager (Using st.cache_resource)
130
  # ==============================================================================
131
 
132
+ def get_saved_worlds(): # Define this before it's used in load_initial_world_from_file
133
+ """Scans the saved worlds directory for world MD files and parses them."""
134
+ try:
135
+ ensure_dir(SAVED_WORLDS_DIR);
136
+ world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
137
+ parsed_worlds = [parse_world_filename(f) for f in world_files] # parse_world_filename needs to be defined below
138
+ parsed_worlds.sort(key=lambda x: x.get('dt') if x.get('dt') else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
139
+ return parsed_worlds
140
+ except Exception as e: print(f"❌ Error scanning saved worlds: {e}"); st.error(f"Could not scan saved worlds: {e}"); return []
141
+
142
+ def parse_world_filename(filename): # Define this before get_saved_worlds uses it indirectly via load_initial
143
+ """Extracts info from filename if possible, otherwise returns defaults."""
144
+ basename = os.path.basename(filename)
145
+ if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
146
+ core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]; parts = core.split('_')
147
+ if len(parts) >= 5 and parts[-3] == "by":
148
+ timestamp_str = parts[-2]; username = parts[-4]; world_name = " ".join(parts[:-4]); dt_obj = None
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...")
160
  loaded_state = defaultdict(dict)
161
+ saved_worlds = get_saved_worlds()
162
  if saved_worlds:
163
  latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
164
+ print(f"⏳ Found most recent file: {latest_world_file_basename}")
165
  load_path = os.path.join(SAVED_WORLDS_DIR, latest_world_file_basename)
166
  if os.path.exists(load_path):
167
  try:
 
170
  if json_match:
171
  world_data_dict = json.loads(json_match.group(1))
172
  for k, v in world_data_dict.items(): loaded_state[str(k)] = v
173
+ print(f"βœ… Successfully loaded {len(loaded_state)} objects for initial state.")
174
+ # Store the initially loaded file basename in session state here?
175
+ st.session_state._initial_world_file_loaded = latest_world_file_basename
176
+ else: print("⚠️ No JSON block found in initial file.")
177
+ except Exception as e: print(f"❌ Error parsing initial world file {latest_world_file_basename}: {e}")
178
+ else: print(f"⚠️ Most recent file {latest_world_file_basename} not found at path {load_path}.")
179
+ else: print("🌫️ No saved world files found to load initial state.")
180
  return loaded_state
181
 
182
  @st.cache_resource(ttl=3600) # Cache resource for 1 hour
 
185
  Initializes and returns the shared world state dictionary and its lock.
186
  Loads initial state from the most recent file on first creation.
187
  """
188
+ print(f"[{time.time():.1f}] --- ✨ Initializing/Retrieving Shared World State Resource ---")
189
  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():
 
200
  return dict(manager["state"]) # Return a copy
201
 
202
  # ==============================================================================
203
+ # 4. πŸ’Ύ World State File Handling (Save/Load - Refactored for Cached State)
204
  # ==============================================================================
205
  def generate_world_save_filename(username="User", world_name="World"):
206
  timestamp = get_current_time_str(); clean_user = clean_filename_part(username, 15);
 
208
  rand_hash = hashlib.md5(str(time.time()).encode()+username.encode()+world_name.encode()).hexdigest()[:4]
209
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
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
 
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
  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
 
 
257
  return True
258
 
259
  except json.JSONDecodeError as e: st.error(f"Invalid JSON in {filename_base}: {e}"); return False
260
  except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return False
261
 
 
 
 
 
 
 
 
 
 
 
262
  # ==============================================================================
263
+ # 5. πŸ‘€ User State & Session Init
264
  # ==============================================================================
265
  def save_username(username):
266
  try:
 
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:
 
302
  if not isinstance(st.session_state.action_log, deque): st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
303
 
304
  # ==============================================================================
305
+ # 6. πŸ“ Action Log Helper
306
  # ==============================================================================
307
  def add_action_log(message, emoji="➑️"):
308
  """Adds a timestamped message with emoji to the session's action log."""
 
312
  st.session_state.action_log.appendleft(f"{emoji} [{timestamp}] {message}")
313
 
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
  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
  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
  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
  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)
400
  if exclude_files: protected.extend(exclude_files)
401
  deleted_count = 0; errors = 0
402
  for pattern in file_patterns:
 
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"):
 
432
  st.image(pasted_img, caption=f"πŸ–ΌοΈ Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str
433
  st.session_state.paste_image_base64_input = ""
434
  st.rerun()
435
+ except ImportError: st.error("⚠️ Pillow library needed.")
436
  except Exception as e: st.error(f"❌ Img decode err: {e}"); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = paste_input_value
437
  else: st.warning("⚠️ No valid image data pasted."); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = paste_input_value
438
  processed_b64 = st.session_state.get('paste_image_base64', '')
 
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
  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()
 
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]
 
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
  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}"):
 
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;
531
  print(f"βœ… Client registered: {client_id}. Total: {len(connected_clients)}")
532
 
533
  async def unregister_client(websocket):
534
+ """Removes client from tracking structures, ensuring thread safety."""
535
  client_id = str(websocket.id);
536
  with clients_lock:
537
  connected_clients.discard(client_id);
538
  if 'active_connections' in st.session_state: st.session_state.active_connections.pop(client_id, None);
539
+ print(f"πŸ”Œ Client unregistered: {client_id}. Remaining: {len(connected_clients)}")
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)
589
+ print(f"βœ… Sent initial state ({len(initial_state_payload)} objs) to {client_id}")
590
+ # Announce join after state sent
591
  await broadcast_message(json.dumps({"type": "user_join", "payload": {"username": username, "id": client_id}}), exclude_id=client_id)
592
+ except Exception as e: print(f"❌ Error during initial phase {client_id}: {e}")
593
 
594
+ try: # Message processing loop
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
602
 
603
  if msg_type == "chat_message":
604
  chat_text = payload.get('message', ''); voice = payload.get('voice', FUN_USERNAMES.get(sender_username, DEFAULT_TTS_VOICE));
605
  print(f"πŸ’¬ WS Recv Chat from {sender_username}: {chat_text[:30]}...")
606
+ run_async(save_chat_entry, sender_username, chat_text, voice) # Save locally async
607
+ await broadcast_message(message, exclude_id=client_id) # Broadcast chat
608
+
609
+ elif msg_type == "place_object":
610
+ obj_data = payload.get("object_data");
611
+ if obj_data and 'obj_id' in obj_data and 'type' in obj_data:
612
+ print(f"βž• WS Recv Place from {sender_username}: {obj_data['type']} ({obj_data['obj_id']})")
613
+ with manager["lock"]: manager["state"][obj_data['obj_id']] = obj_data # Update cached state
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":
621
+ obj_id = payload.get("obj_id"); removed = False
622
+ if obj_id:
623
+ print(f"βž– WS Recv Delete from {sender_username}: {obj_id}")
624
+ with manager["lock"]:
625
+ if obj_id in manager["state"]: del manager["state"][obj_id]; removed = True
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":
633
+ pos_data = payload.get("position"); rot_data = payload.get("rotation")
634
+ if pos_data:
635
+ broadcast_payload = json.dumps({"type": "player_moved", "payload": {"username": sender_username, "id": client_id, "position": pos_data, "rotation": rot_data}});
636
+ await broadcast_message(broadcast_payload, exclude_id=client_id) # Broadcast movement
637
+
638
+ elif msg_type == "ping": await websocket.send(json.dumps({"type": "pong"}))
639
+ else: print(f"⚠️ WS Recv unknown type from {client_id}: {msg_type}")
640
 
641
  except json.JSONDecodeError: print(f"⚠️ WS Invalid JSON from {client_id}: {message[:100]}...")
642
  except Exception as e: print(f"❌ WS Error processing msg from {client_id}: {e}")
 
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():
651
  """Coroutine to run the WebSocket server."""
652
+ if st.session_state.get('server_running_flag', False): return
653
  st.session_state['server_running_flag'] = True; print("βš™οΈ Attempting start WS server 0.0.0.0:8765...")
654
  stop_event = asyncio.Event(); st.session_state['websocket_stop_event'] = stop_event
655
  server = None
656
  try:
657
  server = await websockets.serve(websocket_handler, "0.0.0.0", 8765); st.session_state['server_instance'] = server
658
  print(f"βœ… WS server started: {server.sockets[0].getsockname()}. Waiting for stop signal...")
659
+ await stop_event.wait()
660
  except OSError as e: print(f"### ❌ FAILED START WS SERVER: {e}"); st.session_state['server_running_flag'] = False;
661
  except Exception as e: print(f"### ❌ UNEXPECTED WS SERVER ERROR: {e}"); st.session_state['server_running_flag'] = False;
662
  finally:
 
687
 
688
 
689
  # ==============================================================================
690
+ # 9. 🎨 Streamlit UI Layout Functions
691
  # ==============================================================================
692
 
693
  def render_sidebar():
 
697
  st.header("1. πŸ’Ύ World Management")
698
  st.caption("πŸ’Ύ Save the current view or ✨ load a past creation.")
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")
731
  st.caption("πŸ“œ Unfurl a previously woven dreamscape.")
 
738
  with cols_header[1]: st.write("**Load**")
739
  with cols_header[2]: st.write("**DL**")
740
 
 
 
741
  list_container = st.container(height=300 if len(saved_worlds) > 7 else None)
742
  with list_container:
743
  for world_info in saved_worlds:
 
753
 
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 ---
766
  st.markdown("---")
767
  st.header("3. πŸ› οΈ Build Tools")
768
  st.caption("Select your creative instrument.")
 
785
  st.rerun()
786
 
787
 
788
+ # --- Action Log ---
789
  st.markdown("---")
790
  st.header("4. πŸ“ Action Log")
791
  st.caption("πŸ“œ A chronicle of your recent creative acts.")
 
796
  else: st.caption("🌬️ The log awaits your first action...")
797
 
798
 
799
+ # --- Voice/User ---
800
  st.markdown("---")
801
  st.header("5. πŸ‘€ Voice & User")
802
  st.caption("🎭 Choose your persona in this realm.")
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
 
810
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
811
  if new_username != st.session_state.get('username'):
812
  old_username = st.session_state.username
813
  change_msg = json.dumps({"type":"user_rename", "payload": {"old_username": old_username, "new_username": new_username}})
814
+ run_async(broadcast_message, change_msg) # Broadcast name change
815
  st.session_state.username = new_username; st.session_state.tts_voice = FUN_USERNAMES.get(new_username, DEFAULT_TTS_VOICE); save_username(st.session_state.username)
816
  add_action_log(f"Persona changed to {new_username}", emoji="🎭")
817
  st.rerun()
 
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"])
 
833
  # --- World Builder Tab ---
834
  with tab_world:
835
  st.header("🌌 Shared Dreamscape")
836
+ st.caption("✨ Weave reality with sidebar tools. Changes shared live! Use sidebar to save/load.")
837
  current_file_basename = st.session_state.get('current_world_file', None)
838
  if current_file_basename:
839
  full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
 
845
  html_file_path = 'index.html'
846
  try:
847
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
848
+ ws_url = "ws://localhost:8765" # Default
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)
868
  components.html(html_content_with_state, height=700, scrolling=False)
 
888
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
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="πŸ’¬")
896
  # Rerun is handled implicitly by button + on_click
897
  elif send_button_clicked: st.toast("Message empty or same as last.")
 
913
  st.caption("βš™οΈ Manage saved scrolls and application settings.")
914
 
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}'"
922
+ if os.path.exists(full_path): parsed = parse_world_filename(full_path); save_label = f"πŸ’Ύ Save Changes to '{parsed['name']}'"
923
+ if st.button(save_label, key="save_current_world_files", help=f"Overwrite '{current_file_basename}'"):
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
+
949
+ # Server Status
950
+ st.subheader("βš™οΈ Server Status")
951
+ col_ws, col_clients = st.columns(2)
952
+ with col_ws:
953
+ server_alive = st.session_state.get('server_task') and st.session_state.server_task.is_alive(); ws_status = "Running" if server_alive else "Stopped"; st.metric("WebSocket Server", ws_status)
954
+ if not server_alive and st.button("πŸ”„ Restart Server Thread", key="restart_ws"): start_websocket_server_thread(); st.rerun()
955
+ with col_clients:
956
+ with clients_lock: client_count = len(connected_clients)
957
+ st.metric("πŸ”— Connected Clients", client_count)
958
+
959
+ # File Deletion
960
  st.subheader("πŸ—‘οΈ Archive Maintenance")
961
  st.caption("🧹 Cleanse the old to make way for the new.")
962
  st.warning("Deletion is permanent!", icon="⚠️")
 
970
  with col_del4:
971
  if st.button("πŸ—‘οΈ All Gen", key="del_all_gen"): delete_files([os.path.join(CHAT_DIR, "*.md"), os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3"), os.path.join(SAVED_WORLDS_DIR, "*.md"), os.path.join(MEDIA_DIR, "*.zip")]); st.session_state.chat_history = []; st.session_state.audio_cache = {}; st.session_state.current_world_file = None; add_action_log("Cleared All Generated", emoji="πŸ”₯"); st.rerun()
972
 
973
+ # Download Archives
974
  st.subheader("πŸ“¦ Download Archives")
975
  st.caption("Bundle your creations for safekeeping or sharing.")
976
  col_zip1, col_zip2, col_zip3 = st.columns(3)
 
987
  else:
988
  st.caption("🌬️ No archives found.")
989
 
990
+
991
  # ==============================================================================
992
  # Main Execution Logic
993
  # ==============================================================================
994
 
995
  def initialize_app():
996
+ """Handles session init, server start, and ensures world state resource is accessed."""
997
  init_session_state()
998
+ # Load/Assign username
999
  if not st.session_state.username:
1000
  loaded_user = load_username()
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
 
1022
 
1023
  if __name__ == "__main__":
1024
+ initialize_app()
1025
+ render_sidebar()
1026
+ render_main_content()