awacke1 commited on
Commit
d21bd62
·
verified ·
1 Parent(s): bc33036

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +238 -216
app.py CHANGED
@@ -1,7 +1,6 @@
1
- # app.py (Full Code - Including process_pdf_tab fix)
2
  import streamlit as st
3
  import asyncio
4
- # import websockets # Removed - Not using WS for state sync
5
  import uuid
6
  from datetime import datetime
7
  import os
@@ -12,21 +11,20 @@ import glob
12
  import base64
13
  import io
14
  import streamlit.components.v1 as components
15
- import edge_tts # Keep for TTS
16
  import nest_asyncio
17
  import re
18
  import pytz
19
  import shutil
20
- from PyPDF2 import PdfReader # Keep if PDF tab is kept
21
  import threading
22
  import json
23
  import zipfile
24
  from dotenv import load_dotenv
25
- # from streamlit_marquee import streamlit_marquee # Keep import if used
26
- from collections import defaultdict, Counter, deque # Use deque for action log
27
- # import pandas as pd # Removed dependency
28
- from streamlit_js_eval import streamlit_js_eval, sync # Use sync was removed, re-checking - use streamlit_js_eval
29
- from PIL import Image # Needed for paste_image_component
30
 
31
  # ==============================================================================
32
  # Configuration & Constants
@@ -37,20 +35,31 @@ nest_asyncio.apply()
37
 
38
  # 🎨 Page Config
39
  st.set_page_config(
40
- page_title="🤖🏗️ Shared World Builder 🏆",
41
  page_icon="🏗️",
42
  layout="wide",
43
  initial_sidebar_state="expanded"
44
  )
45
 
46
  # General Constants
47
- icons = '🤖🏗️🗣️💾'
48
- Site_Name = '🤖🏗️ Shared World Builder 🗣️'
49
- START_ROOM = "World Lobby 🌍" # Used? Maybe just for chat title
50
- MEDIA_DIR = "." # Base directory for general files
51
- STATE_FILE = "user_state.txt" # For remembering username
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- # User/Chat Constants
54
  FUN_USERNAMES = {
55
  "BuilderBot 🤖": "en-US-AriaNeural", "WorldWeaver 🕸️": "en-US-JennyNeural",
56
  "Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
@@ -58,34 +67,20 @@ FUN_USERNAMES = {
58
  "CosmicCrafter ✨": "en-GB-RyanNeural", "GeoGuru 🗺️": "en-AU-WilliamNeural",
59
  "BlockBard 🧱": "en-CA-LiamNeural", "SoundSculptor 🔊": "en-US-AnaNeural",
60
  }
61
- EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values())) # Define available voices
62
- DEFAULT_TTS_VOICE = "en-US-AriaNeural"
63
- CHAT_DIR = "chat_logs"
64
-
65
- # Audio Constants
66
- AUDIO_CACHE_DIR = "audio_cache"
67
- AUDIO_DIR = "audio_logs"
68
-
69
- # World Builder Constants
70
- SAVED_WORLDS_DIR = "saved_worlds" # Directory for MD world files
71
- PLOT_WIDTH = 50.0 # Needed for JS injection
72
- PLOT_DEPTH = 50.0 # Needed for JS injection
73
- WORLD_STATE_FILE_MD_PREFIX = "🌍_" # Prefix for world save files
74
- MAX_ACTION_LOG_SIZE = 30 # Max entries in sidebar action log
75
 
76
  # File Emojis
77
  FILE_EMOJIS = {"md": "📝", "mp3": "🎵", "png": "🖼️", "mp4": "🎥", "zip": "📦", "json": "📄"}
78
 
79
- # Primitives Map (Tool Name -> Emoji for Radio Button Display)
80
  PRIMITIVE_MAP = {
81
  "Tree": "🌳", "Rock": "🗿", "Simple House": "🏛️", "Pine Tree": "🌲", "Brick Wall": "🧱",
82
  "Sphere": "🔵", "Cube": "📦", "Cylinder": "🧴", "Cone": "🍦", "Torus": "🍩",
83
  "Mushroom": "🍄", "Cactus": "🌵", "Campfire": "🔥", "Star": "⭐", "Gem": "💎",
84
  "Tower": "🗼", "Barrier": "🚧", "Fountain": "⛲", "Lantern": "🏮", "Sign Post": "팻"
85
  }
86
- # Add None option for clearing tool
87
  TOOLS_MAP = {"None": "🚫"}
88
- TOOLS_MAP.update({name: emoji for emoji, name in PRIMITIVE_MAP.items()}) # Combine for radio options
89
 
90
  # --- Directories ---
91
  for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
@@ -94,29 +89,30 @@ for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
94
  # --- API Keys (Placeholder) ---
95
  load_dotenv()
96
 
97
- # --- Global State & Locks (Removed world_objects global, only client tracking if WS re-added) ---
98
- # Lock might still be useful if background tasks modify shared resources (like file lists?) - Keep for now
99
- app_lock = threading.Lock()
100
- # connected_clients = set() # Removed WS client tracking
101
-
102
  # ==============================================================================
103
  # Utility Functions
104
  # ==============================================================================
 
105
  def get_current_time_str(tz='UTC'):
106
- try: timezone = pytz.timezone(tz); now_aware = datetime.now(timezone)
107
- except Exception: now_aware = datetime.now(pytz.utc)
 
 
 
108
  return now_aware.strftime('%Y%m%d_%H%M%S')
109
 
110
  def clean_filename_part(text, max_len=25):
111
  if not isinstance(text, str): text = "invalid_name"
112
- text = re.sub(r'\s+', '_', text); text = re.sub(r'[^\w\-.]', '', text)
 
113
  return text[:max_len]
114
 
115
  def run_async(async_func, *args, **kwargs):
116
- """Runs an async function safely from a sync context using create_task or asyncio.run."""
117
- try: loop = asyncio.get_running_loop(); return loop.create_task(async_func(*args, **kwargs))
 
 
118
  except RuntimeError:
119
- # print(f"Warning: Running async func {async_func.__name__} in new event loop.")
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
@@ -126,35 +122,43 @@ def ensure_dir(dir_path): os.makedirs(dir_path, exist_ok=True)
126
  # ==============================================================================
127
  # World State File Handling (Markdown + JSON)
128
  # ==============================================================================
 
129
  def generate_world_save_filename(username="User", world_name="World"):
130
- """Generates filename including username, world name, timestamp."""
131
- timestamp = get_current_time_str(); clean_user = clean_filename_part(username, 15);
132
- clean_world = clean_filename_part(world_name, 20);
133
- rand_hash = hashlib.md5(str(time.time()).encode()+username.encode()+world_name.encode()).hexdigest()[:4]
134
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
135
 
136
  def parse_world_filename(filename):
137
- """Extracts info from filename (Name, User, Time, Hash)."""
138
  basename = os.path.basename(filename)
139
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
140
- core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]; parts = core.split('_')
 
141
  if len(parts) >= 5 and parts[-3] == "by":
142
- timestamp_str = parts[-2]; username = parts[-4]; world_name = " ".join(parts[:-4]); dt_obj = None
143
- try: dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
 
 
 
 
144
  except Exception: dt_obj = None
145
  return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
 
146
  # Fallback
147
- dt_fallback = None; try: mtime = os.path.getmtime(filename); dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
 
 
 
148
  except Exception: pass
 
149
  return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
150
 
151
  def save_world_to_md(target_filename_base, world_data_dict):
152
- """Saves the provided world state dictionary to a specific MD file."""
153
- # No global state modification, just write the provided dict
154
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
155
  print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
156
  success = False
157
- parsed_info = parse_world_filename(save_path) # Parse final path
158
  timestamp_save = get_current_time_str()
159
  md_content = f"""# World State: {parsed_info['name']} by {parsed_info['user']}
160
  * **File Saved:** {timestamp_save} (UTC)
@@ -165,14 +169,13 @@ def save_world_to_md(target_filename_base, world_data_dict):
165
  {json.dumps(world_data_dict, indent=2)}
166
  ```"""
167
  try:
168
- ensure_dir(SAVED_WORLDS_DIR);
169
  with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
170
  print(f"World state saved successfully to {target_filename_base}"); success = True
171
  except Exception as e: print(f"Error saving world state to {save_path}: {e}")
172
  return success
173
 
174
  def load_world_from_md(filename_base):
175
- """Loads world state dict from an MD file (basename), returns dict or None."""
176
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
177
  print(f"Loading world state dictionary from MD file: {load_path}...")
178
  if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return None
@@ -187,7 +190,6 @@ def load_world_from_md(filename_base):
187
  except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return None
188
 
189
  def get_saved_worlds():
190
- """Scans the saved worlds directory for world MD files and parses them."""
191
  try:
192
  ensure_dir(SAVED_WORLDS_DIR);
193
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
@@ -212,24 +214,23 @@ def load_username():
212
  return None
213
 
214
  def init_session_state():
215
- """Initializes Streamlit session state variables."""
216
  defaults = {
217
  'last_chat_update': 0, 'message_input': "", 'audio_cache': {},
218
  'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [], 'enable_audio': True,
219
  'download_link_cache': {}, 'username': None, 'autosend': False,
220
  'last_message': "",
221
- 'selected_object': 'None', # Current building tool
222
  'current_world_file': None, # Track loaded world filename (basename)
223
  'new_world_name': "MyWorld",
224
  'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
225
- 'world_to_load_data': None, # Temp storage for state loaded from file before sending to JS
226
  'js_object_placed_data': None # Temp storage for data coming from JS place event
227
  }
228
  for k, v in defaults.items():
229
  if k not in st.session_state:
230
  if k == 'action_log': st.session_state[k] = deque(maxlen=MAX_ACTION_LOG_SIZE)
231
  else: st.session_state[k] = v
232
- # Ensure complex types initialized correctly if state is reloaded badly
233
  if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
234
  if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
235
  if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
@@ -239,59 +240,47 @@ def init_session_state():
239
  # Action Log Helper
240
  # ==============================================================================
241
  def add_action_log(message):
242
- """Adds a message to the session's action log."""
243
  if 'action_log' not in st.session_state:
244
  st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
245
  timestamp = datetime.now().strftime("%H:%M:%S")
246
- st.session_state.action_log.appendleft(f"[{timestamp}] {message}") # Add to front
247
 
248
  # ==============================================================================
249
  # JS Communication Handler Function
250
  # ==============================================================================
251
- # Define this at the top level so streamlit_js_eval can find it
252
- def handle_js_object_placed(data): # Renamed arg for clarity
253
- """Callback triggered by JS when an object is placed."""
254
- print(f"Python received object placed event data: {type(data)}") # Debug type
255
- # Data from streamlit_js_eval might already be a dict if json_data='...' was NOT used
256
- # Let's assume data is already a dict/list from JS object sent.
257
- # If it comes as a string, we need json.loads()
258
  processed_data = None
259
  if isinstance(data, str):
260
- try:
261
- processed_data = json.loads(data)
262
- except json.JSONDecodeError:
263
- print("Failed to decode JSON data from JS object place event.")
264
- return False # Indicate failure
265
- elif isinstance(data, dict):
266
- processed_data = data # Assume it's already a dict
267
- else:
268
- print(f"Received unexpected data type from JS place event: {type(data)}")
269
- return False
270
 
271
  if processed_data and 'obj_id' in processed_data and 'type' in processed_data:
272
- # Store data in session state to be processed in the main script flow
273
- st.session_state.js_object_placed_data = processed_data
274
  add_action_log(f"Placed {processed_data.get('type', 'object')} ({processed_data.get('obj_id', 'N/A')[:6]}...)")
275
- else:
276
- print("Received invalid object placement data structure from JS.")
277
- return False
278
- return True # Acknowledge receipt to JS
279
 
280
  # ==============================================================================
281
- # Audio / TTS / Chat / File Handling Helpers (Keep implementations)
282
  # ==============================================================================
283
- # --- Placeholder for brevity ---
284
- def clean_text_for_tts(text): # ... implementation ...
285
  if not isinstance(text, str): return "No text"
286
  text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
287
  text = ' '.join(text.split()); return text[:250] or "No text"
288
- def create_file(content, username, file_type="md", save_path=None): # ... implementation ...
 
289
  if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
290
  ensure_dir(os.path.dirname(save_path))
291
  try:
292
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
293
  except Exception as e: print(f"Error creating file {save_path}: {e}"); return None
294
- def get_download_link(file_path, file_type="md"): # ... implementation ...
 
295
  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>"
296
  try: mtime = os.path.getmtime(file_path)
297
  except OSError: mtime = 0
@@ -306,7 +295,9 @@ def get_download_link(file_path, file_type="md"): # ... implementation ...
306
  st.session_state.download_link_cache[cache_key] = link_html
307
  except Exception as e: print(f"Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
308
  return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
309
- async def async_edge_tts_generate(text, voice, username): # ... implementation ...
 
 
310
  if not text: return None
311
  cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
312
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
@@ -321,21 +312,31 @@ async def async_edge_tts_generate(text, voice, username): # ... implementation .
321
  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
322
  else: print(f"Audio file {save_path} failed generation."); return None
323
  except Exception as e: print(f"Edge TTS Error: {e}"); return None
324
- def play_and_download_audio(file_path): # ... implementation ...
 
325
  if file_path and os.path.exists(file_path):
326
- try: st.audio(file_path); file_type = file_path.split('.')[-1]; st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
 
 
 
327
  except Exception as e: st.error(f"Audio display error for {os.path.basename(file_path)}: {e}")
328
- async def save_chat_entry(username, message, voice, is_markdown=False): # ... implementation ...
 
 
329
  if not message.strip(): return None, None
330
- 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```"
 
331
  md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
332
  md_file = create_file(entry, username, "md", save_path=md_file_path)
333
  if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
334
  st.session_state.chat_history.append(entry)
335
  audio_file = None;
336
- if st.session_state.get('enable_audio', True): tts_message = message ; audio_file = await async_edge_tts_generate(tts_message, voice, username)
 
 
337
  return md_file, audio_file
338
- async def load_chat_history(): # ... implementation ...
 
339
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
340
  if not st.session_state.chat_history:
341
  ensure_dir(CHAT_DIR); print("Loading chat history from files...")
@@ -348,7 +349,9 @@ async def load_chat_history(): # ... implementation ...
348
  st.session_state.chat_history = temp_history
349
  print(f"Loaded {loaded_count} chat entries from files.")
350
  return st.session_state.chat_history
351
- def create_zip_of_files(files_to_zip, prefix="Archive"): # ... implementation ...
 
 
352
  if not files_to_zip: st.warning("No files provided to zip."); return None
353
  timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
354
  try:
@@ -359,7 +362,8 @@ def create_zip_of_files(files_to_zip, prefix="Archive"): # ... implementation ..
359
  else: print(f"Skip zip missing: {f}")
360
  print("Zip success."); st.success(f"Created {zip_name}"); return zip_name
361
  except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
362
- def delete_files(file_patterns, exclude_files=None): # ... implementation ...
 
363
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
364
  current_world_base = st.session_state.get('current_world_file')
365
  if current_world_base: protected.append(current_world_base)
@@ -383,11 +387,14 @@ def delete_files(file_patterns, exclude_files=None): # ... implementation ...
383
  elif deleted_count > 0: st.success(msg)
384
  else: st.info("No matching files found to delete.")
385
  st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
386
- async def save_pasted_image(image, username): # ... implementation ...
 
 
387
  if not image: return None
388
  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
389
  except Exception as e: print(f"Failed image save: {e}"); return None
390
- def paste_image_component(): # ... implementation ...
 
391
  pasted_img = None; img_type = None
392
  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', ""))
393
  if st.button("Process Pasted Image 📋", key="process_paste_button"):
@@ -406,7 +413,9 @@ def paste_image_component(): # ... implementation ...
406
  try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
407
  except Exception: return None
408
  return None
409
- class AudioProcessor: # ... implementation ...
 
 
410
  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 {}
411
  def _save_metadata(self):
412
  try:
@@ -423,27 +432,32 @@ class AudioProcessor: # ... implementation ...
423
  if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0: self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text_cleaned), 'voice': voice}; self._save_metadata(); return cache_path
424
  else: return None
425
  except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
426
- def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
427
- st.subheader("PDF Processing Results");
 
428
  if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
429
- audio_processor = AudioProcessor();
430
  try:
431
  reader=PdfReader(pdf_file);
432
  if reader.is_encrypted: st.warning("PDF is encrypted."); return
433
- total_pages=min(len(reader.pages),max_pages);
434
- st.write(f"Processing first {total_pages} pages of '{pdf_file.name}'...");
435
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
 
436
  def process_page_sync(page_num, page_text):
437
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
438
- try: audio_path = asyncio.run(run_async_audio())
 
439
  if audio_path:
440
  with results_lock: audios[page_num] = audio_path
441
  except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
442
- for i in range(total_pages):
 
443
  try: page = reader.pages[i]; text = page.extract_text();
444
  if text and text.strip(): texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
445
  else: texts[i] = "[No text extracted]"
446
  except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
 
447
  progress_bar = st.progress(0.0, text="Processing pages...")
448
  total_threads = len(page_threads); start_join_time = time.time()
449
  while any(t.is_alive() for t in page_threads):
@@ -452,15 +466,16 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
452
  if time.time() - start_join_time > 600: print("PDF processing timed out."); break
453
  time.sleep(0.5)
454
  progress_bar.progress(1.0, text="Processing complete.")
455
- for i in range(total_pages):
 
456
  with st.expander(f"Page {i+1}"):
457
  st.markdown(texts.get(i, "[Error getting text]"))
458
  audio_file = audios.get(i)
459
  if audio_file: play_and_download_audio(audio_file)
460
  else: st.caption("Audio generation failed or was skipped.")
461
- except ImportError: st.error("PyPDF2 library needed for PDF processing.")
462
- except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
463
 
 
 
464
 
465
  # ==============================================================================
466
  # Streamlit UI Layout Functions
@@ -474,21 +489,19 @@ def render_sidebar():
474
  # --- World Save ---
475
  current_file = st.session_state.get('current_world_file')
476
  current_world_name = "Live State"
 
477
  if current_file:
478
- parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file)) # Parse full path for name
479
- current_world_name = parsed.get("name", current_file)
 
480
 
481
- # Input for Save name (used for both Save and Save As New)
482
  world_save_name = st.text_input(
483
- "World Name for Save:",
484
- key="world_save_name_input",
485
- value=current_world_name if current_file else st.session_state.get('new_world_name', 'MyWorld'),
486
  help="Enter name to save as new, or keep current name to overwrite."
487
  )
488
 
489
- if st.button("💾 Save Current World View", key="sidebar_save_world", help="Saves the current 3D view state to a file."):
490
- if not world_save_name.strip():
491
- st.warning("Please enter a World Name before saving.")
492
  else:
493
  with st.spinner("Requesting world state & saving..."):
494
  js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_sidebar_save", want_result=True)
@@ -496,28 +509,17 @@ def render_sidebar():
496
  try:
497
  world_data_dict = json.loads(js_world_state_str)
498
  if isinstance(world_data_dict, dict):
499
- # Decide filename: Overwrite if name matches current loaded file's derived name, else new file
500
- filename_to_save = ""
501
- is_overwrite = False
502
  if current_file:
503
  parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
504
- # Check if input name matches the name part of the current file
505
- if world_save_name == parsed_current.get('name', ''):
506
- filename_to_save = current_file
507
- is_overwrite = True
508
-
509
- if not filename_to_save: # Create new filename if not overwriting
510
- filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
511
 
512
  if save_world_to_md(filename_to_save, world_data_dict):
513
  action = "Overwritten" if is_overwrite else "Saved new"
514
- st.success(f"World {action}: {filename_to_save}")
515
- add_action_log(f"Saved world: {filename_to_save}")
516
  st.session_state.current_world_file = filename_to_save # Track saved file
517
- st.session_state.new_world_name = "MyWorld" # Reset default potentially
518
- # Clear the input only if it was a new save? Optional.
519
- # st.session_state.world_save_name_input = current_world_name if is_overwrite else "MyWorld"
520
- st.rerun() # Refresh sidebar list
521
  else: st.error("Failed to save world state.")
522
  else: st.error("Invalid state format from client.")
523
  except json.JSONDecodeError: st.error("Failed to decode state from client.")
@@ -528,52 +530,41 @@ def render_sidebar():
528
  st.markdown("---")
529
  st.header("📂 Load World")
530
  saved_worlds = get_saved_worlds()
531
-
532
  if not saved_worlds: st.caption("No saved worlds found.")
533
  else:
534
  st.caption("Click button to load state.")
535
- cols_header = st.columns([3, 1, 1])
536
  with cols_header[0]: st.write("**Name** (User, Time)")
537
  with cols_header[1]: st.write("**Load**")
538
  with cols_header[2]: st.write("**DL**")
539
  display_limit = 15; displayed_count = 0
540
- for world_info in saved_worlds:
541
- f_basename = os.path.basename(world_info['filename'])
542
- f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename)
543
  display_name = world_info.get('name', f_basename); user = world_info.get('user', 'N/A'); timestamp = world_info.get('timestamp', 'N/A')
544
  display_text = f"{display_name} ({user}, {timestamp})"
545
-
546
- # Use expander for items beyond limit
547
  container = st
548
- if displayed_count >= display_limit:
549
- if 'expander_open' not in st.session_state: st.session_state.expander_open = False
550
- container = st.expander(f"Show {len(saved_worlds)-display_limit} more...", expanded=st.session_state.expander_open)
551
- if not container: break # Should not happen, safety
552
-
553
- with container: # Display inside sidebar or expander
554
- col1, col2, col3 = st.columns([3, 1, 1])
555
- with col1: st.write(f"<small>{display_text}</small>", unsafe_allow_html=True)
556
- with col2:
557
- # Disable button if already loaded
558
- is_current = (st.session_state.get('current_world_file') == f_basename)
559
- btn_load = st.button("Load", key=f"load_{f_basename}", help=f"Load {f_basename}", disabled=is_current)
560
- with col3: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
561
-
562
- if btn_load: # Handle click if not disabled
563
- print(f"Load button clicked for: {f_basename}")
564
- world_dict = load_world_from_md(f_basename)
565
- if world_dict is not None:
566
- st.session_state.world_to_load_data = world_dict
567
- st.session_state.current_world_file = f_basename
568
- add_action_log(f"Loading world: {f_basename}")
569
- st.rerun()
570
- else: st.error(f"Failed to parse world file: {f_basename}")
571
-
572
  displayed_count += 1
573
- if displayed_count >= display_limit and len(saved_worlds) > display_limit:
574
- # Toggle expander state if needed for next items - complex logic, maybe remove expander for simplicity?
575
- # For now, just rely on the single expander after the limit is hit.
576
- pass
577
 
578
 
579
  # --- Build Tools ---
@@ -583,7 +574,7 @@ def render_sidebar():
583
  tool_options = list(TOOLS_MAP.keys())
584
  current_tool_name = st.session_state.get('selected_object', 'None')
585
  try: tool_index = tool_options.index(current_tool_name)
586
- except ValueError: tool_index = 0 # Default to None/index 0
587
 
588
  selected_tool = st.radio(
589
  "Select Tool:", options=tool_options, index=tool_index,
@@ -593,9 +584,16 @@ def render_sidebar():
593
  if selected_tool != current_tool_name:
594
  st.session_state.selected_object = selected_tool
595
  add_action_log(f"Selected tool: {selected_tool}")
596
- run_async(lambda tool=selected_tool: streamlit_js_eval(f"updateSelectedObjectType({json.dumps(tool)});", key=f"update_tool_js_{tool}"))
 
 
 
 
 
 
597
  st.rerun()
598
 
 
599
  # --- Action Log ---
600
  st.markdown("---")
601
  st.header("📝 Action Log")
@@ -605,6 +603,7 @@ def render_sidebar():
605
  if log_entries: st.code('\n'.join(log_entries), language="log")
606
  else: st.caption("No actions recorded yet.")
607
 
 
608
  # --- Voice/User ---
609
  st.markdown("---")
610
  st.header("🗣️ Voice & User")
@@ -625,37 +624,41 @@ def render_main_content():
625
  """Renders the main content area with tabs."""
626
  st.title(f"{Site_Name} - User: {st.session_state.username}")
627
 
628
- # Check if world data needs to be sent to JS
629
  world_data_to_load = st.session_state.pop('world_to_load_data', None)
630
  if world_data_to_load is not None:
631
  print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
632
- # Use sync version of streamlit_js_eval if we need confirmation or it simplifies flow
633
- # However, just calling the JS function might be enough if JS handles errors.
634
- streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
635
- st.toast("World loaded in 3D view.", icon="🔄")
 
 
636
 
637
  # Set up the mechanism for JS to call Python when an object is placed
638
  streamlit_js_eval(
639
  js_code="""
 
640
  if (!window.sendPlacedObjectToPython) {
 
641
  window.sendPlacedObjectToPython = (objectData) => {
 
642
  // Call Python function 'handle_js_object_placed' via its unique key
643
- streamlit_js_eval(`handle_js_object_placed(${JSON.stringify(objectData)})`, key='js_place_event_handler');
 
644
  }
645
  }
646
  """,
647
  key="setup_js_place_event_handler" # Key for the setup code itself
648
  )
649
 
650
- # Check if the Python handler function was triggered in the previous interaction
651
- # Note: The Python function `handle_js_object_placed` should have been defined globally
652
- # and it likely stored data in st.session_state.js_object_placed_data
653
  placed_data = st.session_state.pop('js_object_placed_data', None) # Use pop to consume
654
  if placed_data:
655
- print(f"Processed stored placed object data: {placed_data.get('obj_id')}")
656
  # Action log already added in handle_js_object_placed.
657
- # No further action needed here in this simplified model (no server state update).
658
- pass
659
 
660
 
661
  # Define Tabs
@@ -664,26 +667,39 @@ def render_main_content():
664
  # --- World Builder Tab ---
665
  with tab_world:
666
  st.header("Shared 3D World")
667
- st.caption("Place objects using sidebar tools. Use Sidebar/Files tab to Save/Load.")
668
  current_file_basename = st.session_state.get('current_world_file', None)
669
  if current_file_basename:
670
  full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
671
  if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
672
- else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
673
- else: st.info("Live State Active (Save to persist)")
674
 
675
  # Embed HTML Component
676
  html_file_path = 'index.html'
677
  try:
678
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
679
- # Inject state needed by JS
 
 
 
 
 
 
 
 
 
 
 
 
680
  js_injection_script = f"""<script>
681
  window.USERNAME = {json.dumps(st.session_state.username)};
682
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
683
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
684
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
685
- // Initial world state is now loaded via loadWorldState() JS call triggered by Python
686
- console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE }});
 
687
  </script>"""
688
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
689
  components.html(html_content_with_state, height=700, scrolling=False)
@@ -706,9 +722,8 @@ def render_main_content():
706
 
707
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
708
  send_button_clicked = st.button("Send Chat", key="send_chat_button", on_click=clear_chat_input_callback)
709
- # Removed autosend for simplicity
710
 
711
- if send_button_clicked: # Process only on button click now
712
  message_to_send = message_value # Value before potential clear by callback
713
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
714
  st.session_state.last_message = message_to_send
@@ -717,6 +732,7 @@ def render_main_content():
717
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
718
  # Rerun is handled implicitly by button click + callback
719
  elif send_button_clicked: st.toast("Message empty or same as last.")
 
720
 
721
  # --- PDF Tab ---
722
  with tab_pdf:
@@ -734,6 +750,7 @@ def render_main_content():
734
  st.subheader("💾 World Management")
735
  current_file_basename = st.session_state.get('current_world_file', None)
736
 
 
737
  if current_file_basename:
738
  full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
739
  save_label = f"Save Changes to '{current_file_basename}'"
@@ -754,6 +771,7 @@ def render_main_content():
754
  else: st.warning("Did not receive world state from client.")
755
  else: st.info("Load a world or use 'Save As New Version' below.")
756
 
 
757
  st.subheader("Save As New Version")
758
  new_name_files = st.text_input("World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
759
  if st.button("💾 Save Current View as New Version", key="save_new_version_files"):
@@ -776,7 +794,7 @@ def render_main_content():
776
  else: st.warning("Did not receive world state from client.")
777
  else: st.warning("Please enter a name.")
778
 
779
- # Removed Server Status Section
780
 
781
  st.subheader("🗑️ Delete Files")
782
  st.warning("Deletion is permanent!", icon="⚠️")
@@ -793,7 +811,8 @@ def render_main_content():
793
  st.subheader("📦 Download Archives")
794
  col_zip1, col_zip2, col_zip3 = st.columns(3)
795
  with col_zip1:
796
- if st.button("Zip Worlds"): create_zip_of_files(glob.glob(os.path.join(SAVED_WORLDS_DIR, "*.md")), "Worlds")
 
797
  with col_zip2:
798
  if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
799
  with col_zip3:
@@ -803,7 +822,7 @@ def render_main_content():
803
  st.caption("Existing Zip Files:")
804
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
805
  else:
806
- st.caption("No zip archives found.")
807
 
808
 
809
  # ==============================================================================
@@ -811,7 +830,7 @@ def render_main_content():
811
  # ==============================================================================
812
 
813
  def initialize_app():
814
- """Handles session init and initial world load."""
815
  init_session_state()
816
  # Load username
817
  if not st.session_state.username:
@@ -819,22 +838,25 @@ def initialize_app():
819
  if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
820
  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)
821
 
822
- # Load initial world state (most recent) if no specific world load is pending from a button click
823
- if st.session_state.get('world_to_load_data') is None and st.session_state.get('current_world_file') is None:
824
- print("Attempting initial load of most recent world...")
825
- saved_worlds = get_saved_worlds()
826
- if saved_worlds:
827
- latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
828
- print(f"Loading most recent world on startup: {latest_world_file_basename}")
829
- world_dict = load_world_from_md(latest_world_file_basename)
830
- if world_dict is not None:
831
- st.session_state.world_to_load_data = world_dict # Set data to be sent to JS
832
- st.session_state.current_world_file = latest_world_file_basename # Update selection state
833
- else: print("Failed to load most recent world.")
834
- else: print("No saved worlds found for initial load.")
 
 
 
835
 
836
 
837
  if __name__ == "__main__":
838
- initialize_app() # Initialize state, user, potentially queue initial world load data
839
  render_sidebar() # Render sidebar UI (includes load buttons)
840
- render_main_content() # Render main UI (includes logic to send loaded world data to JS)
 
1
+ # app.py
2
  import streamlit as st
3
  import asyncio
 
4
  import uuid
5
  from datetime import datetime
6
  import os
 
11
  import base64
12
  import io
13
  import streamlit.components.v1 as components
14
+ import edge_tts
15
  import nest_asyncio
16
  import re
17
  import pytz
18
  import shutil
19
+ from PyPDF2 import PdfReader
20
  import threading
21
  import json
22
  import zipfile
23
  from dotenv import load_dotenv
24
+ # from streamlit_marquee import streamlit_marquee # Can uncomment if marquee is used
25
+ from collections import defaultdict, Counter, deque
26
+ from streamlit_js_eval import streamlit_js_eval # Correct import
27
+ from PIL import Image
 
28
 
29
  # ==============================================================================
30
  # Configuration & Constants
 
35
 
36
  # 🎨 Page Config
37
  st.set_page_config(
38
+ page_title="🏗️ World Action Builder 🏆",
39
  page_icon="🏗️",
40
  layout="wide",
41
  initial_sidebar_state="expanded"
42
  )
43
 
44
  # General Constants
45
+ Site_Name = '🏗️ World Action Builder'
46
+ MEDIA_DIR = "."
47
+ STATE_FILE = "user_state.txt"
48
+ DEFAULT_TTS_VOICE = "en-US-AriaNeural"
49
+
50
+ # Directories
51
+ CHAT_DIR = "chat_logs"
52
+ AUDIO_CACHE_DIR = "audio_cache"
53
+ AUDIO_DIR = "audio_logs"
54
+ SAVED_WORLDS_DIR = "saved_worlds"
55
+
56
+ # World Builder Constants
57
+ PLOT_WIDTH = 50.0
58
+ PLOT_DEPTH = 50.0
59
+ WORLD_STATE_FILE_MD_PREFIX = "🌍_"
60
+ MAX_ACTION_LOG_SIZE = 30
61
 
62
+ # User/Chat Constants (Re-added)
63
  FUN_USERNAMES = {
64
  "BuilderBot 🤖": "en-US-AriaNeural", "WorldWeaver 🕸️": "en-US-JennyNeural",
65
  "Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
 
67
  "CosmicCrafter ✨": "en-GB-RyanNeural", "GeoGuru 🗺️": "en-AU-WilliamNeural",
68
  "BlockBard 🧱": "en-CA-LiamNeural", "SoundSculptor 🔊": "en-US-AnaNeural",
69
  }
70
+ EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values()))
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  # File Emojis
73
  FILE_EMOJIS = {"md": "📝", "mp3": "🎵", "png": "🖼️", "mp4": "🎥", "zip": "📦", "json": "📄"}
74
 
75
+ # Primitives Map
76
  PRIMITIVE_MAP = {
77
  "Tree": "🌳", "Rock": "🗿", "Simple House": "🏛️", "Pine Tree": "🌲", "Brick Wall": "🧱",
78
  "Sphere": "🔵", "Cube": "📦", "Cylinder": "🧴", "Cone": "🍦", "Torus": "🍩",
79
  "Mushroom": "🍄", "Cactus": "🌵", "Campfire": "🔥", "Star": "⭐", "Gem": "💎",
80
  "Tower": "🗼", "Barrier": "🚧", "Fountain": "⛲", "Lantern": "🏮", "Sign Post": "팻"
81
  }
 
82
  TOOLS_MAP = {"None": "🚫"}
83
+ TOOLS_MAP.update({name: emoji for emoji, name in PRIMITIVE_MAP.items()})
84
 
85
  # --- Directories ---
86
  for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
 
89
  # --- API Keys (Placeholder) ---
90
  load_dotenv()
91
 
 
 
 
 
 
92
  # ==============================================================================
93
  # Utility Functions
94
  # ==============================================================================
95
+
96
  def get_current_time_str(tz='UTC'):
97
+ try:
98
+ timezone = pytz.timezone(tz)
99
+ now_aware = datetime.now(timezone)
100
+ except Exception:
101
+ now_aware = datetime.now(pytz.utc)
102
  return now_aware.strftime('%Y%m%d_%H%M%S')
103
 
104
  def clean_filename_part(text, max_len=25):
105
  if not isinstance(text, str): text = "invalid_name"
106
+ text = re.sub(r'\s+', '_', text)
107
+ text = re.sub(r'[^\w\-.]', '', text)
108
  return text[:max_len]
109
 
110
  def run_async(async_func, *args, **kwargs):
111
+ """Runs an async function safely from a sync context."""
112
+ try:
113
+ loop = asyncio.get_running_loop()
114
+ return loop.create_task(async_func(*args, **kwargs))
115
  except RuntimeError:
 
116
  try: return asyncio.run(async_func(*args, **kwargs))
117
  except Exception as e: print(f"Error run_async new loop: {e}"); return None
118
  except Exception as e: print(f"Error run_async schedule task: {e}"); return None
 
122
  # ==============================================================================
123
  # World State File Handling (Markdown + JSON)
124
  # ==============================================================================
125
+
126
  def generate_world_save_filename(username="User", world_name="World"):
127
+ timestamp = get_current_time_str()
128
+ clean_user = clean_filename_part(username, 15)
129
+ clean_world = clean_filename_part(world_name, 20)
130
+ rand_hash = hashlib.md5(str(time.time()).encode() + username.encode() + world_name.encode()).hexdigest()[:4]
131
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
132
 
133
  def parse_world_filename(filename):
 
134
  basename = os.path.basename(filename)
135
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
136
+ core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
137
+ parts = core.split('_')
138
  if len(parts) >= 5 and parts[-3] == "by":
139
+ timestamp_str = parts[-2]
140
+ username = parts[-4]
141
+ world_name = " ".join(parts[:-4])
142
+ dt_obj = None
143
+ try:
144
+ dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
145
  except Exception: dt_obj = None
146
  return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
147
+
148
  # Fallback
149
+ dt_fallback = None
150
+ try:
151
+ mtime = os.path.getmtime(filename)
152
+ dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
153
  except Exception: pass
154
+ # Corrected indentation for this return statement
155
  return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
156
 
157
  def save_world_to_md(target_filename_base, world_data_dict):
 
 
158
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
159
  print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
160
  success = False
161
+ parsed_info = parse_world_filename(save_path)
162
  timestamp_save = get_current_time_str()
163
  md_content = f"""# World State: {parsed_info['name']} by {parsed_info['user']}
164
  * **File Saved:** {timestamp_save} (UTC)
 
169
  {json.dumps(world_data_dict, indent=2)}
170
  ```"""
171
  try:
172
+ ensure_dir(SAVED_WORLDS_DIR)
173
  with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
174
  print(f"World state saved successfully to {target_filename_base}"); success = True
175
  except Exception as e: print(f"Error saving world state to {save_path}: {e}")
176
  return success
177
 
178
  def load_world_from_md(filename_base):
 
179
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
180
  print(f"Loading world state dictionary from MD file: {load_path}...")
181
  if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return None
 
190
  except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return None
191
 
192
  def get_saved_worlds():
 
193
  try:
194
  ensure_dir(SAVED_WORLDS_DIR);
195
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
 
214
  return None
215
 
216
  def init_session_state():
 
217
  defaults = {
218
  'last_chat_update': 0, 'message_input': "", 'audio_cache': {},
219
  'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [], 'enable_audio': True,
220
  'download_link_cache': {}, 'username': None, 'autosend': False,
221
  'last_message': "",
222
+ 'selected_object': 'None',
223
  'current_world_file': None, # Track loaded world filename (basename)
224
  'new_world_name': "MyWorld",
225
  'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
226
+ 'world_to_load_data': None, # Temp storage for state loaded from file
227
  'js_object_placed_data': None # Temp storage for data coming from JS place event
228
  }
229
  for k, v in defaults.items():
230
  if k not in st.session_state:
231
  if k == 'action_log': st.session_state[k] = deque(maxlen=MAX_ACTION_LOG_SIZE)
232
  else: st.session_state[k] = v
233
+ # Ensure complex types initialized correctly
234
  if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
235
  if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
236
  if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
 
240
  # Action Log Helper
241
  # ==============================================================================
242
  def add_action_log(message):
 
243
  if 'action_log' not in st.session_state:
244
  st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
245
  timestamp = datetime.now().strftime("%H:%M:%S")
246
+ st.session_state.action_log.appendleft(f"[{timestamp}] {message}")
247
 
248
  # ==============================================================================
249
  # JS Communication Handler Function
250
  # ==============================================================================
251
+ def handle_js_object_placed(data):
252
+ """Callback triggered by JS when an object is placed. Stores data in state."""
253
+ print(f"Python received object placed event data: {type(data)}")
 
 
 
 
254
  processed_data = None
255
  if isinstance(data, str):
256
+ try: processed_data = json.loads(data)
257
+ except json.JSONDecodeError: print("Failed decode JSON from JS object place event."); return False
258
+ elif isinstance(data, dict): processed_data = data
259
+ else: print(f"Received unexpected data type from JS place event: {type(data)}"); return False
 
 
 
 
 
 
260
 
261
  if processed_data and 'obj_id' in processed_data and 'type' in processed_data:
262
+ st.session_state.js_object_placed_data = processed_data # Store for main loop processing
 
263
  add_action_log(f"Placed {processed_data.get('type', 'object')} ({processed_data.get('obj_id', 'N/A')[:6]}...)")
264
+ else: print("Received invalid object placement data structure from JS."); return False
265
+ return True
 
 
266
 
267
  # ==============================================================================
268
+ # Audio / TTS / Chat / File Handling Helpers
269
  # ==============================================================================
270
+ # --- Text & File Helpers ---
271
+ def clean_text_for_tts(text):
272
  if not isinstance(text, str): return "No text"
273
  text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
274
  text = ' '.join(text.split()); return text[:250] or "No text"
275
+
276
+ def create_file(content, username, file_type="md", save_path=None):
277
  if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
278
  ensure_dir(os.path.dirname(save_path))
279
  try:
280
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
281
  except Exception as e: print(f"Error creating file {save_path}: {e}"); return None
282
+
283
+ def get_download_link(file_path, file_type="md"):
284
  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>"
285
  try: mtime = os.path.getmtime(file_path)
286
  except OSError: mtime = 0
 
295
  st.session_state.download_link_cache[cache_key] = link_html
296
  except Exception as e: print(f"Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
297
  return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
298
+
299
+ # --- Audio / TTS ---
300
+ async def async_edge_tts_generate(text, voice, username):
301
  if not text: return None
302
  cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
303
  if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
 
312
  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
313
  else: print(f"Audio file {save_path} failed generation."); return None
314
  except Exception as e: print(f"Edge TTS Error: {e}"); return None
315
+
316
+ def play_and_download_audio(file_path):
317
  if file_path and os.path.exists(file_path):
318
+ try:
319
+ st.audio(file_path)
320
+ file_type = file_path.split('.')[-1]
321
+ st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
322
  except Exception as e: st.error(f"Audio display error for {os.path.basename(file_path)}: {e}")
323
+
324
+ # --- Chat ---
325
+ async def save_chat_entry(username, message, voice, is_markdown=False):
326
  if not message.strip(): return None, None
327
+ timestamp_str = get_current_time_str();
328
+ entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
329
  md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
330
  md_file = create_file(entry, username, "md", save_path=md_file_path)
331
  if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
332
  st.session_state.chat_history.append(entry)
333
  audio_file = None;
334
+ if st.session_state.get('enable_audio', True):
335
+ tts_message = message
336
+ audio_file = await async_edge_tts_generate(tts_message, voice, username)
337
  return md_file, audio_file
338
+
339
+ async def load_chat_history():
340
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
341
  if not st.session_state.chat_history:
342
  ensure_dir(CHAT_DIR); print("Loading chat history from files...")
 
349
  st.session_state.chat_history = temp_history
350
  print(f"Loaded {loaded_count} chat entries from files.")
351
  return st.session_state.chat_history
352
+
353
+ # --- File Management ---
354
+ def create_zip_of_files(files_to_zip, prefix="Archive"):
355
  if not files_to_zip: st.warning("No files provided to zip."); return None
356
  timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
357
  try:
 
362
  else: print(f"Skip zip missing: {f}")
363
  print("Zip success."); st.success(f"Created {zip_name}"); return zip_name
364
  except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
365
+
366
+ def delete_files(file_patterns, exclude_files=None):
367
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
368
  current_world_base = st.session_state.get('current_world_file')
369
  if current_world_base: protected.append(current_world_base)
 
387
  elif deleted_count > 0: st.success(msg)
388
  else: st.info("No matching files found to delete.")
389
  st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
390
+
391
+ # --- Image Handling ---
392
+ async def save_pasted_image(image, username):
393
  if not image: return None
394
  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
395
  except Exception as e: print(f"Failed image save: {e}"); return None
396
+
397
+ def paste_image_component():
398
  pasted_img = None; img_type = None
399
  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', ""))
400
  if st.button("Process Pasted Image 📋", key="process_paste_button"):
 
413
  try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
414
  except Exception: return None
415
  return None
416
+
417
+ # --- PDF Processing ---
418
+ class AudioProcessor:
419
  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 {}
420
  def _save_metadata(self):
421
  try:
 
432
  if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0: self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text_cleaned), 'voice': voice}; self._save_metadata(); return cache_path
433
  else: return None
434
  except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
435
+
436
+ def process_pdf_tab(pdf_file, max_pages, voice):
437
+ st.subheader("PDF Processing Results")
438
  if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
439
+ audio_processor = AudioProcessor()
440
  try:
441
  reader=PdfReader(pdf_file);
442
  if reader.is_encrypted: st.warning("PDF is encrypted."); return
443
+ total_pages_in_pdf = len(reader.pages); pages_to_process = min(total_pages_in_pdf, max_pages);
444
+ st.write(f"Processing first {pages_to_process} of {total_pages_in_pdf} pages from '{pdf_file.name}'...")
445
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
446
+
447
  def process_page_sync(page_num, page_text):
448
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
449
+ try:
450
+ audio_path = asyncio.run(run_async_audio())
451
  if audio_path:
452
  with results_lock: audios[page_num] = audio_path
453
  except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
454
+
455
+ for i in range(pages_to_process):
456
  try: page = reader.pages[i]; text = page.extract_text();
457
  if text and text.strip(): texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
458
  else: texts[i] = "[No text extracted]"
459
  except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
460
+
461
  progress_bar = st.progress(0.0, text="Processing pages...")
462
  total_threads = len(page_threads); start_join_time = time.time()
463
  while any(t.is_alive() for t in page_threads):
 
466
  if time.time() - start_join_time > 600: print("PDF processing timed out."); break
467
  time.sleep(0.5)
468
  progress_bar.progress(1.0, text="Processing complete.")
469
+
470
+ for i in range(pages_to_process):
471
  with st.expander(f"Page {i+1}"):
472
  st.markdown(texts.get(i, "[Error getting text]"))
473
  audio_file = audios.get(i)
474
  if audio_file: play_and_download_audio(audio_file)
475
  else: st.caption("Audio generation failed or was skipped.")
 
 
476
 
477
+ except ImportError: st.error("PyPDF2 library needed.")
478
+ except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
479
 
480
  # ==============================================================================
481
  # Streamlit UI Layout Functions
 
489
  # --- World Save ---
490
  current_file = st.session_state.get('current_world_file')
491
  current_world_name = "Live State"
492
+ default_save_name = "MyWorld"
493
  if current_file:
494
+ parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
495
+ current_world_name = parsed.get("name", current_file)
496
+ default_save_name = current_world_name # Default to overwriting current name
497
 
 
498
  world_save_name = st.text_input(
499
+ "World Name for Save:", key="world_save_name_input", value=default_save_name,
 
 
500
  help="Enter name to save as new, or keep current name to overwrite."
501
  )
502
 
503
+ if st.button("💾 Save Current World View", key="sidebar_save_world"):
504
+ if not world_save_name.strip(): st.warning("Please enter a World Name.")
 
505
  else:
506
  with st.spinner("Requesting world state & saving..."):
507
  js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_sidebar_save", want_result=True)
 
509
  try:
510
  world_data_dict = json.loads(js_world_state_str)
511
  if isinstance(world_data_dict, dict):
512
+ filename_to_save = ""; is_overwrite = False
 
 
513
  if current_file:
514
  parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
515
+ if world_save_name == parsed_current.get('name', ''): filename_to_save = current_file; is_overwrite = True
516
+ if not filename_to_save: filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
 
 
 
 
 
517
 
518
  if save_world_to_md(filename_to_save, world_data_dict):
519
  action = "Overwritten" if is_overwrite else "Saved new"
520
+ st.success(f"World {action}: {filename_to_save}"); add_action_log(f"Saved world: {filename_to_save}")
 
521
  st.session_state.current_world_file = filename_to_save # Track saved file
522
+ st.rerun()
 
 
 
523
  else: st.error("Failed to save world state.")
524
  else: st.error("Invalid state format from client.")
525
  except json.JSONDecodeError: st.error("Failed to decode state from client.")
 
530
  st.markdown("---")
531
  st.header("📂 Load World")
532
  saved_worlds = get_saved_worlds()
 
533
  if not saved_worlds: st.caption("No saved worlds found.")
534
  else:
535
  st.caption("Click button to load state.")
536
+ cols_header = st.columns([3, 1, 1]);
537
  with cols_header[0]: st.write("**Name** (User, Time)")
538
  with cols_header[1]: st.write("**Load**")
539
  with cols_header[2]: st.write("**DL**")
540
  display_limit = 15; displayed_count = 0
541
+ for i, world_info in enumerate(saved_worlds):
542
+ f_basename = os.path.basename(world_info['filename']); f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename);
 
543
  display_name = world_info.get('name', f_basename); user = world_info.get('user', 'N/A'); timestamp = world_info.get('timestamp', 'N/A')
544
  display_text = f"{display_name} ({user}, {timestamp})"
545
+ is_last_displayed = i == display_limit -1; show_expander_trigger = len(saved_worlds) > display_limit and is_last_displayed
 
546
  container = st
547
+ if displayed_count >= display_limit: container = st.expander(f"Show {len(saved_worlds)-display_limit} more...")
548
+ if displayed_count < display_limit or show_expander_trigger :
549
+ with container:
550
+ col1, col2, col3 = st.columns([3, 1, 1])
551
+ with col1: st.write(f"<small>{display_text}</small>", unsafe_allow_html=True)
552
+ with col2:
553
+ is_current = (st.session_state.get('current_world_file') == f_basename)
554
+ btn_load = st.button("Load", key=f"load_{f_basename}", help=f"Load {f_basename}", disabled=is_current)
555
+ with col3: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
556
+ if btn_load:
557
+ print(f"Load button clicked for: {f_basename}")
558
+ world_dict = load_world_from_md(f_basename)
559
+ if world_dict is not None:
560
+ st.session_state.world_to_load_data = world_dict
561
+ st.session_state.current_world_file = f_basename
562
+ add_action_log(f"Loading world: {f_basename}")
563
+ st.rerun()
564
+ else: st.error(f"Failed to parse world file: {f_basename}")
565
+ if show_expander_trigger: # If this was the trigger, stop outer loop
566
+ break
 
 
 
 
567
  displayed_count += 1
 
 
 
 
568
 
569
 
570
  # --- Build Tools ---
 
574
  tool_options = list(TOOLS_MAP.keys())
575
  current_tool_name = st.session_state.get('selected_object', 'None')
576
  try: tool_index = tool_options.index(current_tool_name)
577
+ except ValueError: tool_index = 0
578
 
579
  selected_tool = st.radio(
580
  "Select Tool:", options=tool_options, index=tool_index,
 
584
  if selected_tool != current_tool_name:
585
  st.session_state.selected_object = selected_tool
586
  add_action_log(f"Selected tool: {selected_tool}")
587
+ # Update JS using synchronous call if needed immediately, or rely on injection
588
+ try:
589
+ streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
590
+ except Exception as e:
591
+ print(f"JS tool update error: {e}")
592
+ # Rerun might still be useful to ensure radio button visual updates correctly,
593
+ # although state change should handle it. Let's keep it for now.
594
  st.rerun()
595
 
596
+
597
  # --- Action Log ---
598
  st.markdown("---")
599
  st.header("📝 Action Log")
 
603
  if log_entries: st.code('\n'.join(log_entries), language="log")
604
  else: st.caption("No actions recorded yet.")
605
 
606
+
607
  # --- Voice/User ---
608
  st.markdown("---")
609
  st.header("🗣️ Voice & User")
 
624
  """Renders the main content area with tabs."""
625
  st.title(f"{Site_Name} - User: {st.session_state.username}")
626
 
627
+ # Check if world data needs to be sent to JS (loaded via sidebar button)
628
  world_data_to_load = st.session_state.pop('world_to_load_data', None)
629
  if world_data_to_load is not None:
630
  print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
631
+ try:
632
+ # Call JS function loadWorldState, pass data as JSON
633
+ streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
634
+ st.toast("World loaded in 3D view.", icon="🔄")
635
+ except Exception as e:
636
+ st.error(f"Failed to send loaded world state to JS: {e}")
637
 
638
  # Set up the mechanism for JS to call Python when an object is placed
639
  streamlit_js_eval(
640
  js_code="""
641
+ // Ensure function exists and avoid re-defining listener if possible
642
  if (!window.sendPlacedObjectToPython) {
643
+ console.log('Defining sendPlacedObjectToPython for JS->Python comms...');
644
  window.sendPlacedObjectToPython = (objectData) => {
645
+ console.log('JS sending placed object:', objectData);
646
  // Call Python function 'handle_js_object_placed' via its unique key
647
+ // Pass data directly as object - streamlit_js_eval handles serialization
648
+ streamlit_js_eval(python_code='handle_js_object_placed(data=' + JSON.stringify(objectData) + ')', key='js_place_event_handler');
649
  }
650
  }
651
  """,
652
  key="setup_js_place_event_handler" # Key for the setup code itself
653
  )
654
 
655
+ # Check if the Python handler function was triggered and stored data
 
 
656
  placed_data = st.session_state.pop('js_object_placed_data', None) # Use pop to consume
657
  if placed_data:
658
+ print(f"Python processed stored placed object data: {placed_data.get('obj_id')}")
659
  # Action log already added in handle_js_object_placed.
660
+ # The object exists client-side. No server-side dict to update in this model.
661
+ pass # No further action needed in main loop for placement event
662
 
663
 
664
  # Define Tabs
 
667
  # --- World Builder Tab ---
668
  with tab_world:
669
  st.header("Shared 3D World")
670
+ st.caption("Place objects using sidebar tools. Use Sidebar to Save/Load.")
671
  current_file_basename = st.session_state.get('current_world_file', None)
672
  if current_file_basename:
673
  full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
674
  if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
675
+ else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None # Clear if missing
676
+ else: st.info("Live State Active (Save to persist changes)")
677
 
678
  # Embed HTML Component
679
  html_file_path = 'index.html'
680
  try:
681
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
682
+ # Inject state needed by JS (Tool, Constants)
683
+ # The initial world state is loaded via explicit JS call if world_to_load_data exists,
684
+ # OR on first load if nothing else triggered a load. We need to send *something* initially.
685
+ initial_world_data = {} # Default to empty
686
+ if 'world_to_load_data' in st.session_state and st.session_state.world_to_load_data is not None:
687
+ # This should have been popped and sent via JS call, but as fallback:
688
+ initial_world_data = st.session_state.world_to_load_data
689
+ print("Warning: Sending world data via injection as fallback.")
690
+ elif st.session_state.get('current_world_file'):
691
+ # If a file is loaded but data wasn't popped, try loading it again for injection
692
+ loaded_dict = load_world_from_md(st.session_state.current_world_file)
693
+ if loaded_dict: initial_world_data = loaded_dict
694
+
695
  js_injection_script = f"""<script>
696
  window.USERNAME = {json.dumps(st.session_state.username)};
697
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
698
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
699
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
700
+ // Send current state for initial draw IF NOT handled by explicit loadWorldState call
701
+ window.INITIAL_WORLD_OBJECTS = {json.dumps(initial_world_data)};
702
+ console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjects: {len(initial_world_data)} }});
703
  </script>"""
704
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
705
  components.html(html_content_with_state, height=700, scrolling=False)
 
722
 
723
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
724
  send_button_clicked = st.button("Send Chat", key="send_chat_button", on_click=clear_chat_input_callback)
 
725
 
726
+ if send_button_clicked: # Process only on button click
727
  message_to_send = message_value # Value before potential clear by callback
728
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
729
  st.session_state.last_message = message_to_send
 
732
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
733
  # Rerun is handled implicitly by button click + callback
734
  elif send_button_clicked: st.toast("Message empty or same as last.")
735
+ # Removed autosend checkbox for simplicity
736
 
737
  # --- PDF Tab ---
738
  with tab_pdf:
 
750
  st.subheader("💾 World Management")
751
  current_file_basename = st.session_state.get('current_world_file', None)
752
 
753
+ # Save Current Version Button (if a world is loaded)
754
  if current_file_basename:
755
  full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
756
  save_label = f"Save Changes to '{current_file_basename}'"
 
771
  else: st.warning("Did not receive world state from client.")
772
  else: st.info("Load a world or use 'Save As New Version' below.")
773
 
774
+ # Save As New Version Section
775
  st.subheader("Save As New Version")
776
  new_name_files = st.text_input("World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
777
  if st.button("💾 Save Current View as New Version", key="save_new_version_files"):
 
794
  else: st.warning("Did not receive world state from client.")
795
  else: st.warning("Please enter a name.")
796
 
797
+ # --- Removed Server Status Section ---
798
 
799
  st.subheader("🗑️ Delete Files")
800
  st.warning("Deletion is permanent!", icon="⚠️")
 
811
  st.subheader("📦 Download Archives")
812
  col_zip1, col_zip2, col_zip3 = st.columns(3)
813
  with col_zip1:
814
+ # Corrected path for zipping worlds
815
+ if st.button("Zip Worlds"): create_zip_of_files(glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")), "Worlds")
816
  with col_zip2:
817
  if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
818
  with col_zip3:
 
822
  st.caption("Existing Zip Files:")
823
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
824
  else:
825
+ st.caption("No zip archives found.") # Correct indentation
826
 
827
 
828
  # ==============================================================================
 
830
  # ==============================================================================
831
 
832
  def initialize_app():
833
+ """Handles session init and initial world load setup."""
834
  init_session_state()
835
  # Load username
836
  if not st.session_state.username:
 
838
  if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
839
  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)
840
 
841
+ # Set up initial world state to load IF this is the first run AND no specific load is already pending
842
+ if 'world_to_load_data' not in st.session_state or st.session_state.world_to_load_data is None:
843
+ if st.session_state.get('current_world_file') is None: # Only load initially if no world is 'active'
844
+ print("Attempting initial load of most recent world...")
845
+ saved_worlds = get_saved_worlds()
846
+ if saved_worlds:
847
+ latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
848
+ print(f"Queueing most recent world for load: {latest_world_file_basename}")
849
+ world_dict = load_world_from_md(latest_world_file_basename)
850
+ if world_dict is not None:
851
+ st.session_state.world_to_load_data = world_dict # Queue data to be sent to JS
852
+ st.session_state.current_world_file = latest_world_file_basename # Set as current
853
+ else: print("Failed to load most recent world for initial state.")
854
+ else:
855
+ print("No saved worlds found, starting empty.");
856
+ st.session_state.world_to_load_data = {} # Send empty state to JS initially
857
 
858
 
859
  if __name__ == "__main__":
860
+ initialize_app() # Initialize state, user, queue initial world load data
861
  render_sidebar() # Render sidebar UI (includes load buttons)
862
+ render_main_content() # Render main UI (includes logic to send queued world data to JS)