awacke1 commited on
Commit
4b8785f
Β·
verified Β·
1 Parent(s): 576ecc1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +521 -504
app.py CHANGED
@@ -1,6 +1,7 @@
1
- # app.py (Full Code - Fixes Applied, Single Statements per Line)
2
  import streamlit as st
3
  import asyncio
 
4
  import uuid
5
  from datetime import datetime
6
  import os
@@ -21,28 +22,19 @@ import threading
21
  import json
22
  import zipfile
23
  from dotenv import load_dotenv
24
- # from streamlit_marquee import streamlit_marquee # Import if needed
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
31
  # ==============================================================================
32
-
33
- # πŸ› οΈ Patch asyncio for nesting
34
  nest_asyncio.apply()
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"
@@ -56,7 +48,7 @@ SAVED_WORLDS_DIR = "saved_worlds"
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
@@ -64,13 +56,11 @@ FUN_USERNAMES = {
64
  "BuilderBot πŸ€–": "en-US-AriaNeural", "WorldWeaver πŸ•ΈοΈ": "en-US-JennyNeural",
65
  "Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
66
  "PixelPainter 🎨": "en-CA-ClaraNeural", "VoxelVortex πŸŒͺ️": "en-US-GuyNeural",
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 = {
@@ -82,110 +72,130 @@ PRIMITIVE_MAP = {
82
  TOOLS_MAP = {"None": "🚫"}
83
  TOOLS_MAP.update({name: emoji for emoji, name in PRIMITIVE_MAP.items()})
84
 
85
- # --- Ensure Directories Exist ---
86
  for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
87
  os.makedirs(d, exist_ok=True)
88
-
89
- # --- API Keys (Placeholder) ---
90
  load_dotenv()
91
 
92
- # --- Lock for Action Log (Session State is generally per-session, but use if needed) ---
93
- # action_log_lock = threading.Lock() # Usually not needed for session_state modifications
 
94
 
95
  # ==============================================================================
96
- # Utility Functions
97
  # ==============================================================================
98
-
99
  def get_current_time_str(tz='UTC'):
100
- """Gets formatted timestamp string in specified timezone (default UTC)."""
101
- try:
102
- timezone = pytz.timezone(tz)
103
- now_aware = datetime.now(timezone)
104
- except pytz.UnknownTimeZoneError:
105
- now_aware = datetime.now(pytz.utc)
106
- except Exception as e:
107
- print(f"Timezone error ({tz}), using UTC. Error: {e}")
108
- now_aware = datetime.now(pytz.utc)
109
  return now_aware.strftime('%Y%m%d_%H%M%S')
110
 
111
  def clean_filename_part(text, max_len=25):
112
- """Cleans a string part for use in a filename."""
113
  if not isinstance(text, str): text = "invalid_name"
114
- text = re.sub(r'\s+', '_', text)
115
- text = re.sub(r'[^\w\-.]', '', text)
116
  return text[:max_len]
117
 
118
  def run_async(async_func, *args, **kwargs):
119
  """Runs an async function safely from a sync context using create_task or asyncio.run."""
120
- # This helper attempts to schedule the async function as a background task.
121
- # Note: Background tasks in Streamlit might have limitations accessing session state later.
122
- try:
123
- loop = asyncio.get_running_loop()
124
- # Create task to run concurrently
125
- return loop.create_task(async_func(*args, **kwargs))
126
- except RuntimeError: # No running loop in this thread
127
- # Fallback: Run in a new loop (blocks until completion)
128
- # print(f"Warning: Running async func {async_func.__name__} in new event loop.")
129
- try:
130
- return asyncio.run(async_func(*args, **kwargs))
131
- except Exception as e:
132
- print(f"Error running async func {async_func.__name__} in new loop: {e}")
133
- return None
134
- except Exception as e:
135
- print(f"Error scheduling async task {async_func.__name__}: {e}")
136
- return None
137
-
138
- def ensure_dir(dir_path):
139
- """Creates directory if it doesn't exist."""
140
- os.makedirs(dir_path, exist_ok=True)
141
 
142
  # ==============================================================================
143
- # World State File Handling (Markdown + JSON)
144
  # ==============================================================================
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def generate_world_save_filename(username="User", world_name="World"):
147
- """Generates a filename for saving world state MD files."""
148
- timestamp = get_current_time_str()
149
- clean_user = clean_filename_part(username, 15)
150
- clean_world = clean_filename_part(world_name, 20)
151
- rand_hash = hashlib.md5(str(time.time()).encode() + username.encode() + world_name.encode()).hexdigest()[:4]
152
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
153
 
154
  def parse_world_filename(filename):
155
- """Extracts info from filename if possible, otherwise returns defaults."""
156
  basename = os.path.basename(filename)
157
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
158
- core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
159
- parts = core.split('_')
160
  if len(parts) >= 5 and parts[-3] == "by":
161
- timestamp_str = parts[-2]
162
- username = parts[-4]
163
- world_name = " ".join(parts[:-4])
164
- dt_obj = None
165
- try:
166
- dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
167
- except Exception:
168
- dt_obj = None
169
  return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
170
-
171
  # Fallback
172
- dt_fallback = None
173
- try:
174
- mtime = os.path.getmtime(filename)
175
- dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
176
- except Exception:
177
- pass
178
  return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
179
 
180
 
181
- def save_world_to_md(target_filename_base, world_data_dict):
182
- """Saves the provided world state dictionary to a specific MD file."""
 
183
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
184
- print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
185
  success = False
186
- parsed_info = parse_world_filename(save_path)
187
- timestamp_save = get_current_time_str()
188
- md_content = f"""# World State: {parsed_info['name']} by {parsed_info['user']}
 
 
 
189
  * **File Saved:** {timestamp_save} (UTC)
190
  * **Source Timestamp:** {parsed_info['timestamp']}
191
  * **Objects:** {len(world_data_dict)}
@@ -193,42 +203,39 @@ def save_world_to_md(target_filename_base, world_data_dict):
193
  ```json
194
  {json.dumps(world_data_dict, indent=2)}
195
  ```"""
196
- try:
197
- ensure_dir(SAVED_WORLDS_DIR);
198
- with open(save_path, 'w', encoding='utf-8') as f:
199
- f.write(md_content)
200
- print(f"World state saved successfully to {target_filename_base}")
201
- success = True
202
- except Exception as e:
203
- print(f"Error saving world state to {save_path}: {e}")
204
  return success
205
 
206
-
207
- def load_world_from_md(filename_base):
208
- """Loads world state dict from an MD file (basename), returns dict or None."""
209
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
210
- print(f"Loading world state dictionary from MD file: {load_path}...")
211
- if not os.path.exists(load_path):
212
- st.error(f"World file not found: {filename_base}")
213
- return None
214
  try:
215
- with open(load_path, 'r', encoding='utf-8') as f:
216
- content = f.read()
217
- # Find JSON block more robustly
218
  json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE)
219
- if not json_match:
220
- st.error(f"Could not find valid JSON block in {filename_base}")
221
- return None
222
  world_data_dict = json.loads(json_match.group(1))
223
- print(f"Parsed {len(world_data_dict)} objects from {filename_base}.")
224
- return world_data_dict # Return the dictionary
225
- except json.JSONDecodeError as e:
226
- st.error(f"Invalid JSON found in {filename_base}: {e}")
227
- return None
228
- except Exception as e:
229
- st.error(f"Error loading world state from {filename_base}: {e}")
230
- st.exception(e)
231
- return None
 
 
 
 
 
232
 
233
  def get_saved_worlds():
234
  """Scans the saved worlds directory for world MD files and parses them."""
@@ -236,13 +243,9 @@ def get_saved_worlds():
236
  ensure_dir(SAVED_WORLDS_DIR);
237
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
238
  parsed_worlds = [parse_world_filename(f) for f in world_files]
239
- # Sort by datetime object (newest first), handle None dt values
240
  parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
241
  return parsed_worlds
242
- except Exception as e:
243
- print(f"Error scanning saved worlds: {e}")
244
- st.error(f"Could not scan saved worlds: {e}")
245
- return []
246
 
247
  # ==============================================================================
248
  # User State & Session Init
@@ -250,39 +253,37 @@ def get_saved_worlds():
250
  def save_username(username):
251
  try:
252
  with open(STATE_FILE, 'w') as f: f.write(username)
253
- except Exception as e: print(f"Failed save username: {e}")
254
 
255
  def load_username():
256
  if os.path.exists(STATE_FILE):
257
  try:
258
  with open(STATE_FILE, 'r') as f: return f.read().strip()
259
- except Exception as e: print(f"Failed load username: {e}")
260
  return None
261
 
262
  def init_session_state():
263
  """Initializes Streamlit session state variables."""
264
  defaults = {
 
 
265
  'last_chat_update': 0, 'message_input': "", 'audio_cache': {},
266
  'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [], 'enable_audio': True,
267
  'download_link_cache': {}, 'username': None, 'autosend': False,
268
  'last_message': "",
269
  'selected_object': 'None',
 
270
  'current_world_file': None, # Track loaded world filename (basename)
271
- 'new_world_name': "MyWorld",
272
  'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
273
- 'world_to_load_data': None, # Temp storage for state loaded from file
274
- 'js_object_placed_data': None # Temp storage for data coming from JS place event
275
  }
276
  for k, v in defaults.items():
277
  if k not in st.session_state:
278
- # Use copy for mutable defaults like deque to avoid shared reference issue
279
- if isinstance(v, deque):
280
- st.session_state[k] = v.copy()
281
- elif isinstance(v, (dict, list)): # Also copy dicts/lists if needed
282
- st.session_state[k] = v.copy()
283
- else:
284
- st.session_state[k] = v
285
- # Ensure complex types are correctly initialized if session reloads partially
286
  if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
287
  if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
288
  if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
@@ -291,37 +292,12 @@ def init_session_state():
291
  # ==============================================================================
292
  # Action Log Helper
293
  # ==============================================================================
294
- def add_action_log(message):
295
- """Adds a message to the session's action log."""
296
- if 'action_log' not in st.session_state:
297
  st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
298
  timestamp = datetime.now().strftime("%H:%M:%S")
299
- # Prepend so newest is at top
300
- st.session_state.action_log.appendleft(f"[{timestamp}] {message}")
301
-
302
- # ==============================================================================
303
- # JS Communication Handler Function
304
- # ==============================================================================
305
- # This function needs to be defined globally for streamlit_js_eval to find it by name
306
- def handle_js_object_placed(data):
307
- """Callback triggered by JS when an object is placed. Stores data in state."""
308
- print(f"Python received object placed event data: {type(data)}")
309
- processed_data = None
310
- # Logic assumes streamlit_js_eval passes the JS object directly as Python dict/list
311
- if isinstance(data, dict):
312
- processed_data = data
313
- elif isinstance(data, str): # Fallback if it comes as JSON string
314
- try: processed_data = json.loads(data)
315
- except json.JSONDecodeError: print("Failed decode JSON from JS object place event."); return False
316
- else: print(f"Received unexpected data type from JS place event: {type(data)}"); return False
317
-
318
- if processed_data and 'obj_id' in processed_data and 'type' in processed_data:
319
- st.session_state.js_object_placed_data = processed_data # Store for main loop processing
320
- add_action_log(f"Placed {processed_data.get('type', 'object')} ({processed_data.get('obj_id', 'N/A')[:6]}...)")
321
- # Return value isn't used by the JS call, but good practice
322
- return True
323
- else: print("Received invalid object placement data structure from JS."); return False
324
-
325
 
326
  # ==============================================================================
327
  # Audio / TTS / Chat / File Handling Helpers (Keep implementations)
@@ -331,14 +307,12 @@ def clean_text_for_tts(text):
331
  if not isinstance(text, str): return "No text"
332
  text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
333
  text = ' '.join(text.split()); return text[:250] or "No text"
334
-
335
  def create_file(content, username, file_type="md", save_path=None):
336
  if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
337
  ensure_dir(os.path.dirname(save_path))
338
  try:
339
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
340
- except Exception as e: print(f"Error creating file {save_path}: {e}"); return None
341
-
342
  def get_download_link(file_path, file_type="md"):
343
  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>"
344
  try: mtime = os.path.getmtime(file_path)
@@ -352,9 +326,8 @@ def get_download_link(file_path, file_type="md"):
352
  basename = os.path.basename(file_path)
353
  link_html = f'<a href="data:{mime_types.get(file_type, "application/octet-stream")};base64,{b64}" download="{basename}" title="Download {basename}">{FILE_EMOJIS.get(file_type, "πŸ“„")}</a>'
354
  st.session_state.download_link_cache[cache_key] = link_html
355
- except Exception as e: print(f"Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
356
  return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
357
-
358
  # --- Audio / TTS ---
359
  async def async_edge_tts_generate(text, voice, username):
360
  if not text: return None
@@ -369,17 +342,15 @@ async def async_edge_tts_generate(text, voice, username):
369
  try:
370
  communicate = edge_tts.Communicate(text_cleaned, voice); await communicate.save(save_path);
371
  if os.path.exists(save_path) and os.path.getsize(save_path) > 0: st.session_state.audio_cache[cache_key] = save_path; return save_path
372
- else: print(f"Audio file {save_path} failed generation."); return None
373
- except Exception as e: print(f"Edge TTS Error: {e}"); return None
374
-
375
  def play_and_download_audio(file_path):
376
  if file_path and os.path.exists(file_path):
377
  try:
378
  st.audio(file_path)
379
  file_type = file_path.split('.')[-1]
380
  st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
381
- except Exception as e: st.error(f"Audio display error for {os.path.basename(file_path)}: {e}")
382
-
383
  # --- Chat ---
384
  async def save_chat_entry(username, message, voice, is_markdown=False):
385
  if not message.strip(): return None, None
@@ -394,95 +365,87 @@ async def save_chat_entry(username, message, voice, is_markdown=False):
394
  tts_message = message
395
  audio_file = await async_edge_tts_generate(tts_message, voice, username)
396
  return md_file, audio_file
397
-
398
  async def load_chat_history():
399
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
400
  if not st.session_state.chat_history:
401
- ensure_dir(CHAT_DIR); print("Loading chat history from files...")
402
  chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime); loaded_count = 0
403
  temp_history = []
404
  for f_path in chat_files:
405
  try:
406
  with open(f_path, 'r', encoding='utf-8') as file: temp_history.append(file.read().strip()); loaded_count += 1
407
- except Exception as e: print(f"Err read chat {f_path}: {e}")
408
  st.session_state.chat_history = temp_history
409
- print(f"Loaded {loaded_count} chat entries from files.")
410
  return st.session_state.chat_history
411
-
412
  # --- File Management ---
413
  def create_zip_of_files(files_to_zip, prefix="Archive"):
414
- if not files_to_zip: st.warning("No files provided to zip."); return None
415
  timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
416
  try:
417
- print(f"Creating zip: {zip_name}...");
418
  with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as z:
419
  for f in files_to_zip:
420
  if os.path.exists(f): z.write(f, os.path.basename(f))
421
- else: print(f"Skip zip missing: {f}")
422
- print("Zip success."); st.success(f"Created {zip_name}"); return zip_name
423
- except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
424
-
425
  def delete_files(file_patterns, exclude_files=None):
426
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
427
  current_world_base = st.session_state.get('current_world_file')
428
- if current_world_base: protected.append(current_world_base)
429
  if exclude_files: protected.extend(exclude_files)
430
  deleted_count = 0; errors = 0
431
  for pattern in file_patterns:
432
  pattern_path = pattern
433
- print(f"Attempting to delete files matching: {pattern_path}")
434
  try:
435
  files_to_delete = glob.glob(pattern_path)
436
- if not files_to_delete: print(f"No files found for pattern: {pattern}"); continue
437
  for f_path in files_to_delete:
438
  basename = os.path.basename(f_path)
439
  if os.path.isfile(f_path) and basename not in protected:
440
- try: os.remove(f_path); print(f"Deleted: {f_path}"); deleted_count += 1
441
- except Exception as e: print(f"Failed delete {f_path}: {e}"); errors += 1
442
- elif os.path.isdir(f_path): print(f"Skipping directory: {f_path}")
443
- except Exception as glob_e: print(f"Error matching pattern {pattern}: {glob_e}"); errors += 1
444
- msg = f"Deleted {deleted_count} files.";
445
  if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
446
  elif deleted_count > 0: st.success(msg)
447
- else: st.info("No matching files found to delete.")
448
  st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
449
-
450
  # --- Image Handling ---
451
  async def save_pasted_image(image, username):
452
  if not image: return None
453
- try:
454
- 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)
455
- image.save(filepath, "PNG"); print(f"Pasted image saved: {filepath}"); return filepath
456
- except Exception as e: print(f"Failed image save: {e}"); return None
457
-
458
  def paste_image_component():
459
  pasted_img = None; img_type = None
460
- # Simplified paste component logic
461
- paste_input_value = st.text_area("Paste Image Data Here", key="paste_input_area", height=50)
462
- if st.button("Process Pasted Image πŸ“‹", key="process_paste_button"):
463
  if paste_input_value and paste_input_value.startswith('data:image'):
464
  try:
465
  mime_type = paste_input_value.split(';')[0].split(':')[1]; base64_str = paste_input_value.split(',')[1]; img_bytes = base64.b64decode(base64_str); pasted_img = Image.open(io.BytesIO(img_bytes)); img_type = mime_type.split('/')[1]
466
- st.image(pasted_img, caption=f"Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str # Store processed base64
467
- # Clear input area state for next run - using callback is better if possible
468
- # st.session_state.paste_input_area = "" # Direct modification fails
469
- st.rerun() # Rerun necessary to potentially process image
470
- except ImportError: st.error("Pillow library needed.")
471
- except Exception as e: st.error(f"Img decode err: {e}"); st.session_state.paste_image_base64 = ""
472
- else: st.warning("No valid image data pasted."); st.session_state.paste_image_base64 = ""
473
- # Return the image if successfully processed in *this* run after button press
474
- # This is tricky due to rerun, might need state flag
475
- return pasted_img
476
-
477
-
478
  # --- PDF Processing ---
479
- class AudioProcessor:
480
  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 {}
481
  def _save_metadata(self):
482
  try:
483
  with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f, indent=2)
484
- except Exception as e: print(f"Failed metadata save: {e}")
485
- async def create_audio(self, text, voice='en-US-AriaNeural'):
486
  cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=os.path.join(self.cache_dir, f"{cache_key}.mp3");
487
  if cache_key in self.metadata and os.path.exists(cache_path): return cache_path
488
  text_cleaned=clean_text_for_tts(text);
@@ -492,86 +455,185 @@ class AudioProcessor:
492
  communicate=edge_tts.Communicate(text_cleaned,voice); await communicate.save(cache_path)
493
  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
494
  else: return None
495
- except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
496
 
497
- def process_pdf_tab(pdf_file, max_pages, voice):
498
- st.subheader("PDF Processing Results")
499
- if pdf_file is None:
500
- st.info("Upload a PDF file and click 'Process PDF' to begin.")
501
- return
502
  audio_processor = AudioProcessor()
503
  try:
504
- reader=PdfReader(pdf_file)
505
- if reader.is_encrypted:
506
- st.warning("PDF is encrypted.")
507
- return
508
- total_pages_in_pdf = len(reader.pages)
509
- pages_to_process = min(total_pages_in_pdf, max_pages);
510
- st.write(f"Processing first {pages_to_process} of {total_pages_in_pdf} pages from '{pdf_file.name}'...")
511
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
512
 
513
  def process_page_sync(page_num, page_text):
514
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
515
  try:
516
- audio_path = asyncio.run(run_async_audio()) # asyncio.run is simpler here
517
- if audio_path:
518
- with results_lock: audios[page_num] = audio_path
519
- except Exception as page_e:
520
- print(f"Err process page {page_num+1}: {page_e}")
521
 
522
  # Start threads
523
  for i in range(pages_to_process):
524
- try:
525
  page = reader.pages[i]
526
- text = page.extract_text()
527
- if text and text.strip():
528
- texts[i]=text
529
- thread = threading.Thread(target=process_page_sync, args=(i, text))
530
- page_threads.append(thread)
531
- thread.start()
532
- else:
533
- texts[i] = "[No text extracted or page empty]"
534
- print(f"Page {i+1}: No text extracted.")
 
535
  except Exception as extract_e:
536
- texts[i] = f"[Error extracting text: {extract_e}]"
537
- print(f"Error page {i+1} extract: {extract_e}")
538
 
539
  # Wait for threads and display progress
540
- progress_bar = st.progress(0.0, text="Processing pages...")
541
- total_threads = len(page_threads)
542
- start_join_time = time.time()
543
  while any(t.is_alive() for t in page_threads):
544
- completed_threads = total_threads - sum(t.is_alive() for t in page_threads)
545
- progress = completed_threads / total_threads if total_threads > 0 else 1.0
546
- progress_bar.progress(min(progress, 1.0), text=f"Processed {completed_threads}/{total_threads} pages...")
547
- if time.time() - start_join_time > 600: # 10 min timeout
548
- print("PDF processing timed out.")
549
- st.warning("Processing timed out.")
550
- break
551
- time.sleep(0.5) # Avoid busy-waiting
552
- progress_bar.progress(1.0, text="Processing complete.")
553
 
554
  # Display results
555
- st.write("Displaying results:")
556
  for i in range(pages_to_process):
557
  with st.expander(f"Page {i+1}"):
558
- st.markdown(texts.get(i, "[Error getting text]"))
559
- audio_file = audios.get(i) # Get result from shared dict
560
- if audio_file:
561
- play_and_download_audio(audio_file)
562
  else:
563
- # Check if text existed to differentiate between skipped vs failed
564
  page_text = texts.get(i,"")
565
- if page_text.strip() and page_text != "[No text extracted or page empty]" and not page_text.startswith("[Error"):
566
- st.caption("Audio generation failed or timed out.")
567
- #else: # No text or error extracting text
568
- # st.caption("No text to generate audio from.") # Implicit
569
 
570
- except ImportError:
571
- st.error("PyPDF2 library needed for PDF processing.")
572
- except Exception as pdf_e:
573
- st.error(f"Error reading PDF '{pdf_file.name}': {pdf_e}");
574
- st.exception(pdf_e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
 
576
 
577
  # ==============================================================================
@@ -581,188 +643,190 @@ def process_pdf_tab(pdf_file, max_pages, voice):
581
  def render_sidebar():
582
  """Renders the Streamlit sidebar contents."""
583
  with st.sidebar:
584
- st.header("πŸ’Ύ World Management")
 
 
585
 
586
- # --- World Save ---
587
  current_file = st.session_state.get('current_world_file')
588
- current_world_name = "Live State"
589
- default_save_name = st.session_state.get('new_world_name', 'MyWorld')
590
- if current_file:
591
- parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
592
- current_world_name = parsed.get("name", current_file)
593
- default_save_name = current_world_name # Default to overwriting current name
594
-
595
- world_save_name = st.text_input(
596
- "World Name for Save:",
597
- key="world_save_name_input",
598
- value=default_save_name,
599
- help="Enter name to save as new, or keep current name to overwrite."
600
- )
601
 
602
- if st.button("πŸ’Ύ Save Current World View", key="sidebar_save_world"):
603
- if not world_save_name.strip():
604
- st.warning("Please enter a World Name.")
605
  else:
606
- with st.spinner("Requesting world state & saving..."):
607
- js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_sidebar_save", want_result=True)
608
- if js_world_state_str:
609
- try:
610
- world_data_dict = json.loads(js_world_state_str)
611
- if isinstance(world_data_dict, dict):
612
- filename_to_save = ""
613
- is_overwrite = False
614
- if current_file:
615
- parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
616
- # Check if input name matches the name part of the current file
617
- if world_save_name == parsed_current.get('name', ''):
618
- filename_to_save = current_file # Use existing basename
619
- is_overwrite = True
620
-
621
- if not filename_to_save: # Create new filename if not overwriting
622
- filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
623
-
624
- if save_world_to_md(filename_to_save, world_data_dict):
625
- action = "Overwritten" if is_overwrite else "Saved new"
626
- st.success(f"World {action}: {filename_to_save}")
627
- add_action_log(f"Saved world: {filename_to_save}")
628
- st.session_state.current_world_file = filename_to_save # Track saved file
629
- st.session_state.new_world_name = "MyWorld" # Reset default
630
- st.rerun() # Refresh sidebar list
631
- else: st.error("Failed to save world state to file.")
632
- else: st.error("Invalid state format received from client.")
633
- except json.JSONDecodeError: st.error("Failed to decode state from client.")
634
- except Exception as e: st.error(f"Save error: {e}")
635
- else: st.warning("Did not receive world state from client.")
636
-
637
- # --- World Load ---
638
  st.markdown("---")
639
- st.header("πŸ“‚ Load World")
 
640
  saved_worlds = get_saved_worlds()
641
 
642
- if not saved_worlds: st.caption("No saved worlds found.")
643
  else:
644
- st.caption("Click button to load state.")
645
- cols_header = st.columns([4, 1, 1]) # Adjusted column ratio
646
- with cols_header[0]: st.write("**Name** (User, Time)")
647
- with cols_header[1]: st.write("**Load**")
648
- with cols_header[2]: st.write("**DL**")
649
-
650
- # Simple list without expander for now
651
- for world_info in saved_worlds:
652
- f_basename = os.path.basename(world_info['filename'])
653
- f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename)
654
- display_name = world_info.get('name', f_basename); user = world_info.get('user', 'N/A'); timestamp = world_info.get('timestamp', 'N/A')
655
- display_text = f"{display_name} ({user}, {timestamp})"
656
-
657
- col1, col2, col3 = st.columns([4, 1, 1])
658
- with col1: st.write(f"<small>{display_text}</small>", unsafe_allow_html=True)
659
- with col2:
660
- is_current = (st.session_state.get('current_world_file') == f_basename)
661
- btn_load = st.button("Load", key=f"load_{f_basename}", help=f"Load {f_basename}", disabled=is_current)
662
- with col3: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
663
-
664
- if btn_load: # Handle click if not disabled
665
- print(f"Load button clicked for: {f_basename}")
666
- world_dict = load_world_from_md(f_basename)
667
- if world_dict is not None:
668
- st.session_state.world_to_load_data = world_dict # Queue data for JS
669
- st.session_state.current_world_file = f_basename
670
- add_action_log(f"Loading world: {f_basename}")
671
- st.rerun() # Trigger rerun to send data via injection/call
672
- else: st.error(f"Failed to parse world file: {f_basename}")
673
-
674
-
675
- # --- Build Tools ---
676
  st.markdown("---")
677
- st.header("πŸ—οΈ Build Tools")
678
- st.caption("Select an object to place.")
679
  tool_options = list(TOOLS_MAP.keys())
680
  current_tool_name = st.session_state.get('selected_object', 'None')
681
  try: tool_index = tool_options.index(current_tool_name)
682
  except ValueError: tool_index = 0
683
 
684
- # Use columns for horizontal layout feel for Radio buttons
685
- cols_tools = st.columns(len(tool_options))
686
  selected_tool = st.radio(
687
  "Select Tool:", options=tool_options, index=tool_index,
688
  format_func=lambda name: f"{TOOLS_MAP.get(name, '')} {name}",
689
- key="tool_selector_radio", horizontal=True, label_visibility="collapsed" # Hide label, use header
690
  )
691
-
692
  if selected_tool != current_tool_name:
693
  st.session_state.selected_object = selected_tool
694
- add_action_log(f"Selected tool: {selected_tool}")
695
- try: # Use streamlit_js_eval, not sync
696
- streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
697
- except Exception as e: print(f"JS tool update error: {e}")
698
  st.rerun()
699
 
700
- # --- Action Log ---
 
701
  st.markdown("---")
702
- st.header("πŸ“ Action Log")
 
703
  log_container = st.container(height=200)
704
  with log_container:
705
  log_entries = st.session_state.get('action_log', [])
706
  if log_entries: st.code('\n'.join(log_entries), language="log")
707
- else: st.caption("No actions recorded yet.")
 
708
 
709
- # --- Voice/User ---
710
  st.markdown("---")
711
- st.header("πŸ—£οΈ Voice & User")
 
712
  current_username = st.session_state.get('username', "DefaultUser")
713
  username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
714
  current_index = 0;
715
- if current_username in username_options: try: current_index = username_options.index(current_username)
716
- except ValueError: pass
 
 
717
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
718
- if new_username != st.session_state.username:
 
 
 
719
  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)
720
- add_action_log(f"Username changed to {new_username}")
721
  st.rerun()
722
- st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
723
 
724
 
725
  def render_main_content():
726
  """Renders the main content area with tabs."""
727
  st.title(f"{Site_Name} - User: {st.session_state.username}")
728
 
729
- # Check if world data needs to be sent to JS
730
  world_data_to_load = st.session_state.pop('world_to_load_data', None)
731
  if world_data_to_load is not None:
732
- print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
733
  try:
734
  streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
735
- st.toast("World loaded in 3D view.", icon="πŸ”„")
736
- except Exception as e:
737
- st.error(f"Failed to send loaded world state to JS: {e}")
738
 
739
- # Set up the mechanism for JS to call Python when an object is placed
740
- # This defines the JS function `window.sendPlacedObjectToPython`
741
  streamlit_js_eval(
742
  js_code="""
743
- if (!window.sendPlacedObjectToPython) {
744
- console.log('Defining sendPlacedObjectToPython for JS->Python comms...');
 
745
  window.sendPlacedObjectToPython = (objectData) => {
746
  console.log('JS sending placed object:', objectData);
747
- // Call Python function handle_js_object_placed, passing data directly
748
  streamlit_js_eval(python_code='handle_js_object_placed(data=' + JSON.stringify(objectData) + ')', key='js_place_event_handler');
749
- }
750
- }
 
 
 
 
 
751
  """,
752
- key="setup_js_place_event_handler" # Key for the setup code itself
753
  )
754
 
755
- # Check if the Python handler function was triggered in the previous interaction
 
756
  if 'js_place_event_handler' in st.session_state:
757
- # The handle_js_object_placed function should have stored data in this key
758
  placed_data = st.session_state.pop('js_object_placed_data', None)
759
- if placed_data:
760
- print(f"Python processed stored placed object data: {placed_data.get('obj_id')}")
761
- # Action log already added in handle_js_object_placed.
762
- # No server-side dict to update, client manages its state until save.
763
- pass
764
- # Remove the trigger key itself to prevent re-processing
765
- del st.session_state['js_place_event_handler']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
 
767
 
768
  # Define Tabs
@@ -770,142 +834,100 @@ def render_main_content():
770
 
771
  # --- World Builder Tab ---
772
  with tab_world:
773
- st.header("Shared 3D World")
774
- st.caption("Place objects using sidebar tools. Use Sidebar to Save/Load.")
775
  current_file_basename = st.session_state.get('current_world_file', None)
776
  if current_file_basename:
777
  full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
778
- if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
779
- else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
780
- else: st.info("Live State Active (Save to persist)")
781
 
782
  # Embed HTML Component
783
  html_file_path = 'index.html'
784
  try:
785
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
786
  # Inject state needed by JS
787
- # Load initial data for injection *only if* no specific load is pending
788
- initial_world_data = {}
789
- if world_data_to_load is None: # Check if data was *not* popped above
790
- if st.session_state.get('current_world_file'):
791
- loaded_dict = load_world_from_md(st.session_state.current_world_file)
792
- if loaded_dict: initial_world_data = loaded_dict
793
- # If current_world_file is None AND world_data_to_load is None, initial_world_data remains {}
794
 
795
  js_injection_script = f"""<script>
796
  window.USERNAME = {json.dumps(st.session_state.username)};
797
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
798
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
799
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
800
- // Send current state ONLY if not handled by explicit loadWorldState call
801
- window.INITIAL_WORLD_OBJECTS = {json.dumps(initial_world_data)};
802
- console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjects: {len(initial_world_data)} }});
803
  </script>"""
804
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
805
  components.html(html_content_with_state, height=700, scrolling=False)
806
- except FileNotFoundError: st.error(f"CRITICAL ERROR: Could not find '{html_file_path}'.")
807
- except Exception as e: st.error(f"Error loading 3D component: {e}"); st.exception(e)
808
 
809
  # --- Chat Tab ---
810
  with tab_chat:
811
- st.header(f"πŸ’¬ Chat")
812
  chat_history_list = st.session_state.get('chat_history', [])
813
  if not chat_history_list: chat_history_list = asyncio.run(load_chat_history())
814
  chat_container = st.container(height=500)
815
  with chat_container:
816
  if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:])))
817
- else: st.caption("No chat messages yet.")
818
 
819
  def clear_chat_input_callback(): st.session_state.message_input = ""
820
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
821
- send_button_clicked = st.button("Send Chat", key="send_chat_button", on_click=clear_chat_input_callback)
822
 
823
  if send_button_clicked:
824
- message_to_send = message_value # Value before potential clear by callback
825
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
826
  st.session_state.last_message = message_to_send
827
  voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
 
828
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
829
- # Rerun is handled implicitly by button click + callback
 
830
  elif send_button_clicked: st.toast("Message empty or same as last.")
831
 
832
  # --- PDF Tab ---
833
  with tab_pdf:
834
- st.header("πŸ“š PDF Tools")
835
- pdf_file = st.file_uploader("Upload PDF for Audio Conversion", type="pdf", key="pdf_upload")
836
- max_pages = st.slider('Max Pages to Process', 1, 50, 10, key="pdf_pages")
 
837
  if pdf_file:
838
- if st.button("Process PDF to Audio", key="process_pdf_button"):
839
- with st.spinner("Processing PDF... This may take time."):
840
  process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice)
841
 
842
  # --- Files & Settings Tab ---
843
  with tab_files:
844
- st.header("πŸ“‚ Files & Settings")
845
- st.subheader("πŸ’Ύ World Management")
846
- current_file_basename = st.session_state.get('current_world_file', None)
847
 
848
- if current_file_basename:
849
- full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
850
- save_label = f"Save Changes to '{current_file_basename}'"
851
- if os.path.exists(full_path_for_parse): parsed = parse_world_filename(full_path_for_parse); save_label = f"Save Changes to '{parsed['name']}'"
852
- if st.button(save_label, key="save_current_world_files", help=f"Overwrite '{current_file_basename}'"):
853
- if not os.path.exists(full_path_for_parse): st.error(f"Cannot save, file missing.")
854
- else:
855
- with st.spinner("Requesting state & saving..."):
856
- js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_save_current", want_result=True)
857
- if js_world_state_str:
858
- try:
859
- world_data_dict = json.loads(js_world_state_str)
860
- if isinstance(world_data_dict, dict):
861
- if save_world_to_md(current_file_basename, world_data_dict): st.success("Current world saved!"); add_action_log(f"Saved world: {current_file_basename}")
862
- else: st.error("Failed to save.")
863
- else: st.error("Invalid format from client.")
864
- except json.JSONDecodeError: st.error("Failed to decode state from client.")
865
- else: st.warning("Did not receive world state from client.")
866
- else: st.info("Load a world or use 'Save As New Version' below.")
867
-
868
- st.subheader("Save As New Version")
869
- new_name_files = st.text_input("World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
870
- if st.button("πŸ’Ύ Save Current View as New Version", key="save_new_version_files"):
871
- if new_name_files.strip():
872
- with st.spinner(f"Requesting state & saving as '{new_name_files}'..."):
873
- js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_save_new", want_result=True)
874
- if js_world_state_str:
875
- try:
876
- world_data_dict = json.loads(js_world_state_str)
877
- if isinstance(world_data_dict, dict):
878
- new_filename_base = generate_world_save_filename(st.session_state.username, new_name_files)
879
- if save_world_to_md(new_filename_base, world_data_dict):
880
- st.success(f"Saved as {new_filename_base}")
881
- st.session_state.current_world_file = new_filename_base; st.session_state.new_world_name = "MyWorld";
882
- add_action_log(f"Saved new world: {new_filename_base}")
883
- st.rerun()
884
- else: st.error("Failed to save new version.")
885
- else: st.error("Invalid format from client.")
886
- except json.JSONDecodeError: st.error("Failed to decode state from client.")
887
- else: st.warning("Did not receive world state from client.")
888
- else: st.warning("Please enter a name.")
889
-
890
- # Removed Server Status Section
891
-
892
- st.subheader("πŸ—‘οΈ Delete Files")
893
  st.warning("Deletion is permanent!", icon="⚠️")
894
  col_del1, col_del2, col_del3, col_del4 = st.columns(4)
895
  with col_del1:
896
- if st.button("πŸ—‘οΈ Chats", key="del_chat_md"): delete_files([os.path.join(CHAT_DIR, "*.md")]); st.session_state.chat_history = []; st.rerun()
897
  with col_del2:
898
- if st.button("πŸ—‘οΈ Audio", key="del_audio_mp3"): delete_files([os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3")]); st.session_state.audio_cache = {}; st.rerun()
899
  with col_del3:
900
- # Corrected delete pattern using prefix
901
- if st.button("πŸ—‘οΈ Worlds", key="del_worlds_md"): delete_files([os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")]); st.session_state.current_world_file = None; st.rerun()
902
  with col_del4:
903
- 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; st.rerun()
904
 
905
  st.subheader("πŸ“¦ Download Archives")
 
906
  col_zip1, col_zip2, col_zip3 = st.columns(3)
907
  with col_zip1:
908
- # Corrected path for zipping worlds
909
  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")
910
  with col_zip2:
911
  if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
@@ -916,9 +938,7 @@ def render_main_content():
916
  st.caption("Existing Zip Files:")
917
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
918
  else:
919
- # Correct indentation confirmed here
920
- st.caption("No zip archives found.")
921
-
922
 
923
  # ==============================================================================
924
  # Main Execution Logic
@@ -933,25 +953,22 @@ def initialize_app():
933
  if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
934
  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)
935
 
936
- # Set up initial world state to load IF this is the first run AND no specific load is already pending
937
- if 'world_to_load_data' not in st.session_state or st.session_state.world_to_load_data is None:
938
- if st.session_state.get('current_world_file') is None: # Only load initially if no world is 'active'
939
- print("Attempting initial load of most recent world...")
940
- saved_worlds = get_saved_worlds()
941
- if saved_worlds:
942
- latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
943
- print(f"Queueing most recent world for load: {latest_world_file_basename}")
944
- world_dict = load_world_from_md(latest_world_file_basename)
945
- if world_dict is not None:
946
- st.session_state.world_to_load_data = world_dict # Queue data to be sent to JS
947
- st.session_state.current_world_file = latest_world_file_basename # Set as current
948
- else: print("Failed to load most recent world.")
949
- else:
950
- print("No saved worlds found, starting empty.");
951
- st.session_state.world_to_load_data = {} # Send empty state to JS initially
952
 
953
 
954
  if __name__ == "__main__":
955
  initialize_app() # Initialize state, user, queue initial world load data
956
- render_sidebar() # Render sidebar UI (includes load buttons)
957
  render_main_content() # Render main UI (includes logic to send queued world data to JS)
 
1
+ # app.py (Re-integrated WebSockets for 3D Sync)
2
  import streamlit as st
3
  import asyncio
4
+ import websockets # Re-added
5
  import uuid
6
  from datetime import datetime
7
  import os
 
22
  import json
23
  import zipfile
24
  from dotenv import load_dotenv
25
+ # from streamlit_marquee import streamlit_marquee
26
  from collections import defaultdict, Counter, deque
27
+ from streamlit_js_eval import streamlit_js_eval # Keep for UI interaction if needed
28
  from PIL import Image
29
 
30
  # ==============================================================================
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 = "."
39
  STATE_FILE = "user_state.txt"
40
  DEFAULT_TTS_VOICE = "en-US-AriaNeural"
 
48
  # World Builder Constants
49
  PLOT_WIDTH = 50.0
50
  PLOT_DEPTH = 50.0
51
+ WORLD_STATE_FILE_MD_PREFIX = "🌌_" # Keep prefix for saved files
52
  MAX_ACTION_LOG_SIZE = 30
53
 
54
  # User/Chat Constants
 
56
  "BuilderBot πŸ€–": "en-US-AriaNeural", "WorldWeaver πŸ•ΈοΈ": "en-US-JennyNeural",
57
  "Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
58
  "PixelPainter 🎨": "en-CA-ClaraNeural", "VoxelVortex πŸŒͺ️": "en-US-GuyNeural",
59
+ } # Simplified list
 
 
60
  EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values()))
61
 
62
  # File Emojis
63
+ FILE_EMOJIS = {"md": "πŸ“œ", "mp3": "🎡", "png": "πŸ–ΌοΈ", "mp4": "πŸŽ₯", "zip": "πŸ“¦", "json": "πŸ“„"}
64
 
65
  # Primitives Map
66
  PRIMITIVE_MAP = {
 
72
  TOOLS_MAP = {"None": "🚫"}
73
  TOOLS_MAP.update({name: emoji for emoji, name in PRIMITIVE_MAP.items()})
74
 
 
75
  for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
76
  os.makedirs(d, exist_ok=True)
 
 
77
  load_dotenv()
78
 
79
+ # --- Global State (WebSocket Client Tracking Only) ---
80
+ 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:
123
+ with open(load_path, 'r', encoding='utf-8') as f: content = f.read()
124
+ json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE)
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
136
+ def get_world_state_manager():
137
+ """
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():
158
+ """Safely gets a copy of the current world state dictionary."""
159
+ manager = get_world_state_manager()
160
+ with manager["lock"]:
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);
168
+ clean_world = clean_filename_part(world_name, 20);
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
  basename = os.path.basename(filename)
174
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
175
+ core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]; parts = core.split('_')
 
176
  if len(parts) >= 5 and parts[-3] == "by":
177
+ timestamp_str = parts[-2]; username = parts[-4]; world_name = " ".join(parts[:-4]); dt_obj = None
178
+ try: dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
179
+ except Exception: dt_obj = None
 
 
 
 
 
180
  return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
 
181
  # Fallback
182
+ dt_fallback = None; try: mtime = os.path.getmtime(filename); dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
183
+ except Exception: pass
 
 
 
 
184
  return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
185
 
186
 
187
+ def save_world_state_to_md(target_filename_base):
188
+ """Saves the current cached world state to a specific MD file."""
189
+ manager = get_world_state_manager() # Get resource
190
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
191
+ print(f"πŸ’Ύ Acquiring lock to save world state to: {save_path}...")
192
  success = False
193
+ with manager["lock"]: # Use resource's lock
194
+ world_data_dict = dict(manager["state"]) # Get copy from resource state
195
+ print(f"πŸ’Ύ Saving {len(world_data_dict)} objects...")
196
+ parsed_info = parse_world_filename(save_path)
197
+ timestamp_save = get_current_time_str()
198
+ md_content = f"""# World State: {parsed_info['name']} by {parsed_info['user']}
199
  * **File Saved:** {timestamp_save} (UTC)
200
  * **Source Timestamp:** {parsed_info['timestamp']}
201
  * **Objects:** {len(world_data_dict)}
 
203
  ```json
204
  {json.dumps(world_data_dict, indent=2)}
205
  ```"""
206
+ try:
207
+ ensure_dir(SAVED_WORLDS_DIR);
208
+ with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
209
+ print(f"βœ… World state saved successfully to {target_filename_base}")
210
+ success = True
211
+ except Exception as e: print(f"❌ Error saving world state to {save_path}: {e}")
 
 
212
  return success
213
 
214
+ def load_world_state_from_md(filename_base):
215
+ """Loads world state from MD, updates cached state, returns success bool."""
216
+ manager = get_world_state_manager() # Get resource
217
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
218
+ print(f"πŸ“œ Loading world state from MD file: {load_path}...")
219
+ if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return False
 
 
220
  try:
221
+ with open(load_path, 'r', encoding='utf-8') as f: content = f.read()
 
 
222
  json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE)
223
+ if not json_match: st.error(f"Could not find JSON block in {filename_base}"); return False
 
 
224
  world_data_dict = json.loads(json_match.group(1))
225
+
226
+ print(f"βš™οΈ Acquiring lock to update cached world state from {filename_base}...")
227
+ with manager["lock"]: # Use resource's lock
228
+ manager["state"].clear() # Clear the existing cached state dict
229
+ for k, v in world_data_dict.items(): manager["state"][str(k)] = v # Update with loaded data
230
+ loaded_count = len(manager["state"])
231
+ print(f"βœ… Loaded {loaded_count} objects into cached state. Lock released.")
232
+ st.session_state.current_world_file = filename_base # Track loaded file
233
+ # Consider clearing the cache resource if behavior is unexpected after loading?
234
+ # get_world_state_manager.clear() # <-- Use this if needed to force re-init on next access
235
+ return True
236
+
237
+ except json.JSONDecodeError as e: st.error(f"Invalid JSON in {filename_base}: {e}"); return False
238
+ except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return False
239
 
240
  def get_saved_worlds():
241
  """Scans the saved worlds directory for world MD files and parses them."""
 
243
  ensure_dir(SAVED_WORLDS_DIR);
244
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
245
  parsed_worlds = [parse_world_filename(f) for f in world_files]
 
246
  parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
247
  return parsed_worlds
248
+ except Exception as e: print(f"❌ Error scanning saved worlds: {e}"); st.error(f"Could not scan saved worlds: {e}"); return []
 
 
 
249
 
250
  # ==============================================================================
251
  # User State & Session Init
 
253
  def save_username(username):
254
  try:
255
  with open(STATE_FILE, 'w') as f: f.write(username)
256
+ except Exception as e: print(f"❌ Failed save username: {e}")
257
 
258
  def load_username():
259
  if os.path.exists(STATE_FILE):
260
  try:
261
  with open(STATE_FILE, 'r') as f: return f.read().strip()
262
+ except Exception as e: print(f"❌ Failed load username: {e}")
263
  return None
264
 
265
  def init_session_state():
266
  """Initializes Streamlit session state variables."""
267
  defaults = {
268
+ 'server_running_flag': False, 'server_instance': None, 'server_task': None,
269
+ 'active_connections': defaultdict(dict), # Stores websocket objects by client_id
270
  'last_chat_update': 0, 'message_input': "", 'audio_cache': {},
271
  'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [], 'enable_audio': True,
272
  'download_link_cache': {}, 'username': None, 'autosend': False,
273
  'last_message': "",
274
  'selected_object': 'None',
275
+ # Removed 'initial_world_state_loaded' flag, cache resource handles init
276
  'current_world_file': None, # Track loaded world filename (basename)
277
+ 'new_world_name': "MyDreamscape",
278
  'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
279
+ # Removed 'world_to_load_data' and 'js_object_placed_data' - WS handles live updates
 
280
  }
281
  for k, v in defaults.items():
282
  if k not in st.session_state:
283
+ if isinstance(v, (deque, dict, list)): st.session_state[k] = v.copy()
284
+ else: st.session_state[k] = v
285
+ # Ensure complex types are correctly initialized
286
+ if not isinstance(st.session_state.active_connections, defaultdict): st.session_state.active_connections = defaultdict(dict)
 
 
 
 
287
  if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
288
  if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
289
  if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
 
292
  # ==============================================================================
293
  # Action Log Helper
294
  # ==============================================================================
295
+ def add_action_log(message, emoji="➑️"):
296
+ """Adds a timestamped message with emoji to the session's action log."""
297
+ if 'action_log' not in st.session_state or not isinstance(st.session_state.action_log, deque):
298
  st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
299
  timestamp = datetime.now().strftime("%H:%M:%S")
300
+ st.session_state.action_log.appendleft(f"{emoji} [{timestamp}] {message}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
  # ==============================================================================
303
  # Audio / TTS / Chat / File Handling Helpers (Keep implementations)
 
307
  if not isinstance(text, str): return "No text"
308
  text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
309
  text = ' '.join(text.split()); return text[:250] or "No text"
 
310
  def create_file(content, username, file_type="md", save_path=None):
311
  if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
312
  ensure_dir(os.path.dirname(save_path))
313
  try:
314
  with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
315
+ except Exception as e: print(f"❌ Error creating file {save_path}: {e}"); return None
 
316
  def get_download_link(file_path, file_type="md"):
317
  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>"
318
  try: mtime = os.path.getmtime(file_path)
 
326
  basename = os.path.basename(file_path)
327
  link_html = f'<a href="data:{mime_types.get(file_type, "application/octet-stream")};base64,{b64}" download="{basename}" title="Download {basename}">{FILE_EMOJIS.get(file_type, "πŸ“„")}</a>'
328
  st.session_state.download_link_cache[cache_key] = link_html
329
+ except Exception as e: print(f"❌ Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
330
  return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
 
331
  # --- Audio / TTS ---
332
  async def async_edge_tts_generate(text, voice, username):
333
  if not text: return None
 
342
  try:
343
  communicate = edge_tts.Communicate(text_cleaned, voice); await communicate.save(save_path);
344
  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
345
+ else: print(f"❌ Audio file {save_path} failed generation."); return None
346
+ except Exception as e: print(f"❌ Edge TTS Error: {e}"); return None
 
347
  def play_and_download_audio(file_path):
348
  if file_path and os.path.exists(file_path):
349
  try:
350
  st.audio(file_path)
351
  file_type = file_path.split('.')[-1]
352
  st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
353
+ except Exception as e: st.error(f"❌ Audio display error for {os.path.basename(file_path)}: {e}")
 
354
  # --- Chat ---
355
  async def save_chat_entry(username, message, voice, is_markdown=False):
356
  if not message.strip(): return None, None
 
365
  tts_message = message
366
  audio_file = await async_edge_tts_generate(tts_message, voice, username)
367
  return md_file, audio_file
 
368
  async def load_chat_history():
369
  if 'chat_history' not in st.session_state: st.session_state.chat_history = []
370
  if not st.session_state.chat_history:
371
+ ensure_dir(CHAT_DIR); print("πŸ“œ Loading chat history from files...")
372
  chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime); loaded_count = 0
373
  temp_history = []
374
  for f_path in chat_files:
375
  try:
376
  with open(f_path, 'r', encoding='utf-8') as file: temp_history.append(file.read().strip()); loaded_count += 1
377
+ except Exception as e: print(f"❌ Err read chat {f_path}: {e}")
378
  st.session_state.chat_history = temp_history
379
+ print(f"βœ… Loaded {loaded_count} chat entries from files.")
380
  return st.session_state.chat_history
 
381
  # --- File Management ---
382
  def create_zip_of_files(files_to_zip, prefix="Archive"):
383
+ if not files_to_zip: st.warning("πŸ’¨ Nothing to gather into an archive."); return None
384
  timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
385
  try:
386
+ print(f"πŸ“¦ Creating zip archive: {zip_name}...");
387
  with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as z:
388
  for f in files_to_zip:
389
  if os.path.exists(f): z.write(f, os.path.basename(f))
390
+ else: print(f"πŸ’¨ Skip zip missing file: {f}")
391
+ print("βœ… Zip archive created successfully."); st.success(f"Created {zip_name}"); return zip_name
392
+ except Exception as e: print(f"❌ Zip creation failed: {e}"); st.error(f"Zip creation failed: {e}"); return None
 
393
  def delete_files(file_patterns, exclude_files=None):
394
  protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
395
  current_world_base = st.session_state.get('current_world_file')
396
+ if current_world_base: protected.append(current_world_base) # Protect loaded world
397
  if exclude_files: protected.extend(exclude_files)
398
  deleted_count = 0; errors = 0
399
  for pattern in file_patterns:
400
  pattern_path = pattern
401
+ print(f"πŸ—‘οΈ Attempting to delete files matching: {pattern_path}")
402
  try:
403
  files_to_delete = glob.glob(pattern_path)
404
+ if not files_to_delete: print(f"πŸ’¨ No files found for pattern: {pattern}"); continue
405
  for f_path in files_to_delete:
406
  basename = os.path.basename(f_path)
407
  if os.path.isfile(f_path) and basename not in protected:
408
+ try: os.remove(f_path); print(f"πŸ—‘οΈ Deleted: {f_path}"); deleted_count += 1
409
+ except Exception as e: print(f"❌ Failed delete {f_path}: {e}"); errors += 1
410
+ # else: print(f"🚫 Skipping protected/directory: {f_path}") # Debugging
411
+ except Exception as glob_e: print(f"❌ Error matching pattern {pattern}: {glob_e}"); errors += 1
412
+ msg = f"βœ… Successfully deleted {deleted_count} files." if errors == 0 and deleted_count > 0 else f"Deleted {deleted_count} files."
413
  if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
414
  elif deleted_count > 0: st.success(msg)
415
+ else: st.info("πŸ’¨ No matching unprotected files found to delete.")
416
  st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
 
417
  # --- Image Handling ---
418
  async def save_pasted_image(image, username):
419
  if not image: return None
420
+ 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
421
+ except Exception as e: print(f"❌ Failed image save: {e}"); return None
 
 
 
422
  def paste_image_component():
423
  pasted_img = None; img_type = None
424
+ 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)")
425
+ if st.button("πŸ–ΌοΈ Process Pasted Image", key="process_paste_button"):
426
+ st.session_state.paste_image_base64_input = paste_input_value
427
  if paste_input_value and paste_input_value.startswith('data:image'):
428
  try:
429
  mime_type = paste_input_value.split(';')[0].split(':')[1]; base64_str = paste_input_value.split(',')[1]; img_bytes = base64.b64decode(base64_str); pasted_img = Image.open(io.BytesIO(img_bytes)); img_type = mime_type.split('/')[1]
430
+ st.image(pasted_img, caption=f"πŸ–ΌοΈ Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str
431
+ st.session_state.paste_image_base64_input = ""
432
+ st.rerun()
433
+ except ImportError: st.error("⚠️ Pillow library needed for image pasting.")
434
+ 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
435
+ else: st.warning("⚠️ No valid image data pasted."); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = paste_input_value
436
+ processed_b64 = st.session_state.get('paste_image_base64', '')
437
+ if processed_b64:
438
+ try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
439
+ except Exception: return None
440
+ return None
 
441
  # --- PDF Processing ---
442
+ class AudioProcessor: # Keep as is
443
  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 {}
444
  def _save_metadata(self):
445
  try:
446
  with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f, indent=2)
447
+ except Exception as e: print(f"❌ Failed metadata save: {e}")
448
+ async def create_audio(self, text, voice=DEFAULT_TTS_VOICE):
449
  cache_key=hashlib.md5(f"{text[:150]}:{voice}".encode()).hexdigest(); cache_path=os.path.join(self.cache_dir, f"{cache_key}.mp3");
450
  if cache_key in self.metadata and os.path.exists(cache_path): return cache_path
451
  text_cleaned=clean_text_for_tts(text);
 
455
  communicate=edge_tts.Communicate(text_cleaned,voice); await communicate.save(cache_path)
456
  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
457
  else: return None
458
+ except Exception as e: print(f"❌ TTS Create Audio Error: {e}"); return None
459
 
460
+ def process_pdf_tab(pdf_file, max_pages, voice): # Corrected version
461
+ st.subheader("πŸ“œ PDF Processing Results")
462
+ if pdf_file is None: st.info("⬆️ Upload a PDF file and click 'Process PDF' to begin."); return
 
 
463
  audio_processor = AudioProcessor()
464
  try:
465
+ reader=PdfReader(pdf_file);
466
+ if reader.is_encrypted: st.warning("πŸ”’ PDF is encrypted."); return
467
+ total_pages_in_pdf = len(reader.pages); pages_to_process = min(total_pages_in_pdf, max_pages);
468
+ st.write(f"⏳ Processing first {pages_to_process} of {total_pages_in_pdf} pages from '{pdf_file.name}'...")
 
 
 
469
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
470
 
471
  def process_page_sync(page_num, page_text):
472
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
473
  try:
474
+ audio_path = asyncio.run(run_async_audio()) # Use asyncio.run in thread
475
+ if audio_path:
476
+ with results_lock: audios[page_num] = audio_path
477
+ except Exception as page_e: print(f"❌ Err process page {page_num+1}: {page_e}")
 
478
 
479
  # Start threads
480
  for i in range(pages_to_process):
481
+ try: # Start try block for page processing
482
  page = reader.pages[i]
483
+ text = page.extract_text() # Attempt text extraction
484
+ if text and text.strip(): # Check extracted text
485
+ texts[i]=text # Store text
486
+ thread = threading.Thread(target=process_page_sync, args=(i, text)) # Create thread
487
+ page_threads.append(thread) # Append thread
488
+ thread.start() # Start thread
489
+ else: # Handle empty extraction
490
+ texts[i] = "[πŸ“„ No text extracted or page empty]"
491
+ # print(f"Page {i+1}: No text extracted.") # Verbose
492
+ # Correctly indented except block
493
  except Exception as extract_e:
494
+ texts[i] = f"[❌ Error extract: {extract_e}]" # Store error message
495
+ print(f"Error page {i+1} extract: {extract_e}") # Log error
496
 
497
  # Wait for threads and display progress
498
+ progress_bar = st.progress(0.0, text="✨ Transmuting pages to sound...")
499
+ total_threads = len(page_threads); start_join_time = time.time()
 
500
  while any(t.is_alive() for t in page_threads):
501
+ completed_threads = total_threads - sum(t.is_alive() for t in page_threads); progress = completed_threads / total_threads if total_threads > 0 else 1.0
502
+ progress_bar.progress(min(progress, 1.0), text=f"✨ Processed {completed_threads}/{total_threads} pages...")
503
+ if time.time() - start_join_time > 600: print("βŒ› PDF processing timed out."); st.warning("Processing timed out."); break
504
+ time.sleep(0.5)
505
+ progress_bar.progress(1.0, text="βœ… Processing complete.")
 
 
 
 
506
 
507
  # Display results
508
+ st.write("🎢 Results:")
509
  for i in range(pages_to_process):
510
  with st.expander(f"Page {i+1}"):
511
+ st.markdown(texts.get(i, "[❓ Error getting text]"))
512
+ audio_file = audios.get(i)
513
+ if audio_file: play_and_download_audio(audio_file)
 
514
  else:
 
515
  page_text = texts.get(i,"")
516
+ if page_text.strip() and not page_text.startswith("["): st.caption("πŸ”‡ Audio generation failed or timed out.")
517
+ # else: st.caption("πŸ”‡ No text to generate audio from.") # Implicit
 
 
518
 
519
+ except ImportError: st.error("⚠️ PyPDF2 library needed.")
520
+ except Exception as pdf_e: st.error(f"❌ Error reading PDF '{pdf_file.name}': {pdf_e}"); st.exception(pdf_e)
521
+
522
+
523
+ # ==============================================================================
524
+ # WebSocket Server Logic (Re-added for Chat/Presence)
525
+ # ==============================================================================
526
+
527
+ async def register_client(websocket):
528
+ client_id = str(websocket.id);
529
+ with clients_lock: connected_clients.add(client_id);
530
+ if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict);
531
+ st.session_state.active_connections[client_id] = websocket;
532
+ print(f"βœ… Client registered: {client_id}. Total: {len(connected_clients)}")
533
+
534
+ async def unregister_client(websocket):
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
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
+ with clients_lock:
551
+ if not connected_clients: return
552
+ current_client_ids = list(connected_clients)
553
+ active_connections_copy = st.session_state.active_connections.copy()
554
+
555
+ tasks = []
556
+ for client_id in current_client_ids:
557
+ if client_id == exclude_id: continue
558
+ websocket = active_connections_copy.get(client_id)
559
+ if websocket: tasks.append(asyncio.create_task(send_safely(websocket, message, client_id)))
560
+
561
+ if tasks: await asyncio.gather(*tasks, return_exceptions=True) # Wait and ignore errors here
562
+
563
+ async def websocket_handler(websocket, path):
564
+ """Handles WebSocket connections and messages (primarily for Chat)."""
565
+ await register_client(websocket); client_id = str(websocket.id);
566
+ username = st.session_state.get('username', f"User_{client_id[:4]}")
567
+
568
+ try: # Announce join
569
+ print(f"✨ {username} ({client_id}) connected.")
570
+ await broadcast_message(json.dumps({"type": "user_join", "payload": {"username": username, "id": client_id}}), exclude_id=client_id)
571
+ except Exception as e: print(f"❌ Error during join announcement {client_id}: {e}")
572
+
573
+ try: # Message loop
574
+ async for message in websocket:
575
+ try:
576
+ data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
577
+ sender_username = payload.get("username", username) # Username sent from client
578
+
579
+ if msg_type == "chat_message":
580
+ chat_text = payload.get('message', ''); voice = payload.get('voice', FUN_USERNAMES.get(sender_username, DEFAULT_TTS_VOICE));
581
+ print(f"πŸ’¬ WS Recv Chat from {sender_username}: {chat_text[:30]}...")
582
+ # Schedule save/TTS locally
583
+ run_async(save_chat_entry, sender_username, chat_text, voice)
584
+ # Broadcast message to other clients (they will update their UI)
585
+ await broadcast_message(message, exclude_id=client_id)
586
+ elif msg_type == "ping": # Handle keepalive pings
587
+ await websocket.send(json.dumps({"type": "pong"}))
588
+ # Add other message types if needed (e.g., simple presence)
589
+ else:
590
+ print(f"⚠️ WS Recv unknown message type from {client_id}: {msg_type}")
591
+
592
+ except json.JSONDecodeError: print(f"⚠️ WS Invalid JSON from {client_id}: {message[:100]}...")
593
+ except Exception as e: print(f"❌ WS Error processing msg from {client_id}: {e}")
594
+ except websockets.ConnectionClosed: print(f"πŸ”Œ WS Client disconnected: {client_id} ({username})")
595
+ except Exception as e: print(f"❌ WS Unexpected handler error {client_id}: {e}")
596
+ finally:
597
+ await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
598
+ await unregister_client(websocket) # Cleanup
599
+
600
+ async def run_websocket_server():
601
+ """Coroutine to run the WebSocket server."""
602
+ if st.session_state.get('server_running_flag', False): return # Prevent multiple starts
603
+ st.session_state['server_running_flag'] = True; print("βš™οΈ Attempting start WS server 0.0.0.0:8765...")
604
+ stop_event = asyncio.Event(); st.session_state['websocket_stop_event'] = stop_event
605
+ server = None
606
+ try:
607
+ server = await websockets.serve(websocket_handler, "0.0.0.0", 8765); st.session_state['server_instance'] = server
608
+ print(f"βœ… WS server started: {server.sockets[0].getsockname()}. Waiting for stop signal...")
609
+ await stop_event.wait() # Keep server running
610
+ except OSError as e: print(f"### ❌ FAILED START WS SERVER: {e}"); st.session_state['server_running_flag'] = False;
611
+ except Exception as e: print(f"### ❌ UNEXPECTED WS SERVER ERROR: {e}"); st.session_state['server_running_flag'] = False;
612
+ finally:
613
+ print("βš™οΈ WS server task finishing...");
614
+ if server: server.close(); await server.wait_closed(); print("βœ… WS server closed.")
615
+ st.session_state['server_running_flag'] = False; st.session_state['server_instance'] = None; st.session_state['websocket_stop_event'] = None
616
+
617
+ def start_websocket_server_thread():
618
+ """Starts the WebSocket server in a separate thread if not already running."""
619
+ if st.session_state.get('server_task') and st.session_state.server_task.is_alive(): return
620
+ if st.session_state.get('server_running_flag', False): return
621
+ print("βš™οΈ Creating/starting new server thread.");
622
+ def run_loop(): # Wrapper to manage event loop in thread
623
+ loop = None
624
+ try: loop = asyncio.get_running_loop()
625
+ except RuntimeError: loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop)
626
+ try: loop.run_until_complete(run_websocket_server())
627
+ finally:
628
+ if loop and not loop.is_closed():
629
+ tasks = asyncio.all_tasks(loop);
630
+ if tasks:
631
+ for task in tasks: task.cancel()
632
+ try: loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
633
+ except asyncio.CancelledError: pass
634
+ loop.close(); print("βš™οΈ Server thread loop closed.")
635
+ st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5)
636
+ if not st.session_state.server_task.is_alive(): print("### ❌ Server thread failed to stay alive!")
637
 
638
 
639
  # ==============================================================================
 
643
  def render_sidebar():
644
  """Renders the Streamlit sidebar contents."""
645
  with st.sidebar:
646
+ # 1. World Management
647
+ st.header("1. πŸ’Ύ World Management")
648
+ st.caption("πŸ’Ύ Save the current view or ✨ load a past creation.")
649
 
650
+ # World Save Button (simplified logic)
651
  current_file = st.session_state.get('current_world_file')
652
+ 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))
653
+ world_save_name = st.text_input("World Name:", key="world_save_name_input", value=save_name_value, help="Enter name to save.")
 
 
 
 
 
 
 
 
 
 
 
654
 
655
+ if st.button("πŸ’Ύ Save Current View", key="sidebar_save_world"):
656
+ if not world_save_name.strip(): st.warning("⚠️ Please enter a World Name.")
 
657
  else:
658
+ # Store info needed for callback after JS returns data
659
+ st.session_state.pending_save_filename = generate_world_save_filename(st.session_state.username, world_save_name)
660
+ st.session_state.pending_save_op_text = f"Saving {world_save_name}..."
661
+ # Trigger JS to get state and call Python function `handle_js_save_data`
662
+ try:
663
+ print("🐍 Requesting world state from JS for saving...")
664
+ # JS function `getWorldStateForSave` now calls Python func `handle_js_save_data`
665
+ streamlit_js_eval("getWorldStateForSave(true);", key="trigger_js_save")
666
+ st.info("πŸ’Ύ Requesting state from view...")
667
+ except Exception as e: st.error(f"❌ Failed to request save state: {e}")
668
+
669
+ # World Load Section
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
  st.markdown("---")
671
+ st.header("2. πŸ“‚ Load World")
672
+ st.caption("πŸ“œ Unfurl a previously woven dreamscape.")
673
  saved_worlds = get_saved_worlds()
674
 
675
+ if not saved_worlds: st.caption("🌫️ The archives are empty.")
676
  else:
677
+ cols_header = st.columns([4, 1, 1]);
678
+ with cols_header[0]: st.write("**Name** (User, Time)")
679
+ with cols_header[1]: st.write("**Load**")
680
+ with cols_header[2]: st.write("**DL**")
681
+
682
+ display_limit = 15
683
+ # Use a container for scrollability if list is long
684
+ list_container = st.container(height=300 if len(saved_worlds) > 7 else None)
685
+ with list_container:
686
+ for world_info in saved_worlds:
687
+ f_basename = os.path.basename(world_info['filename']); f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename);
688
+ display_name = world_info.get('name', f_basename); user = world_info.get('user', 'N/A'); timestamp = world_info.get('timestamp', 'N/A')
689
+ display_text = f"{display_name} ({user}, {timestamp})"
690
+ col1, col2, col3 = st.columns([4, 1, 1])
691
+ with col1: st.write(f"<small>{display_text}</small>", unsafe_allow_html=True)
692
+ with col2:
693
+ is_current = (st.session_state.get('current_world_file') == f_basename)
694
+ btn_load = st.button("✨", key=f"load_{f_basename}", help=f"Load {f_basename}", disabled=is_current)
695
+ with col3: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
696
+
697
+ if btn_load:
698
+ print(f"πŸ–±οΈ Load button clicked for: {f_basename}")
699
+ world_dict = load_world_from_md(f_basename)
700
+ if world_dict is not None:
701
+ st.session_state.world_to_load_data = world_dict # Queue data for JS
702
+ st.session_state.current_world_file = f_basename
703
+ add_action_log(f"Loading world: {f_basename}", emoji="πŸ“‚")
704
+ st.rerun()
705
+ else: st.error(f"❌ Failed to parse world file: {f_basename}")
706
+
707
+ # Build Tools Section
 
708
  st.markdown("---")
709
+ st.header("3. πŸ› οΈ Build Tools")
710
+ st.caption("Select your creative instrument.")
711
  tool_options = list(TOOLS_MAP.keys())
712
  current_tool_name = st.session_state.get('selected_object', 'None')
713
  try: tool_index = tool_options.index(current_tool_name)
714
  except ValueError: tool_index = 0
715
 
 
 
716
  selected_tool = st.radio(
717
  "Select Tool:", options=tool_options, index=tool_index,
718
  format_func=lambda name: f"{TOOLS_MAP.get(name, '')} {name}",
719
+ key="tool_selector_radio", horizontal=True, label_visibility="collapsed"
720
  )
 
721
  if selected_tool != current_tool_name:
722
  st.session_state.selected_object = selected_tool
723
+ tool_emoji = TOOLS_MAP.get(selected_tool, '❓')
724
+ add_action_log(f"Tool selected: {selected_tool}", emoji=tool_emoji)
725
+ try: streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
726
+ except Exception as e: print(f"❌ JS tool update error: {e}")
727
  st.rerun()
728
 
729
+
730
+ # Action Log Section
731
  st.markdown("---")
732
+ st.header("4. πŸ“ Action Log")
733
+ st.caption("πŸ“œ A chronicle of your recent creative acts.")
734
  log_container = st.container(height=200)
735
  with log_container:
736
  log_entries = st.session_state.get('action_log', [])
737
  if log_entries: st.code('\n'.join(log_entries), language="log")
738
+ else: st.caption("🌬️ The log awaits your first action...")
739
+
740
 
741
+ # Voice/User Section
742
  st.markdown("---")
743
+ st.header("5. πŸ‘€ Voice & User")
744
+ st.caption("🎭 Choose your persona in this realm.")
745
  current_username = st.session_state.get('username', "DefaultUser")
746
  username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
747
  current_index = 0;
748
+ try:
749
+ if current_username in username_options: current_index = username_options.index(current_username)
750
+ except ValueError: pass # Keep index 0 if not found
751
+
752
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
753
+ if new_username != st.session_state.get('username'):
754
+ old_username = st.session_state.username
755
+ change_msg = json.dumps({"type":"user_rename", "payload": {"old_username": old_username, "new_username": new_username}})
756
+ run_async(broadcast_message, change_msg) # Broadcast name change if WS is running
757
  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)
758
+ add_action_log(f"Persona changed to {new_username}", emoji="🎭")
759
  st.rerun()
760
+ st.session_state['enable_audio'] = st.toggle("πŸ”Š Enable TTS Audio", value=st.session_state.get('enable_audio', True), help="Generate audio for chat messages?")
761
 
762
 
763
  def render_main_content():
764
  """Renders the main content area with tabs."""
765
  st.title(f"{Site_Name} - User: {st.session_state.username}")
766
 
767
+ # 1. Check if world data needs to be sent to JS (loaded via sidebar button)
768
  world_data_to_load = st.session_state.pop('world_to_load_data', None)
769
  if world_data_to_load is not None:
770
+ print(f"🐍 Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
771
  try:
772
  streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
773
+ st.toast("🌌 World loaded in 3D view.", icon="πŸ”„")
774
+ except Exception as e: st.error(f"❌ Failed to send loaded world state to JS: {e}")
 
775
 
776
+ # 2. Set up JS communication handlers (Object Placement & Save Data Callback)
 
777
  streamlit_js_eval(
778
  js_code="""
779
+ // Ensure functions are defined only once per page load
780
+ if (!window.streamlitPythonCallSetupDone) {
781
+ console.log('🐍 Setting up JS->Python communication functions...');
782
  window.sendPlacedObjectToPython = (objectData) => {
783
  console.log('JS sending placed object:', objectData);
 
784
  streamlit_js_eval(python_code='handle_js_object_placed(data=' + JSON.stringify(objectData) + ')', key='js_place_event_handler');
785
+ };
786
+ window.sendSaveDataToPython = (jsonData) => {
787
+ console.log('JS sending saved world state back to Python...');
788
+ streamlit_js_eval(python_code='handle_js_save_data(data=' + JSON.stringify(jsonData) + ')', key='js_save_state_handler');
789
+ };
790
+ window.streamlitPythonCallSetupDone = true;
791
+ }
792
  """,
793
+ key="setup_js_comms" # Key for the setup code itself
794
  )
795
 
796
+ # 3. Process data received from JS callbacks (if any were triggered)
797
+ # Check for placement data
798
  if 'js_place_event_handler' in st.session_state:
 
799
  placed_data = st.session_state.pop('js_object_placed_data', None)
800
+ if placed_data: print(f"🐍 Python processed placed object: {placed_data.get('obj_id')}")
801
+ del st.session_state['js_place_event_handler'] # Clear trigger
802
+
803
+ # Check for save data
804
+ if 'js_save_state_handler' in st.session_state:
805
+ saved_data_str = st.session_state.pop('js_save_state_data', None)
806
+ del st.session_state['js_save_state_handler']
807
+ if saved_data_str:
808
+ print("🐍 Python processing saved world data received from JS...")
809
+ try:
810
+ world_data_dict = json.loads(saved_data_str)
811
+ if isinstance(world_data_dict, dict):
812
+ save_filename = st.session_state.get("pending_save_filename") # Get filename set by button
813
+ save_op_text = st.session_state.get("pending_save_op_text", f"Saving {save_filename}...")
814
+ is_overwrite = st.session_state.get("pending_save_is_overwrite", False)
815
+ if save_filename:
816
+ with st.spinner(save_op_text):
817
+ if save_world_to_md(save_filename, world_data_dict):
818
+ action = "Overwritten" if is_overwrite else "Saved new"; st.success(f"World {action}: {save_filename}");
819
+ add_action_log(f"Saved world: {save_filename}", emoji="πŸ’Ύ")
820
+ st.session_state.current_world_file = save_filename # Update current file
821
+ # Clear pending save state
822
+ 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)
823
+ st.rerun() # Refresh lists
824
+ else: st.error("❌ Failed to save world state to file.")
825
+ else: st.error("❌ Save triggered but filename was missing in state.")
826
+ else: st.error("❌ Invalid save data format received.")
827
+ except json.JSONDecodeError: st.error("❌ Failed to decode save data from JS.")
828
+ except Exception as e: st.error(f"❌ Error processing save data: {e}"); st.exception(e)
829
+ else: print("⚠️ Save callback triggered, but no save data found.")
830
 
831
 
832
  # Define Tabs
 
834
 
835
  # --- World Builder Tab ---
836
  with tab_world:
837
+ st.header("🌌 Shared Dreamscape")
838
+ st.caption("✨ Weave reality with sidebar tools. Save your creations!")
839
  current_file_basename = st.session_state.get('current_world_file', None)
840
  if current_file_basename:
841
  full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
842
+ if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"🌠 Viewing: **{parsed['name']}** (`{current_file_basename}`)")
843
+ else: st.warning(f"⚠️ Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
844
+ else: st.info("☁️ Live State Active (Save to persist)")
845
 
846
  # Embed HTML Component
847
  html_file_path = 'index.html'
848
  try:
849
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
850
  # Inject state needed by JS
851
+ initial_world_data = {} # Default empty, loadWorldState handles initial load
852
+ if world_data_to_load is None and st.session_state.get('current_world_file'):
853
+ # If a file is selected, but wasn't just loaded (e.g., page refresh), load its data for initial injection
854
+ loaded_dict = load_world_from_md(st.session_state.current_world_file)
855
+ if loaded_dict: initial_world_data = loaded_dict
 
 
856
 
857
  js_injection_script = f"""<script>
858
  window.USERNAME = {json.dumps(st.session_state.username)};
859
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
860
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
861
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
862
+ window.INITIAL_WORLD_OBJECTS = {json.dumps(initial_world_data)}; /* Used only if loadWorldState isn't called */
863
+ console.log("🐍 Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjectsInject: {len(initial_world_data)} }});
 
864
  </script>"""
865
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
866
  components.html(html_content_with_state, height=700, scrolling=False)
867
+ except FileNotFoundError: st.error(f"❌ CRITICAL ERROR: Could not find '{html_file_path}'.")
868
+ except Exception as e: st.error(f"❌ Error loading 3D component: {e}"); st.exception(e)
869
 
870
  # --- Chat Tab ---
871
  with tab_chat:
872
+ st.header(f"πŸ’¬ Whispers in the Void")
873
  chat_history_list = st.session_state.get('chat_history', [])
874
  if not chat_history_list: chat_history_list = asyncio.run(load_chat_history())
875
  chat_container = st.container(height=500)
876
  with chat_container:
877
  if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:])))
878
+ else: st.caption("🌬️ Silence reigns...")
879
 
880
  def clear_chat_input_callback(): st.session_state.message_input = ""
881
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
882
+ send_button_clicked = st.button("βœ‰οΈ Send Message", key="send_chat_button", on_click=clear_chat_input_callback)
883
 
884
  if send_button_clicked:
885
+ message_to_send = message_value
886
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
887
  st.session_state.last_message = message_to_send
888
  voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
889
+ # Use run_async for background save/TTS generation
890
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
891
+ add_action_log(f"Sent chat: {message_to_send[:20]}...", emoji="πŸ’¬")
892
+ # Rerun is handled implicitly by button + on_click
893
  elif send_button_clicked: st.toast("Message empty or same as last.")
894
 
895
  # --- PDF Tab ---
896
  with tab_pdf:
897
+ st.header("πŸ“š Tome Translator")
898
+ st.caption("πŸ”Š Give voice to the silent knowledge within PDF scrolls.")
899
+ pdf_file = st.file_uploader("Upload PDF Scroll", type="pdf", key="pdf_upload")
900
+ max_pages = st.slider('Max Pages to Animate', 1, 50, 10, key="pdf_pages")
901
  if pdf_file:
902
+ if st.button("πŸŽ™οΈ Animate PDF to Audio", key="process_pdf_button"):
903
+ with st.spinner("✨ Transcribing ancient glyphs to sound..."):
904
  process_pdf_tab(pdf_file, max_pages, st.session_state.tts_voice)
905
 
906
  # --- Files & Settings Tab ---
907
  with tab_files:
908
+ st.header("πŸ“‚ Archives & Settings")
909
+ st.caption("βš™οΈ Manage saved scrolls and application settings.")
 
910
 
911
+ st.subheader("πŸ’Ύ World Scroll Management")
912
+ # Save buttons moved to sidebar for this iteration
913
+
914
+ st.subheader("πŸ—‘οΈ Archive Maintenance")
915
+ st.caption("🧹 Cleanse the old to make way for the new.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
916
  st.warning("Deletion is permanent!", icon="⚠️")
917
  col_del1, col_del2, col_del3, col_del4 = st.columns(4)
918
  with col_del1:
919
+ if st.button("πŸ—‘οΈ Chats", key="del_chat_md"): delete_files([os.path.join(CHAT_DIR, "*.md")]); st.session_state.chat_history = []; add_action_log("Cleared Chats", emoji="🧹"); st.rerun()
920
  with col_del2:
921
+ if st.button("πŸ—‘οΈ Audio", key="del_audio_mp3"): delete_files([os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3")]); st.session_state.audio_cache = {}; add_action_log("Cleared Audio", emoji="🧹"); st.rerun()
922
  with col_del3:
923
+ if st.button("πŸ—‘οΈ Worlds", key="del_worlds_md"): delete_files([os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")]); st.session_state.current_world_file = None; add_action_log("Cleared Worlds", emoji="🧹"); st.rerun()
 
924
  with col_del4:
925
+ 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()
926
 
927
  st.subheader("πŸ“¦ Download Archives")
928
+ st.caption("Bundle your creations for safekeeping or sharing.")
929
  col_zip1, col_zip2, col_zip3 = st.columns(3)
930
  with col_zip1:
 
931
  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")
932
  with col_zip2:
933
  if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
 
938
  st.caption("Existing Zip Files:")
939
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
940
  else:
941
+ st.caption("🌬️ No archives found.")
 
 
942
 
943
  # ==============================================================================
944
  # Main Execution Logic
 
953
  if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
954
  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)
955
 
956
+ # Queue initial world state load IF no world is selected AND no load is pending
957
+ if st.session_state.get('world_to_load_data') is None and st.session_state.get('current_world_file') is None:
958
+ print("🐍 Attempting initial load of most recent world...")
959
+ saved_worlds = get_saved_worlds()
960
+ if saved_worlds:
961
+ latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
962
+ print(f"🐍 Queueing most recent world for load: {latest_world_file_basename}")
963
+ world_dict = load_world_from_md(latest_world_file_basename)
964
+ if world_dict is not None:
965
+ st.session_state.world_to_load_data = world_dict # Queue data
966
+ st.session_state.current_world_file = latest_world_file_basename # Set as current
967
+ else: print("❌ Failed to load most recent world."); st.session_state.world_to_load_data = {} # Send empty state
968
+ else: print("🌫️ No saved worlds found."); st.session_state.world_to_load_data = {} # Send empty state
 
 
 
969
 
970
 
971
  if __name__ == "__main__":
972
  initialize_app() # Initialize state, user, queue initial world load data
973
+ render_sidebar() # Render sidebar UI
974
  render_main_content() # Render main UI (includes logic to send queued world data to JS)