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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +280 -296
app.py CHANGED
@@ -1,6 +1,7 @@
1
- # app.py (Refactored - Client State Save, Action Log, Radio Tools)
2
  import streamlit as st
3
  import asyncio
 
4
  import uuid
5
  from datetime import datetime
6
  import os
@@ -16,28 +17,38 @@ import nest_asyncio
16
  import re
17
  import pytz
18
  import shutil
19
- # from PyPDF2 import PdfReader # Keep if PDF tab is kept
20
- import threading # Keep lock for potential future use or background tasks
21
  import json
22
  import zipfile
23
  from dotenv import load_dotenv
24
  # from streamlit_marquee import streamlit_marquee # Keep import if used
25
  from collections import defaultdict, Counter, deque # Use deque for action log
26
  # import pandas as pd # Removed dependency
27
- from streamlit_js_eval import streamlit_js_eval
28
  from PIL import Image # Needed for paste_image_component
29
 
30
  # ==============================================================================
31
  # Configuration & Constants
32
  # ==============================================================================
33
- st.set_page_config(page_title="πŸŒπŸ—οΈ World Action Builder πŸ†", page_icon="πŸ—οΈ", layout="wide", initial_sidebar_state="expanded")
34
- nest_asyncio.apply() # Patch asyncio
 
 
 
 
 
 
 
 
 
35
 
36
  # General Constants
37
- Site_Name = 'πŸŒπŸ—οΈ World Action Builder'
38
- MEDIA_DIR = "."
39
- STATE_FILE = "user_state.txt"
40
- DEFAULT_TTS_VOICE = "en-US-AriaNeural"
 
41
 
42
  # User/Chat Constants
43
  FUN_USERNAMES = {
@@ -46,23 +57,20 @@ FUN_USERNAMES = {
46
  "PixelPainter 🎨": "en-CA-ClaraNeural", "VoxelVortex πŸŒͺ️": "en-US-GuyNeural",
47
  "CosmicCrafter ✨": "en-GB-RyanNeural", "GeoGuru πŸ—ΊοΈ": "en-AU-WilliamNeural",
48
  "BlockBard 🧱": "en-CA-LiamNeural", "SoundSculptor πŸ”Š": "en-US-AnaNeural",
49
- # Add more if desired
50
  }
51
- # Define available voices based on the dictionary (needed for TTS)
52
- EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values()))
53
- DEFAULT_TTS_VOICE = "en-US-AriaNeural" # Keep a default
54
  CHAT_DIR = "chat_logs"
55
 
56
- # Directories
57
- CHAT_DIR = "chat_logs"
58
  AUDIO_CACHE_DIR = "audio_cache"
59
  AUDIO_DIR = "audio_logs"
60
- SAVED_WORLDS_DIR = "saved_worlds"
61
 
62
  # World Builder Constants
63
- PLOT_WIDTH = 50.0
64
- PLOT_DEPTH = 50.0
65
- WORLD_STATE_FILE_MD_PREFIX = "🌍_"
 
66
  MAX_ACTION_LOG_SIZE = 30 # Max entries in sidebar action log
67
 
68
  # File Emojis
@@ -86,6 +94,11 @@ for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
86
  # --- API Keys (Placeholder) ---
87
  load_dotenv()
88
 
 
 
 
 
 
89
  # ==============================================================================
90
  # Utility Functions
91
  # ==============================================================================
@@ -94,15 +107,16 @@ def get_current_time_str(tz='UTC'):
94
  except Exception: now_aware = datetime.now(pytz.utc)
95
  return now_aware.strftime('%Y%m%d_%H%M%S')
96
 
97
- def clean_filename_part(text, max_len=25): # Shorter max len
98
  if not isinstance(text, str): text = "invalid_name"
99
  text = re.sub(r'\s+', '_', text); text = re.sub(r'[^\w\-.]', '', text)
100
  return text[:max_len]
101
 
102
  def run_async(async_func, *args, **kwargs):
103
- """Runs an async function safely from a sync context using create_task."""
104
  try: loop = asyncio.get_running_loop(); return loop.create_task(async_func(*args, **kwargs))
105
  except RuntimeError:
 
106
  try: return asyncio.run(async_func(*args, **kwargs))
107
  except Exception as e: print(f"Error run_async new loop: {e}"); return None
108
  except Exception as e: print(f"Error run_async schedule task: {e}"); return None
@@ -114,52 +128,33 @@ def ensure_dir(dir_path): os.makedirs(dir_path, exist_ok=True)
114
  # ==============================================================================
115
  def generate_world_save_filename(username="User", world_name="World"):
116
  """Generates filename including username, world name, timestamp."""
117
- timestamp = get_current_time_str()
118
- clean_user = clean_filename_part(username, 15)
119
- clean_world = clean_filename_part(world_name, 20)
120
- rand_hash = hashlib.md5(str(time.time()).encode() + username.encode() + world_name.encode()).hexdigest()[:4]
121
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
122
 
123
  def parse_world_filename(filename):
124
- """Extracts info from filename if possible, otherwise returns defaults."""
125
  basename = os.path.basename(filename)
126
- # Check prefix and suffix
127
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
128
- core_name = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
129
- parts = core_name.split('_')
130
- # Check if structure matches Name_by_User_Timestamp_Hash
131
  if len(parts) >= 5 and parts[-3] == "by":
132
- timestamp_str = parts[-2]
133
- username = parts[-4]
134
- world_name = " ".join(parts[:-4]) # Join potential name parts
135
- dt_obj = None
136
- try: # Try parsing timestamp
137
- dt_obj = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S')
138
- dt_obj = pytz.utc.localize(dt_obj) # Assume UTC
139
- except (ValueError, pytz.exceptions.AmbiguousTimeError, pytz.exceptions.NonExistentTimeError):
140
- dt_obj = None # Parsing failed
141
  return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
142
-
143
- # Fallback for unknown format or if parsing above failed
144
- # This section runs if the 'if' conditions above weren't fully met
145
- # print(f"Using fallback parsing for filename: {basename}") # Debugging log
146
- dt_fallback = None
147
- try: # Try getting modification time
148
- mtime = os.path.getmtime(filename)
149
- dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
150
- except Exception: # Ignore errors getting mtime
151
- pass
152
- # CORRECTED INDENTATION: This return belongs to the main function scope for the fallback case
153
  return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
154
 
155
-
156
  def save_world_to_md(target_filename_base, world_data_dict):
157
  """Saves the provided world state dictionary to a specific MD file."""
 
158
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
159
  print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
160
  success = False
161
- # No lock needed as we're writing data received from JS, not modifying shared state directly
162
- parsed_info = parse_world_filename(save_path)
163
  timestamp_save = get_current_time_str()
164
  md_content = f"""# World State: {parsed_info['name']} by {parsed_info['user']}
165
  * **File Saved:** {timestamp_save} (UTC)
@@ -177,7 +172,7 @@ def save_world_to_md(target_filename_base, world_data_dict):
177
  return success
178
 
179
  def load_world_from_md(filename_base):
180
- """Loads world state dict from an MD file (basename), DOES NOT update global state."""
181
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
182
  print(f"Loading world state dictionary from MD file: {load_path}...")
183
  if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return None
@@ -204,12 +199,12 @@ def get_saved_worlds():
204
  # ==============================================================================
205
  # User State & Session Init
206
  # ==============================================================================
207
- def save_username(username): # Keep as is
208
  try:
209
  with open(STATE_FILE, 'w') as f: f.write(username)
210
  except Exception as e: print(f"Failed save username: {e}")
211
 
212
- def load_username(): # Keep as is
213
  if os.path.exists(STATE_FILE):
214
  try:
215
  with open(STATE_FILE, 'r') as f: return f.read().strip()
@@ -226,7 +221,7 @@ def init_session_state():
226
  'selected_object': 'None', # Current building tool
227
  'current_world_file': None, # Track loaded world filename (basename)
228
  'new_world_name': "MyWorld",
229
- 'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE), # Use deque for fixed-size log
230
  'world_to_load_data': None, # Temp storage for state loaded from file before sending to JS
231
  'js_object_placed_data': None # Temp storage for data coming from JS place event
232
  }
@@ -251,34 +246,40 @@ def add_action_log(message):
251
  st.session_state.action_log.appendleft(f"[{timestamp}] {message}") # Add to front
252
 
253
  # ==============================================================================
254
- # JS Communication Handler Function (Called from JS via streamlit_js_eval)
255
  # ==============================================================================
256
- # NOTE: Functions called by streamlit_js_eval run in the main Python thread.
257
- # Avoid blocking operations here. Use session state to pass data if needed.
258
-
259
- def handle_js_object_placed(data):
260
  """Callback triggered by JS when an object is placed."""
261
- print(f"Python received object placed event: {data}") # Debug
262
- if isinstance(data, dict) and data.get('obj_id') and data.get('type'):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  # Store data in session state to be processed in the main script flow
264
- st.session_state.js_object_placed_data = data
265
- # Add to action log immediately
266
- add_action_log(f"Placed {data.get('type', 'object')} ({data.get('obj_id', 'N/A')[:6]}...)")
267
- # Need a rerun for the main loop to process st.session_state.js_object_placed_data
268
- # However, triggering rerun from here can be complex.
269
- # Let the natural Streamlit flow handle picking it up.
270
  else:
271
- print("Received invalid object placement data from JS.")
 
272
  return True # Acknowledge receipt to JS
273
 
274
-
275
  # ==============================================================================
276
- # Audio / TTS / Chat / File Handling Helpers (Unchanged from previous)
277
  # ==============================================================================
278
- # (Include the full code for these helpers: clean_text_for_tts, create_file,
279
- # get_download_link, async_edge_tts_generate, play_and_download_audio,
280
- # save_chat_entry, load_chat_history, create_zip_of_files, delete_files,
281
- # save_pasted_image, paste_image_component, AudioProcessor, process_pdf_tab)
282
  # --- Placeholder for brevity ---
283
  def clean_text_for_tts(text): # ... implementation ...
284
  if not isinstance(text, str): return "No text"
@@ -422,7 +423,43 @@ class AudioProcessor: # ... implementation ...
422
  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
423
  else: return None
424
  except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
425
- # def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ... (Keep if needed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
 
427
 
428
  # ==============================================================================
@@ -435,118 +472,94 @@ def render_sidebar():
435
  st.header("πŸ’Ύ World Management")
436
 
437
  # --- World Save ---
438
- current_world_name = "Live State"
439
  current_file = st.session_state.get('current_world_file')
 
440
  if current_file:
441
- parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
442
- current_world_name = parsed.get("name", current_file)
443
-
444
- save_button_label = f"πŸ’Ύ Save '{current_world_name}'" if current_file else "πŸ’Ύ Save Live State as New..."
445
- if st.button(save_button_label, key="sidebar_save_world", help="Save the current state of the 3D world."):
446
- with st.spinner("Requesting world state from client..."):
447
- # Ask JS for the current world state
448
- js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_js", want_result=True)
449
-
450
- if js_world_state_str:
451
- try:
452
- world_data_dict = json.loads(js_world_state_str)
453
- if isinstance(world_data_dict, dict):
454
- save_filename = ""
455
- if current_file: # Overwrite existing file
456
- save_filename = current_file
457
- op_text = f"Overwriting '{current_world_name}'..."
458
- else: # Save as new - prompt for name? Use session state?
459
- world_name_to_save = st.session_state.get("new_world_name", "NewWorld") # Get name from state
460
- if not world_name_to_save.strip(): world_name_to_save = "NewWorld"
461
- save_filename = generate_world_save_filename(st.session_state.username, world_name_to_save)
462
- op_text = f"Saving as new version '{world_name_to_save}'..."
463
-
464
- with st.spinner(op_text):
465
- if save_world_to_md(save_filename, world_data_dict):
466
- st.success(f"World saved as {save_filename}")
467
- add_action_log(f"Saved world: {save_filename}")
468
- st.session_state.current_world_file = save_filename # Track saved file
469
- st.rerun() # Refresh sidebar list
470
- else:
471
- st.error("Failed to save world state to file.")
472
- else:
473
- st.error("Received invalid world state format from client.")
474
- except json.JSONDecodeError:
475
- st.error("Failed to decode world state received from client.")
476
- except Exception as e:
477
- st.error(f"Error during save process: {e}")
478
- else:
479
- st.warning("Did not receive world state from client.")
480
- # Input for "Save As New" name (used if no file loaded)
481
- if not current_file:
482
- st.text_input("World Name for Save:", key="new_world_name", value=st.session_state.get('new_world_name', 'MyWorld'))
483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
 
485
  # --- World Load ---
486
  st.markdown("---")
487
  st.header("πŸ“‚ Load World")
488
  saved_worlds = get_saved_worlds()
489
 
490
- if not saved_worlds:
491
- st.caption("No saved worlds found.")
492
  else:
493
- st.caption("Click to load a saved state.")
494
- # Display load buttons and download links
495
- cols = st.columns([4, 1]) # Columns for name and download button
496
- with cols[0]: st.write("**Name** (User, Time)")
497
- with cols[1]: st.write("**DL**")
498
- display_limit = 15
499
- for i, world_info in enumerate(saved_worlds):
500
  f_basename = os.path.basename(world_info['filename'])
501
- f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename) # Reconstruct full path
502
- display_name = world_info.get('name', f_basename)
503
- user = world_info.get('user', 'N/A')
504
- timestamp = world_info.get('timestamp', 'N/A')
505
  display_text = f"{display_name} ({user}, {timestamp})"
506
 
507
- # Logic for expander
508
- is_last_displayed = i == display_limit -1
509
- show_expander_trigger = len(saved_worlds) > display_limit and is_last_displayed
510
-
511
  container = st
512
- if show_expander_trigger and i == display_limit :
513
- container = st.expander(f"Show {len(saved_worlds)-display_limit} more...")
514
-
515
- if i < display_limit or show_expander_trigger : # Render items within limit or inside expander
516
- with container:
517
- # Special handling to render first item inside expander if triggered
518
- if show_expander_trigger and i == display_limit:
519
- col1_exp, col2_exp, col3_exp = st.columns([3, 1, 1]);
520
- with col1_exp: btn_load = st.button(f"{display_text}", key=f"load_{f_basename}", help=f"Load {f_basename}", use_container_width=True)
521
- with col3_exp: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
522
- # Render items normally (either before limit or after limit inside expander)
523
- elif i < display_limit or not show_expander_trigger:
524
- col1, col2, col3 = st.columns([3, 1, 1]) # Added extra col for button
525
- with col1: st.write(f"<small>{display_text}</small>", unsafe_allow_html=True)
526
- with col2: btn_load = st.button("Load", key=f"load_{f_basename}", help=f"Load {f_basename}")
527
- with col3: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
528
- else: # Render remaining items inside expander
529
- for world_info_more in saved_worlds[display_limit+1:]: # Iterate remaining
530
- f_bn_more=os.path.basename(world_info_more['filename']); f_fp_more=os.path.join(SAVED_WORLDS_DIR, f_bn_more); dn_more=world_info_more.get('name',f_bn_more); u_more=world_info_more.get('user','N/A'); ts_more=world_info_more.get('timestamp','N/A')
531
- disp_more = f"{dn_more} ({u_more}, {ts_more})"
532
- colA, colB, colC = st.columns([3, 1, 1]);
533
- with colA: st.write(f"<small>{disp_more}</small>", unsafe_allow_html=True)
534
- with colB: btn_load_more = st.button("Load", key=f"load_{f_bn_more}", help=f"Load {f_bn_more}")
535
- with colC: st.markdown(get_download_link(f_fp_more, "md"), unsafe_allow_html=True)
536
- # Handle button click for these items
537
- if btn_load_more:
538
- print(f"Load button clicked for: {f_bn_more}")
539
- world_dict = load_world_from_md(f_bn_more)
540
- if world_dict is not None:
541
- st.session_state.world_to_load_data = world_dict # Store data to send to JS
542
- st.session_state.current_world_file = f_bn_more
543
- add_action_log(f"Loading world: {f_bn_more}")
544
- st.rerun() # Trigger rerun to send data via injection/call
545
- else: st.error(f"Failed to parse world file: {f_bn_more}")
546
- break # Stop outer loop after handling expander fully
547
-
548
- # Handle button click for items outside/at start of expander
549
- if btn_load:
550
  print(f"Load button clicked for: {f_basename}")
551
  world_dict = load_world_from_md(f_basename)
552
  if world_dict is not None:
@@ -556,32 +569,31 @@ def render_sidebar():
556
  st.rerun()
557
  else: st.error(f"Failed to parse world file: {f_basename}")
558
 
 
 
 
 
 
 
 
559
  # --- Build Tools ---
560
  st.markdown("---")
561
  st.header("πŸ—οΈ Build Tools")
562
  st.caption("Select an object to place.")
563
- # Use Radio Buttons for Tool Selection
564
- tool_options = list(TOOLS_MAP.keys()) # "None", "Tree", "Rock", etc.
565
  current_tool_name = st.session_state.get('selected_object', 'None')
 
 
566
 
567
  selected_tool = st.radio(
568
- "Select Tool:",
569
- options=tool_options,
570
- index=tool_options.index(current_tool_name) if current_tool_name in tool_options else 0,
571
- format_func=lambda name: f"{TOOLS_MAP.get(name, '')} {name}", # Show emoji + name
572
- key="tool_selector_radio",
573
- horizontal=True # Make radio horizontal if preferred
574
  )
575
-
576
- # Handle tool change
577
  if selected_tool != current_tool_name:
578
  st.session_state.selected_object = selected_tool
579
  add_action_log(f"Selected tool: {selected_tool}")
580
- # CORRECTED: Use streamlit_js_eval instead of sync
581
- # This sends the update to JS without needing a return value immediately
582
- streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
583
- # Rerun to potentially update other UI elements if needed, though maybe optional
584
- # if only JS state needs update for the tool. Let's keep it for now.
585
  st.rerun()
586
 
587
  # --- Action Log ---
@@ -589,26 +601,21 @@ def render_sidebar():
589
  st.header("πŸ“ Action Log")
590
  log_container = st.container(height=200)
591
  with log_container:
592
- if 'action_log' in st.session_state and st.session_state.action_log:
593
- # Display log entries (newest first)
594
- st.code('\n'.join(st.session_state.action_log), language="log")
595
- else:
596
- st.caption("No actions recorded yet.")
597
-
598
 
599
  # --- Voice/User ---
600
  st.markdown("---")
601
  st.header("πŸ—£οΈ Voice & User")
602
- current_username = st.session_state.get('username', list(FUN_USERNAMES.keys())[0]) if FUN_USERNAMES else "DefaultUser"
603
- username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]; current_index = 0
604
- try: current_index = username_options.index(current_username)
605
- except ValueError: current_index = 0
 
606
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
607
  if new_username != st.session_state.username:
608
- st.session_state.username = new_username;
609
- st.session_state.tts_voice = FUN_USERNAMES.get(new_username, DEFAULT_TTS_VOICE);
610
- save_username(st.session_state.username)
611
- # Add action log entry for name change?
612
  add_action_log(f"Username changed to {new_username}")
613
  st.rerun()
614
  st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
@@ -618,50 +625,37 @@ def render_main_content():
618
  """Renders the main content area with tabs."""
619
  st.title(f"{Site_Name} - User: {st.session_state.username}")
620
 
621
- # Check if we need to send newly loaded world data to JS
622
- world_data_to_load = st.session_state.pop('world_to_load_data', None) # Use pop to consume
623
  if world_data_to_load is not None:
624
  print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
625
- # Call the JS function to load the state
626
- sync(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
 
627
  st.toast("World loaded in 3D view.", icon="πŸ”„")
628
 
629
-
630
- # Check for object placement data coming back from JS
631
- # Use streamlit_js_eval to define a Python function callable from JS
632
- # Note: This function runs in the main Python thread when called by JS.
633
- # It should be quick and ideally just set session state.
634
- placed_object_data = streamlit_js_eval(
635
  js_code="""
636
- // Define function in JS global scope if not already defined
637
- if (typeof window.sendPlacedObjectToPython !== 'function') {
638
  window.sendPlacedObjectToPython = (objectData) => {
639
- // Call the Python function 'handle_js_object_placed'
640
- // Pass data as a JSON string for safety
641
- streamlit_js_eval(`handle_js_object_placed(json_data='${JSON.stringify(objectData)}')`, key='js_place_event_handler');
642
  }
643
  }
644
- null; // Return null from initial setup call
645
  """,
646
- key="setup_js_place_event_handler"
647
  )
648
 
649
- # Check if the handler function was called (by checking if its key is in session_state)
650
- if 'js_place_event_handler' in st.session_state:
651
- # The handle_js_object_placed function (defined globally in Python)
652
- # would have been called and stored data in st.session_state.js_object_placed_data
653
- # Process data stored by the callback
654
- processed_data = st.session_state.pop('js_object_placed_data', None) # Use pop to consume
655
- if processed_data:
656
- print(f"Processing stored placed object data: {processed_data.get('obj_id')}")
657
- # Action log entry was already added in the handler function.
658
- # Object exists client-side. Server doesn't need to store it live, only on Save.
659
- # Might trigger a mini-rerun if processing changes UI elements? Unlikely here.
660
- # st.rerun() # Avoid rerun if possible, log entry is enough for now
661
- pass # Main processing (adding to server state dict) removed
662
-
663
- # Important: Clear the trigger key from session_state so it doesn't re-process
664
- del st.session_state['js_place_event_handler']
665
 
666
 
667
  # Define Tabs
@@ -670,26 +664,25 @@ def render_main_content():
670
  # --- World Builder Tab ---
671
  with tab_world:
672
  st.header("Shared 3D World")
673
- st.caption("Place objects using sidebar tools. Use Sidebar to Save/Load.")
674
  current_file_basename = st.session_state.get('current_world_file', None)
675
  if current_file_basename:
676
- full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
677
- if os.path.exists(full_path_for_parse): parsed = parse_world_filename(full_path_for_parse); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
678
  else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
679
- else: st.info("Live State Active (Save to persist changes)")
680
 
681
  # Embed HTML Component
682
  html_file_path = 'index.html'
683
  try:
684
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
685
- # Inject state needed by JS (Tool, Constants)
686
- # Initial world state is loaded via explicit JS call if world_to_load_data exists
687
  js_injection_script = f"""<script>
688
  window.USERNAME = {json.dumps(st.session_state.username)};
689
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
690
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
691
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
692
- // INITIAL_WORLD_OBJECTS injection removed - handled by loadWorldState JS call
693
  console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE }});
694
  </script>"""
695
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
@@ -699,29 +692,30 @@ def render_main_content():
699
 
700
  # --- Chat Tab ---
701
  with tab_chat:
702
- st.header(f"πŸ’¬ Chat") # Simplified header
703
- # Load chat history
704
  chat_history_list = st.session_state.get('chat_history', [])
705
- if not chat_history_list: chat_history_list = asyncio.run(load_chat_history()) # Load if empty
706
-
707
  chat_container = st.container(height=500)
708
  with chat_container:
709
  if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:])))
710
  else: st.caption("No chat messages yet.")
711
 
712
- # Chat Input
 
 
 
713
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
714
- send_button_clicked = st.button("Send Chat", key="send_chat_button")
715
- # Removed autosend for simplicity, can be added back
716
- if send_button_clicked:
717
- message_to_send = message_value
 
718
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
719
  st.session_state.last_message = message_to_send
720
  voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
721
- # Save entry and generate audio (fire-and-forget)
722
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
723
- st.session_state.message_input = "" # Clear state for next run
724
- st.rerun()
725
  elif send_button_clicked: st.toast("Message empty or same as last.")
726
 
727
  # --- PDF Tab ---
@@ -740,10 +734,9 @@ def render_main_content():
740
  st.subheader("πŸ’Ύ World Management")
741
  current_file_basename = st.session_state.get('current_world_file', None)
742
 
743
- # Save Current Version Button (if a world is loaded)
744
  if current_file_basename:
745
  full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
746
- save_label = f"Save Changes to '{current_file_basename}'" # Default label
747
  if os.path.exists(full_path_for_parse): parsed = parse_world_filename(full_path_for_parse); save_label = f"Save Changes to '{parsed['name']}'"
748
  if st.button(save_label, key="save_current_world_files", help=f"Overwrite '{current_file_basename}'"):
749
  if not os.path.exists(full_path_for_parse): st.error(f"Cannot save, file missing.")
@@ -754,15 +747,13 @@ def render_main_content():
754
  try:
755
  world_data_dict = json.loads(js_world_state_str)
756
  if isinstance(world_data_dict, dict):
757
- if save_world_to_md(current_file_basename, world_data_dict):
758
- st.success("Current world saved!"); add_action_log(f"Saved world: {current_file_basename}")
759
  else: st.error("Failed to save.")
760
  else: st.error("Invalid format from client.")
761
  except json.JSONDecodeError: st.error("Failed to decode state from client.")
762
  else: st.warning("Did not receive world state from client.")
763
- else: st.info("Load a world from the sidebar to enable 'Save Changes'.")
764
 
765
- # Save As New Version Section
766
  st.subheader("Save As New Version")
767
  new_name_files = st.text_input("World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
768
  if st.button("πŸ’Ύ Save Current View as New Version", key="save_new_version_files"):
@@ -785,7 +776,8 @@ def render_main_content():
785
  else: st.warning("Did not receive world state from client.")
786
  else: st.warning("Please enter a name.")
787
 
788
- # File Deletion
 
789
  st.subheader("πŸ—‘οΈ Delete Files")
790
  st.warning("Deletion is permanent!", icon="⚠️")
791
  col_del1, col_del2, col_del3, col_del4 = st.columns(4)
@@ -798,11 +790,10 @@ def render_main_content():
798
  with col_del4:
799
  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()
800
 
801
- # Download Archives
802
  st.subheader("πŸ“¦ Download Archives")
803
  col_zip1, col_zip2, col_zip3 = st.columns(3)
804
  with col_zip1:
805
- 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")
806
  with col_zip2:
807
  if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
808
  with col_zip3:
@@ -811,7 +802,8 @@ def render_main_content():
811
  if zip_files:
812
  st.caption("Existing Zip Files:")
813
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
814
- else: st.caption("No zip archives found.")
 
815
 
816
 
817
  # ==============================================================================
@@ -821,36 +813,28 @@ def render_main_content():
821
  def initialize_app():
822
  """Handles session init and initial world load."""
823
  init_session_state()
824
- # Load username here after init
825
  if not st.session_state.username:
826
  loaded_user = load_username()
827
- if loaded_user and loaded_user in FUN_USERNAMES:
828
- st.session_state.username = loaded_user
829
- st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
830
- else:
831
- st.session_state.username = random.choice(list(FUN_USERNAMES.keys())) if FUN_USERNAMES else "User"
832
- st.session_state.tts_voice = FUN_USERNAMES.get(st.session_state.username, DEFAULT_TTS_VOICE)
833
- save_username(st.session_state.username) # Save the newly assigned name
834
-
835
- # Load initial world state (most recent) if not already handled by load button click
836
- # Check 'world_to_load_data' first - if present, it means a load was just triggered.
837
- if 'world_to_load_data' not in st.session_state or st.session_state.world_to_load_data is None:
838
- # Only load from file if no specific world load is pending
839
- if st.session_state.get('current_world_file') is None: # And no world currently selected
840
- print("Attempting initial load of most recent world...")
841
- saved_worlds = get_saved_worlds()
842
- if saved_worlds:
843
- latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
844
- print(f"Loading most recent world on startup: {latest_world_file_basename}")
845
- world_dict = load_world_from_md(latest_world_file_basename)
846
- if world_dict is not None:
847
- st.session_state.world_to_load_data = world_dict # Set data to be sent to JS
848
- st.session_state.current_world_file = latest_world_file_basename # Update selection state
849
- else: print("Failed to load most recent world.")
850
- else: print("No saved worlds found for initial load.")
851
 
852
 
853
  if __name__ == "__main__":
854
- initialize_app() # Initialize state, load user, potentially load initial world data state
855
- render_sidebar() # Render sidebar UI
856
- render_main_content() # Render main UI content (includes logic to send loaded world data to JS)
 
1
+ # app.py (Full Code - Including process_pdf_tab fix)
2
  import streamlit as st
3
  import asyncio
4
+ # import websockets # Removed - Not using WS for state sync
5
  import uuid
6
  from datetime import datetime
7
  import os
 
17
  import re
18
  import pytz
19
  import shutil
20
+ from PyPDF2 import PdfReader # Keep if PDF tab is kept
21
+ import threading
22
  import json
23
  import zipfile
24
  from dotenv import load_dotenv
25
  # from streamlit_marquee import streamlit_marquee # Keep import if used
26
  from collections import defaultdict, Counter, deque # Use deque for action log
27
  # import pandas as pd # Removed dependency
28
+ from streamlit_js_eval import streamlit_js_eval, sync # Use sync was removed, re-checking - use streamlit_js_eval
29
  from PIL import Image # Needed for paste_image_component
30
 
31
  # ==============================================================================
32
  # Configuration & Constants
33
  # ==============================================================================
34
+
35
+ # πŸ› οΈ Patch asyncio for nesting
36
+ nest_asyncio.apply()
37
+
38
+ # 🎨 Page Config
39
+ st.set_page_config(
40
+ page_title="πŸ€–πŸ—οΈ Shared World Builder πŸ†",
41
+ page_icon="πŸ—οΈ",
42
+ layout="wide",
43
+ initial_sidebar_state="expanded"
44
+ )
45
 
46
  # General Constants
47
+ icons = 'πŸ€–πŸ—οΈπŸ—£οΈπŸ’Ύ'
48
+ Site_Name = 'πŸ€–πŸ—οΈ Shared World Builder πŸ—£οΈ'
49
+ START_ROOM = "World Lobby 🌍" # Used? Maybe just for chat title
50
+ MEDIA_DIR = "." # Base directory for general files
51
+ STATE_FILE = "user_state.txt" # For remembering username
52
 
53
  # User/Chat Constants
54
  FUN_USERNAMES = {
 
57
  "PixelPainter 🎨": "en-CA-ClaraNeural", "VoxelVortex πŸŒͺ️": "en-US-GuyNeural",
58
  "CosmicCrafter ✨": "en-GB-RyanNeural", "GeoGuru πŸ—ΊοΈ": "en-AU-WilliamNeural",
59
  "BlockBard 🧱": "en-CA-LiamNeural", "SoundSculptor πŸ”Š": "en-US-AnaNeural",
 
60
  }
61
+ EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values())) # Define available voices
62
+ DEFAULT_TTS_VOICE = "en-US-AriaNeural"
 
63
  CHAT_DIR = "chat_logs"
64
 
65
+ # Audio Constants
 
66
  AUDIO_CACHE_DIR = "audio_cache"
67
  AUDIO_DIR = "audio_logs"
 
68
 
69
  # World Builder Constants
70
+ SAVED_WORLDS_DIR = "saved_worlds" # Directory for MD world files
71
+ PLOT_WIDTH = 50.0 # Needed for JS injection
72
+ PLOT_DEPTH = 50.0 # Needed for JS injection
73
+ WORLD_STATE_FILE_MD_PREFIX = "🌍_" # Prefix for world save files
74
  MAX_ACTION_LOG_SIZE = 30 # Max entries in sidebar action log
75
 
76
  # File Emojis
 
94
  # --- API Keys (Placeholder) ---
95
  load_dotenv()
96
 
97
+ # --- Global State & Locks (Removed world_objects global, only client tracking if WS re-added) ---
98
+ # Lock might still be useful if background tasks modify shared resources (like file lists?) - Keep for now
99
+ app_lock = threading.Lock()
100
+ # connected_clients = set() # Removed WS client tracking
101
+
102
  # ==============================================================================
103
  # Utility Functions
104
  # ==============================================================================
 
107
  except Exception: now_aware = datetime.now(pytz.utc)
108
  return now_aware.strftime('%Y%m%d_%H%M%S')
109
 
110
+ def clean_filename_part(text, max_len=25):
111
  if not isinstance(text, str): text = "invalid_name"
112
  text = re.sub(r'\s+', '_', text); text = re.sub(r'[^\w\-.]', '', text)
113
  return text[:max_len]
114
 
115
  def run_async(async_func, *args, **kwargs):
116
+ """Runs an async function safely from a sync context using create_task or asyncio.run."""
117
  try: loop = asyncio.get_running_loop(); return loop.create_task(async_func(*args, **kwargs))
118
  except RuntimeError:
119
+ # print(f"Warning: Running async func {async_func.__name__} in new event loop.")
120
  try: return asyncio.run(async_func(*args, **kwargs))
121
  except Exception as e: print(f"Error run_async new loop: {e}"); return None
122
  except Exception as e: print(f"Error run_async schedule task: {e}"); return None
 
128
  # ==============================================================================
129
  def generate_world_save_filename(username="User", world_name="World"):
130
  """Generates filename including username, world name, timestamp."""
131
+ timestamp = get_current_time_str(); clean_user = clean_filename_part(username, 15);
132
+ clean_world = clean_filename_part(world_name, 20);
133
+ rand_hash = hashlib.md5(str(time.time()).encode()+username.encode()+world_name.encode()).hexdigest()[:4]
 
134
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
135
 
136
  def parse_world_filename(filename):
137
+ """Extracts info from filename (Name, User, Time, Hash)."""
138
  basename = os.path.basename(filename)
 
139
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
140
+ core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]; parts = core.split('_')
 
 
141
  if len(parts) >= 5 and parts[-3] == "by":
142
+ timestamp_str = parts[-2]; username = parts[-4]; world_name = " ".join(parts[:-4]); dt_obj = None
143
+ try: dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
144
+ except Exception: dt_obj = None
 
 
 
 
 
 
145
  return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
146
+ # Fallback
147
+ dt_fallback = None; try: mtime = os.path.getmtime(filename); dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
148
+ except Exception: pass
 
 
 
 
 
 
 
 
149
  return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
150
 
 
151
  def save_world_to_md(target_filename_base, world_data_dict):
152
  """Saves the provided world state dictionary to a specific MD file."""
153
+ # No global state modification, just write the provided dict
154
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
155
  print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
156
  success = False
157
+ parsed_info = parse_world_filename(save_path) # Parse final path
 
158
  timestamp_save = get_current_time_str()
159
  md_content = f"""# World State: {parsed_info['name']} by {parsed_info['user']}
160
  * **File Saved:** {timestamp_save} (UTC)
 
172
  return success
173
 
174
  def load_world_from_md(filename_base):
175
+ """Loads world state dict from an MD file (basename), returns dict or None."""
176
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
177
  print(f"Loading world state dictionary from MD file: {load_path}...")
178
  if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return None
 
199
  # ==============================================================================
200
  # User State & Session Init
201
  # ==============================================================================
202
+ def save_username(username):
203
  try:
204
  with open(STATE_FILE, 'w') as f: f.write(username)
205
  except Exception as e: print(f"Failed save username: {e}")
206
 
207
+ def load_username():
208
  if os.path.exists(STATE_FILE):
209
  try:
210
  with open(STATE_FILE, 'r') as f: return f.read().strip()
 
221
  'selected_object': 'None', # Current building tool
222
  'current_world_file': None, # Track loaded world filename (basename)
223
  'new_world_name': "MyWorld",
224
+ 'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
225
  'world_to_load_data': None, # Temp storage for state loaded from file before sending to JS
226
  'js_object_placed_data': None # Temp storage for data coming from JS place event
227
  }
 
246
  st.session_state.action_log.appendleft(f"[{timestamp}] {message}") # Add to front
247
 
248
  # ==============================================================================
249
+ # JS Communication Handler Function
250
  # ==============================================================================
251
+ # Define this at the top level so streamlit_js_eval can find it
252
+ def handle_js_object_placed(data): # Renamed arg for clarity
 
 
253
  """Callback triggered by JS when an object is placed."""
254
+ print(f"Python received object placed event data: {type(data)}") # Debug type
255
+ # Data from streamlit_js_eval might already be a dict if json_data='...' was NOT used
256
+ # Let's assume data is already a dict/list from JS object sent.
257
+ # If it comes as a string, we need json.loads()
258
+ processed_data = None
259
+ if isinstance(data, str):
260
+ try:
261
+ processed_data = json.loads(data)
262
+ except json.JSONDecodeError:
263
+ print("Failed to decode JSON data from JS object place event.")
264
+ return False # Indicate failure
265
+ elif isinstance(data, dict):
266
+ processed_data = data # Assume it's already a dict
267
+ else:
268
+ print(f"Received unexpected data type from JS place event: {type(data)}")
269
+ return False
270
+
271
+ if processed_data and 'obj_id' in processed_data and 'type' in processed_data:
272
  # Store data in session state to be processed in the main script flow
273
+ st.session_state.js_object_placed_data = processed_data
274
+ add_action_log(f"Placed {processed_data.get('type', 'object')} ({processed_data.get('obj_id', 'N/A')[:6]}...)")
 
 
 
 
275
  else:
276
+ print("Received invalid object placement data structure from JS.")
277
+ return False
278
  return True # Acknowledge receipt to JS
279
 
 
280
  # ==============================================================================
281
+ # Audio / TTS / Chat / File Handling Helpers (Keep implementations)
282
  # ==============================================================================
 
 
 
 
283
  # --- Placeholder for brevity ---
284
  def clean_text_for_tts(text): # ... implementation ...
285
  if not isinstance(text, str): return "No text"
 
423
  if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0: self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text_cleaned), 'voice': voice}; self._save_metadata(); return cache_path
424
  else: return None
425
  except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
426
+ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
427
+ st.subheader("PDF Processing Results");
428
+ if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
429
+ audio_processor = AudioProcessor();
430
+ try:
431
+ reader=PdfReader(pdf_file);
432
+ if reader.is_encrypted: st.warning("PDF is encrypted."); return
433
+ total_pages=min(len(reader.pages),max_pages);
434
+ st.write(f"Processing first {total_pages} pages of '{pdf_file.name}'...");
435
+ texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
436
+ def process_page_sync(page_num, page_text):
437
+ async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
438
+ try: audio_path = asyncio.run(run_async_audio())
439
+ if audio_path:
440
+ with results_lock: audios[page_num] = audio_path
441
+ except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
442
+ for i in range(total_pages):
443
+ try: page = reader.pages[i]; text = page.extract_text();
444
+ if text and text.strip(): texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
445
+ else: texts[i] = "[No text extracted]"
446
+ except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
447
+ progress_bar = st.progress(0.0, text="Processing pages...")
448
+ total_threads = len(page_threads); start_join_time = time.time()
449
+ while any(t.is_alive() for t in page_threads):
450
+ 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
451
+ progress_bar.progress(min(progress, 1.0), text=f"Processed {completed_threads}/{total_threads} pages...")
452
+ if time.time() - start_join_time > 600: print("PDF processing timed out."); break
453
+ time.sleep(0.5)
454
+ progress_bar.progress(1.0, text="Processing complete.")
455
+ for i in range(total_pages):
456
+ with st.expander(f"Page {i+1}"):
457
+ st.markdown(texts.get(i, "[Error getting text]"))
458
+ audio_file = audios.get(i)
459
+ if audio_file: play_and_download_audio(audio_file)
460
+ else: st.caption("Audio generation failed or was skipped.")
461
+ except ImportError: st.error("PyPDF2 library needed for PDF processing.")
462
+ except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
463
 
464
 
465
  # ==============================================================================
 
472
  st.header("πŸ’Ύ World Management")
473
 
474
  # --- World Save ---
 
475
  current_file = st.session_state.get('current_world_file')
476
+ current_world_name = "Live State"
477
  if current_file:
478
+ parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file)) # Parse full path for name
479
+ current_world_name = parsed.get("name", current_file)
480
+
481
+ # Input for Save name (used for both Save and Save As New)
482
+ world_save_name = st.text_input(
483
+ "World Name for Save:",
484
+ key="world_save_name_input",
485
+ value=current_world_name if current_file else st.session_state.get('new_world_name', 'MyWorld'),
486
+ help="Enter name to save as new, or keep current name to overwrite."
487
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
 
489
+ if st.button("πŸ’Ύ Save Current World View", key="sidebar_save_world", help="Saves the current 3D view state to a file."):
490
+ if not world_save_name.strip():
491
+ st.warning("Please enter a World Name before saving.")
492
+ else:
493
+ with st.spinner("Requesting world state & saving..."):
494
+ js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_sidebar_save", want_result=True)
495
+ if js_world_state_str:
496
+ try:
497
+ world_data_dict = json.loads(js_world_state_str)
498
+ if isinstance(world_data_dict, dict):
499
+ # Decide filename: Overwrite if name matches current loaded file's derived name, else new file
500
+ filename_to_save = ""
501
+ is_overwrite = False
502
+ if current_file:
503
+ parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
504
+ # Check if input name matches the name part of the current file
505
+ if world_save_name == parsed_current.get('name', ''):
506
+ filename_to_save = current_file
507
+ is_overwrite = True
508
+
509
+ if not filename_to_save: # Create new filename if not overwriting
510
+ filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
511
+
512
+ if save_world_to_md(filename_to_save, world_data_dict):
513
+ action = "Overwritten" if is_overwrite else "Saved new"
514
+ st.success(f"World {action}: {filename_to_save}")
515
+ add_action_log(f"Saved world: {filename_to_save}")
516
+ st.session_state.current_world_file = filename_to_save # Track saved file
517
+ st.session_state.new_world_name = "MyWorld" # Reset default potentially
518
+ # Clear the input only if it was a new save? Optional.
519
+ # st.session_state.world_save_name_input = current_world_name if is_overwrite else "MyWorld"
520
+ st.rerun() # Refresh sidebar list
521
+ else: st.error("Failed to save world state.")
522
+ else: st.error("Invalid state format from client.")
523
+ except json.JSONDecodeError: st.error("Failed to decode state from client.")
524
+ except Exception as e: st.error(f"Save error: {e}")
525
+ else: st.warning("Did not receive world state from client.")
526
 
527
  # --- World Load ---
528
  st.markdown("---")
529
  st.header("πŸ“‚ Load World")
530
  saved_worlds = get_saved_worlds()
531
 
532
+ if not saved_worlds: st.caption("No saved worlds found.")
 
533
  else:
534
+ st.caption("Click button to load state.")
535
+ cols_header = st.columns([3, 1, 1])
536
+ with cols_header[0]: st.write("**Name** (User, Time)")
537
+ with cols_header[1]: st.write("**Load**")
538
+ with cols_header[2]: st.write("**DL**")
539
+ display_limit = 15; displayed_count = 0
540
+ for world_info in saved_worlds:
541
  f_basename = os.path.basename(world_info['filename'])
542
+ f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename)
543
+ display_name = world_info.get('name', f_basename); user = world_info.get('user', 'N/A'); timestamp = world_info.get('timestamp', 'N/A')
 
 
544
  display_text = f"{display_name} ({user}, {timestamp})"
545
 
546
+ # Use expander for items beyond limit
 
 
 
547
  container = st
548
+ if displayed_count >= display_limit:
549
+ if 'expander_open' not in st.session_state: st.session_state.expander_open = False
550
+ container = st.expander(f"Show {len(saved_worlds)-display_limit} more...", expanded=st.session_state.expander_open)
551
+ if not container: break # Should not happen, safety
552
+
553
+ with container: # Display inside sidebar or expander
554
+ col1, col2, col3 = st.columns([3, 1, 1])
555
+ with col1: st.write(f"<small>{display_text}</small>", unsafe_allow_html=True)
556
+ with col2:
557
+ # Disable button if already loaded
558
+ is_current = (st.session_state.get('current_world_file') == f_basename)
559
+ btn_load = st.button("Load", key=f"load_{f_basename}", help=f"Load {f_basename}", disabled=is_current)
560
+ with col3: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
561
+
562
+ if btn_load: # Handle click if not disabled
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  print(f"Load button clicked for: {f_basename}")
564
  world_dict = load_world_from_md(f_basename)
565
  if world_dict is not None:
 
569
  st.rerun()
570
  else: st.error(f"Failed to parse world file: {f_basename}")
571
 
572
+ displayed_count += 1
573
+ if displayed_count >= display_limit and len(saved_worlds) > display_limit:
574
+ # Toggle expander state if needed for next items - complex logic, maybe remove expander for simplicity?
575
+ # For now, just rely on the single expander after the limit is hit.
576
+ pass
577
+
578
+
579
  # --- Build Tools ---
580
  st.markdown("---")
581
  st.header("πŸ—οΈ Build Tools")
582
  st.caption("Select an object to place.")
583
+ tool_options = list(TOOLS_MAP.keys())
 
584
  current_tool_name = st.session_state.get('selected_object', 'None')
585
+ try: tool_index = tool_options.index(current_tool_name)
586
+ except ValueError: tool_index = 0 # Default to None/index 0
587
 
588
  selected_tool = st.radio(
589
+ "Select Tool:", options=tool_options, index=tool_index,
590
+ format_func=lambda name: f"{TOOLS_MAP.get(name, '')} {name}",
591
+ key="tool_selector_radio", horizontal=True
 
 
 
592
  )
 
 
593
  if selected_tool != current_tool_name:
594
  st.session_state.selected_object = selected_tool
595
  add_action_log(f"Selected tool: {selected_tool}")
596
+ run_async(lambda tool=selected_tool: streamlit_js_eval(f"updateSelectedObjectType({json.dumps(tool)});", key=f"update_tool_js_{tool}"))
 
 
 
 
597
  st.rerun()
598
 
599
  # --- Action Log ---
 
601
  st.header("πŸ“ Action Log")
602
  log_container = st.container(height=200)
603
  with log_container:
604
+ log_entries = st.session_state.get('action_log', [])
605
+ if log_entries: st.code('\n'.join(log_entries), language="log")
606
+ else: st.caption("No actions recorded yet.")
 
 
 
607
 
608
  # --- Voice/User ---
609
  st.markdown("---")
610
  st.header("πŸ—£οΈ Voice & User")
611
+ current_username = st.session_state.get('username', "DefaultUser")
612
+ username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
613
+ current_index = 0;
614
+ if current_username in username_options: try: current_index = username_options.index(current_username)
615
+ except ValueError: pass
616
  new_username = st.selectbox("Change Name/Voice", options=username_options, index=current_index, key="username_select", format_func=lambda x: x.split(" ")[0])
617
  if new_username != st.session_state.username:
618
+ 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)
 
 
 
619
  add_action_log(f"Username changed to {new_username}")
620
  st.rerun()
621
  st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
 
625
  """Renders the main content area with tabs."""
626
  st.title(f"{Site_Name} - User: {st.session_state.username}")
627
 
628
+ # Check if world data needs to be sent to JS
629
+ world_data_to_load = st.session_state.pop('world_to_load_data', None)
630
  if world_data_to_load is not None:
631
  print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
632
+ # Use sync version of streamlit_js_eval if we need confirmation or it simplifies flow
633
+ # However, just calling the JS function might be enough if JS handles errors.
634
+ streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
635
  st.toast("World loaded in 3D view.", icon="πŸ”„")
636
 
637
+ # Set up the mechanism for JS to call Python when an object is placed
638
+ streamlit_js_eval(
 
 
 
 
639
  js_code="""
640
+ if (!window.sendPlacedObjectToPython) {
 
641
  window.sendPlacedObjectToPython = (objectData) => {
642
+ // Call Python function 'handle_js_object_placed' via its unique key
643
+ streamlit_js_eval(`handle_js_object_placed(${JSON.stringify(objectData)})`, key='js_place_event_handler');
 
644
  }
645
  }
 
646
  """,
647
+ key="setup_js_place_event_handler" # Key for the setup code itself
648
  )
649
 
650
+ # Check if the Python handler function was triggered in the previous interaction
651
+ # Note: The Python function `handle_js_object_placed` should have been defined globally
652
+ # and it likely stored data in st.session_state.js_object_placed_data
653
+ placed_data = st.session_state.pop('js_object_placed_data', None) # Use pop to consume
654
+ if placed_data:
655
+ print(f"Processed stored placed object data: {placed_data.get('obj_id')}")
656
+ # Action log already added in handle_js_object_placed.
657
+ # No further action needed here in this simplified model (no server state update).
658
+ pass
 
 
 
 
 
 
 
659
 
660
 
661
  # Define Tabs
 
664
  # --- World Builder Tab ---
665
  with tab_world:
666
  st.header("Shared 3D World")
667
+ st.caption("Place objects using sidebar tools. Use Sidebar/Files tab to Save/Load.")
668
  current_file_basename = st.session_state.get('current_world_file', None)
669
  if current_file_basename:
670
+ full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
671
+ if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
672
  else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
673
+ else: st.info("Live State Active (Save to persist)")
674
 
675
  # Embed HTML Component
676
  html_file_path = 'index.html'
677
  try:
678
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
679
+ # Inject state needed by JS
 
680
  js_injection_script = f"""<script>
681
  window.USERNAME = {json.dumps(st.session_state.username)};
682
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
683
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
684
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
685
+ // Initial world state is now loaded via loadWorldState() JS call triggered by Python
686
  console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE }});
687
  </script>"""
688
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
 
692
 
693
  # --- Chat Tab ---
694
  with tab_chat:
695
+ st.header(f"πŸ’¬ Chat")
 
696
  chat_history_list = st.session_state.get('chat_history', [])
697
+ if not chat_history_list: chat_history_list = asyncio.run(load_chat_history())
 
698
  chat_container = st.container(height=500)
699
  with chat_container:
700
  if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:])))
701
  else: st.caption("No chat messages yet.")
702
 
703
+ # Define callback for chat input clear
704
+ def clear_chat_input_callback():
705
+ st.session_state.message_input = ""
706
+
707
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
708
+ send_button_clicked = st.button("Send Chat", key="send_chat_button", on_click=clear_chat_input_callback)
709
+ # Removed autosend for simplicity
710
+
711
+ if send_button_clicked: # Process only on button click now
712
+ message_to_send = message_value # Value before potential clear by callback
713
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
714
  st.session_state.last_message = message_to_send
715
  voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
716
+ # Use run_async for background tasks
717
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
718
+ # Rerun is handled implicitly by button click + callback
 
719
  elif send_button_clicked: st.toast("Message empty or same as last.")
720
 
721
  # --- PDF Tab ---
 
734
  st.subheader("πŸ’Ύ World Management")
735
  current_file_basename = st.session_state.get('current_world_file', None)
736
 
 
737
  if current_file_basename:
738
  full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
739
+ save_label = f"Save Changes to '{current_file_basename}'"
740
  if os.path.exists(full_path_for_parse): parsed = parse_world_filename(full_path_for_parse); save_label = f"Save Changes to '{parsed['name']}'"
741
  if st.button(save_label, key="save_current_world_files", help=f"Overwrite '{current_file_basename}'"):
742
  if not os.path.exists(full_path_for_parse): st.error(f"Cannot save, file missing.")
 
747
  try:
748
  world_data_dict = json.loads(js_world_state_str)
749
  if isinstance(world_data_dict, dict):
750
+ 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}")
 
751
  else: st.error("Failed to save.")
752
  else: st.error("Invalid format from client.")
753
  except json.JSONDecodeError: st.error("Failed to decode state from client.")
754
  else: st.warning("Did not receive world state from client.")
755
+ else: st.info("Load a world or use 'Save As New Version' below.")
756
 
 
757
  st.subheader("Save As New Version")
758
  new_name_files = st.text_input("World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
759
  if st.button("πŸ’Ύ Save Current View as New Version", key="save_new_version_files"):
 
776
  else: st.warning("Did not receive world state from client.")
777
  else: st.warning("Please enter a name.")
778
 
779
+ # Removed Server Status Section
780
+
781
  st.subheader("πŸ—‘οΈ Delete Files")
782
  st.warning("Deletion is permanent!", icon="⚠️")
783
  col_del1, col_del2, col_del3, col_del4 = st.columns(4)
 
790
  with col_del4:
791
  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()
792
 
 
793
  st.subheader("πŸ“¦ Download Archives")
794
  col_zip1, col_zip2, col_zip3 = st.columns(3)
795
  with col_zip1:
796
+ if st.button("Zip Worlds"): create_zip_of_files(glob.glob(os.path.join(SAVED_WORLDS_DIR, "*.md")), "Worlds")
797
  with col_zip2:
798
  if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
799
  with col_zip3:
 
802
  if zip_files:
803
  st.caption("Existing Zip Files:")
804
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
805
+ else:
806
+ st.caption("No zip archives found.")
807
 
808
 
809
  # ==============================================================================
 
813
  def initialize_app():
814
  """Handles session init and initial world load."""
815
  init_session_state()
816
+ # Load username
817
  if not st.session_state.username:
818
  loaded_user = load_username()
819
+ if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
820
+ else: st.session_state.username = random.choice(list(FUN_USERNAMES.keys())) if FUN_USERNAMES else "User"; st.session_state.tts_voice = FUN_USERNAMES.get(st.session_state.username, DEFAULT_TTS_VOICE); save_username(st.session_state.username)
821
+
822
+ # Load initial world state (most recent) if no specific world load is pending from a button click
823
+ if st.session_state.get('world_to_load_data') is None and st.session_state.get('current_world_file') is None:
824
+ print("Attempting initial load of most recent world...")
825
+ saved_worlds = get_saved_worlds()
826
+ if saved_worlds:
827
+ latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
828
+ print(f"Loading most recent world on startup: {latest_world_file_basename}")
829
+ world_dict = load_world_from_md(latest_world_file_basename)
830
+ if world_dict is not None:
831
+ st.session_state.world_to_load_data = world_dict # Set data to be sent to JS
832
+ st.session_state.current_world_file = latest_world_file_basename # Update selection state
833
+ else: print("Failed to load most recent world.")
834
+ else: print("No saved worlds found for initial load.")
 
 
 
 
 
 
 
 
835
 
836
 
837
  if __name__ == "__main__":
838
+ initialize_app() # Initialize state, user, potentially queue initial world load data
839
+ render_sidebar() # Render sidebar UI (includes load buttons)
840
+ render_main_content() # Render main UI (includes logic to send loaded world data to JS)