awacke1 commited on
Commit
060a6e8
Β·
verified Β·
1 Parent(s): d21bd62

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +250 -155
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # app.py
2
  import streamlit as st
3
  import asyncio
4
  import uuid
@@ -21,7 +21,7 @@ import threading
21
  import json
22
  import zipfile
23
  from dotenv import load_dotenv
24
- # from streamlit_marquee import streamlit_marquee # Can uncomment if marquee is used
25
  from collections import defaultdict, Counter, deque
26
  from streamlit_js_eval import streamlit_js_eval # Correct import
27
  from PIL import Image
@@ -59,7 +59,7 @@ PLOT_DEPTH = 50.0
59
  WORLD_STATE_FILE_MD_PREFIX = "🌍_"
60
  MAX_ACTION_LOG_SIZE = 30
61
 
62
- # User/Chat Constants (Re-added)
63
  FUN_USERNAMES = {
64
  "BuilderBot πŸ€–": "en-US-AriaNeural", "WorldWeaver πŸ•ΈοΈ": "en-US-JennyNeural",
65
  "Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
@@ -82,48 +82,69 @@ PRIMITIVE_MAP = {
82
  TOOLS_MAP = {"None": "🚫"}
83
  TOOLS_MAP.update({name: emoji for emoji, name in PRIMITIVE_MAP.items()})
84
 
85
- # --- Directories ---
86
  for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
87
  os.makedirs(d, exist_ok=True)
88
 
89
  # --- API Keys (Placeholder) ---
90
  load_dotenv()
91
 
 
 
 
92
  # ==============================================================================
93
  # Utility Functions
94
  # ==============================================================================
95
 
96
  def get_current_time_str(tz='UTC'):
 
97
  try:
98
  timezone = pytz.timezone(tz)
99
  now_aware = datetime.now(timezone)
100
- except Exception:
 
 
 
101
  now_aware = datetime.now(pytz.utc)
102
  return now_aware.strftime('%Y%m%d_%H%M%S')
103
 
104
  def clean_filename_part(text, max_len=25):
 
105
  if not isinstance(text, str): text = "invalid_name"
106
  text = re.sub(r'\s+', '_', text)
107
  text = re.sub(r'[^\w\-.]', '', text)
108
  return text[:max_len]
109
 
110
  def run_async(async_func, *args, **kwargs):
111
- """Runs an async function safely from a sync context."""
 
 
112
  try:
113
  loop = asyncio.get_running_loop()
 
114
  return loop.create_task(async_func(*args, **kwargs))
115
- except RuntimeError:
116
- try: return asyncio.run(async_func(*args, **kwargs))
117
- except Exception as e: print(f"Error run_async new loop: {e}"); return None
118
- except Exception as e: print(f"Error run_async schedule task: {e}"); return None
 
 
 
 
 
 
 
119
 
120
- def ensure_dir(dir_path): os.makedirs(dir_path, exist_ok=True)
 
 
121
 
122
  # ==============================================================================
123
  # World State File Handling (Markdown + JSON)
124
  # ==============================================================================
125
 
126
  def generate_world_save_filename(username="User", world_name="World"):
 
127
  timestamp = get_current_time_str()
128
  clean_user = clean_filename_part(username, 15)
129
  clean_world = clean_filename_part(world_name, 20)
@@ -131,6 +152,7 @@ def generate_world_save_filename(username="User", world_name="World"):
131
  return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
132
 
133
  def parse_world_filename(filename):
 
134
  basename = os.path.basename(filename)
135
  if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
136
  core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
@@ -142,7 +164,8 @@ def parse_world_filename(filename):
142
  dt_obj = None
143
  try:
144
  dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
145
- except Exception: dt_obj = None
 
146
  return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
147
 
148
  # Fallback
@@ -150,11 +173,13 @@ def parse_world_filename(filename):
150
  try:
151
  mtime = os.path.getmtime(filename)
152
  dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
153
- except Exception: pass
154
- # Corrected indentation for this return statement
155
  return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
156
 
 
157
  def save_world_to_md(target_filename_base, world_data_dict):
 
158
  save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
159
  print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
160
  success = False
@@ -169,34 +194,55 @@ def save_world_to_md(target_filename_base, world_data_dict):
169
  {json.dumps(world_data_dict, indent=2)}
170
  ```"""
171
  try:
172
- ensure_dir(SAVED_WORLDS_DIR)
173
- with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
174
- print(f"World state saved successfully to {target_filename_base}"); success = True
175
- except Exception as e: print(f"Error saving world state to {save_path}: {e}")
 
 
 
176
  return success
177
 
 
178
  def load_world_from_md(filename_base):
 
179
  load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
180
  print(f"Loading world state dictionary from MD file: {load_path}...")
181
- if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return None
 
 
182
  try:
183
- with open(load_path, 'r', encoding='utf-8') as f: content = f.read()
 
 
184
  json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE)
185
- if not json_match: st.error(f"Could not find valid JSON block in {filename_base}"); return None
 
 
186
  world_data_dict = json.loads(json_match.group(1))
187
  print(f"Parsed {len(world_data_dict)} objects from {filename_base}.")
188
  return world_data_dict # Return the dictionary
189
- except json.JSONDecodeError as e: st.error(f"Invalid JSON found in {filename_base}: {e}"); return None
190
- except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return None
 
 
 
 
 
191
 
192
  def get_saved_worlds():
 
193
  try:
194
  ensure_dir(SAVED_WORLDS_DIR);
195
  world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
196
  parsed_worlds = [parse_world_filename(f) for f in world_files]
 
197
  parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
198
  return parsed_worlds
199
- except Exception as e: print(f"Error scanning saved worlds: {e}"); st.error(f"Could not scan saved worlds: {e}"); return []
 
 
 
200
 
201
  # ==============================================================================
202
  # User State & Session Init
@@ -214,6 +260,7 @@ def load_username():
214
  return None
215
 
216
  def init_session_state():
 
217
  defaults = {
218
  'last_chat_update': 0, 'message_input': "", 'audio_cache': {},
219
  'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [], 'enable_audio': True,
@@ -228,9 +275,14 @@ def init_session_state():
228
  }
229
  for k, v in defaults.items():
230
  if k not in st.session_state:
231
- if k == 'action_log': st.session_state[k] = deque(maxlen=MAX_ACTION_LOG_SIZE)
232
- else: st.session_state[k] = v
233
- # Ensure complex types initialized correctly
 
 
 
 
 
234
  if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
235
  if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
236
  if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
@@ -240,32 +292,39 @@ def init_session_state():
240
  # Action Log Helper
241
  # ==============================================================================
242
  def add_action_log(message):
 
243
  if 'action_log' not in st.session_state:
244
  st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
245
  timestamp = datetime.now().strftime("%H:%M:%S")
 
246
  st.session_state.action_log.appendleft(f"[{timestamp}] {message}")
247
 
248
  # ==============================================================================
249
  # JS Communication Handler Function
250
  # ==============================================================================
 
251
  def handle_js_object_placed(data):
252
  """Callback triggered by JS when an object is placed. Stores data in state."""
253
  print(f"Python received object placed event data: {type(data)}")
254
  processed_data = None
255
- if isinstance(data, str):
 
 
 
256
  try: processed_data = json.loads(data)
257
  except json.JSONDecodeError: print("Failed decode JSON from JS object place event."); return False
258
- elif isinstance(data, dict): processed_data = data
259
  else: print(f"Received unexpected data type from JS place event: {type(data)}"); return False
260
 
261
  if processed_data and 'obj_id' in processed_data and 'type' in processed_data:
262
  st.session_state.js_object_placed_data = processed_data # Store for main loop processing
263
  add_action_log(f"Placed {processed_data.get('type', 'object')} ({processed_data.get('obj_id', 'N/A')[:6]}...)")
 
 
264
  else: print("Received invalid object placement data structure from JS."); return False
265
- return True
266
 
267
  # ==============================================================================
268
- # Audio / TTS / Chat / File Handling Helpers
269
  # ==============================================================================
270
  # --- Text & File Helpers ---
271
  def clean_text_for_tts(text):
@@ -391,28 +450,30 @@ def delete_files(file_patterns, exclude_files=None):
391
  # --- Image Handling ---
392
  async def save_pasted_image(image, username):
393
  if not image: return None
394
- try: img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]; timestamp = format_timestamp_prefix(username); filename = f"{timestamp}_pasted_{img_hash}.png"; filepath = os.path.join(MEDIA_DIR, filename); image.save(filepath, "PNG"); print(f"Pasted image saved: {filepath}"); return filepath
 
 
395
  except Exception as e: print(f"Failed image save: {e}"); return None
396
 
397
  def paste_image_component():
398
  pasted_img = None; img_type = None
399
- paste_input_value = st.text_area("Paste Image Data Here", key="paste_input_area", height=50, value=st.session_state.get('paste_image_base64_input', ""))
 
400
  if st.button("Process Pasted Image πŸ“‹", key="process_paste_button"):
401
- st.session_state.paste_image_base64_input = paste_input_value
402
  if paste_input_value and paste_input_value.startswith('data:image'):
403
  try:
404
  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]
405
- st.image(pasted_img, caption=f"Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str
406
- st.session_state.paste_image_base64_input = ""
407
- st.rerun()
408
- except ImportError: st.error("Pillow library needed for image pasting.")
409
- 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
410
- else: st.warning("No valid image data pasted."); st.session_state.paste_image_base64 = ""; st.session_state.paste_image_base64_input = paste_input_value
411
- processed_b64 = st.session_state.get('paste_image_base64', '')
412
- if processed_b64:
413
- try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
414
- except Exception: return None
415
- return None
416
 
417
  # --- PDF Processing ---
418
  class AudioProcessor:
@@ -435,47 +496,83 @@ class AudioProcessor:
435
 
436
  def process_pdf_tab(pdf_file, max_pages, voice):
437
  st.subheader("PDF Processing Results")
438
- if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
 
 
439
  audio_processor = AudioProcessor()
440
  try:
441
- reader=PdfReader(pdf_file);
442
- if reader.is_encrypted: st.warning("PDF is encrypted."); return
443
- total_pages_in_pdf = len(reader.pages); pages_to_process = min(total_pages_in_pdf, max_pages);
 
 
 
444
  st.write(f"Processing first {pages_to_process} of {total_pages_in_pdf} pages from '{pdf_file.name}'...")
445
  texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
446
 
447
  def process_page_sync(page_num, page_text):
448
  async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
449
  try:
450
- audio_path = asyncio.run(run_async_audio())
451
- if audio_path:
452
- with results_lock: audios[page_num] = audio_path
453
- except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
 
454
 
 
455
  for i in range(pages_to_process):
456
- try: page = reader.pages[i]; text = page.extract_text();
457
- if text and text.strip(): texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
458
- else: texts[i] = "[No text extracted]"
459
- except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
 
 
 
 
 
 
 
 
 
 
460
 
 
461
  progress_bar = st.progress(0.0, text="Processing pages...")
462
- total_threads = len(page_threads); start_join_time = time.time()
 
463
  while any(t.is_alive() for t in page_threads):
464
- 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
 
465
  progress_bar.progress(min(progress, 1.0), text=f"Processed {completed_threads}/{total_threads} pages...")
466
- if time.time() - start_join_time > 600: print("PDF processing timed out."); break
467
- time.sleep(0.5)
 
 
 
468
  progress_bar.progress(1.0, text="Processing complete.")
469
 
 
 
470
  for i in range(pages_to_process):
471
  with st.expander(f"Page {i+1}"):
472
  st.markdown(texts.get(i, "[Error getting text]"))
473
- audio_file = audios.get(i)
474
- if audio_file: play_and_download_audio(audio_file)
475
- else: st.caption("Audio generation failed or was skipped.")
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
- except ImportError: st.error("PyPDF2 library needed.")
478
- except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
479
 
480
  # ==============================================================================
481
  # Streamlit UI Layout Functions
@@ -489,19 +586,22 @@ def render_sidebar():
489
  # --- World Save ---
490
  current_file = st.session_state.get('current_world_file')
491
  current_world_name = "Live State"
492
- default_save_name = "MyWorld"
493
  if current_file:
494
  parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
495
  current_world_name = parsed.get("name", current_file)
496
  default_save_name = current_world_name # Default to overwriting current name
497
 
498
  world_save_name = st.text_input(
499
- "World Name for Save:", key="world_save_name_input", value=default_save_name,
 
 
500
  help="Enter name to save as new, or keep current name to overwrite."
501
  )
502
 
503
  if st.button("πŸ’Ύ Save Current World View", key="sidebar_save_world"):
504
- if not world_save_name.strip(): st.warning("Please enter a World Name.")
 
505
  else:
506
  with st.spinner("Requesting world state & saving..."):
507
  js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_sidebar_save", want_result=True)
@@ -509,19 +609,27 @@ def render_sidebar():
509
  try:
510
  world_data_dict = json.loads(js_world_state_str)
511
  if isinstance(world_data_dict, dict):
512
- filename_to_save = ""; is_overwrite = False
 
513
  if current_file:
514
  parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
515
- if world_save_name == parsed_current.get('name', ''): filename_to_save = current_file; is_overwrite = True
516
- if not filename_to_save: filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
 
 
 
 
 
517
 
518
  if save_world_to_md(filename_to_save, world_data_dict):
519
  action = "Overwritten" if is_overwrite else "Saved new"
520
- st.success(f"World {action}: {filename_to_save}"); add_action_log(f"Saved world: {filename_to_save}")
 
521
  st.session_state.current_world_file = filename_to_save # Track saved file
522
- st.rerun()
523
- else: st.error("Failed to save world state.")
524
- else: st.error("Invalid state format from client.")
 
525
  except json.JSONDecodeError: st.error("Failed to decode state from client.")
526
  except Exception as e: st.error(f"Save error: {e}")
527
  else: st.warning("Did not receive world state from client.")
@@ -530,41 +638,38 @@ def render_sidebar():
530
  st.markdown("---")
531
  st.header("πŸ“‚ Load World")
532
  saved_worlds = get_saved_worlds()
 
533
  if not saved_worlds: st.caption("No saved worlds found.")
534
  else:
535
- st.caption("Click button to load state.")
536
- cols_header = st.columns([3, 1, 1]);
537
- with cols_header[0]: st.write("**Name** (User, Time)")
538
- with cols_header[1]: st.write("**Load**")
539
- with cols_header[2]: st.write("**DL**")
540
- display_limit = 15; displayed_count = 0
541
- for i, world_info in enumerate(saved_worlds):
542
- f_basename = os.path.basename(world_info['filename']); f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename);
 
 
543
  display_name = world_info.get('name', f_basename); user = world_info.get('user', 'N/A'); timestamp = world_info.get('timestamp', 'N/A')
544
  display_text = f"{display_name} ({user}, {timestamp})"
545
- is_last_displayed = i == display_limit -1; show_expander_trigger = len(saved_worlds) > display_limit and is_last_displayed
546
- container = st
547
- if displayed_count >= display_limit: container = st.expander(f"Show {len(saved_worlds)-display_limit} more...")
548
- if displayed_count < display_limit or show_expander_trigger :
549
- with container:
550
- col1, col2, col3 = st.columns([3, 1, 1])
551
- with col1: st.write(f"<small>{display_text}</small>", unsafe_allow_html=True)
552
- with col2:
553
- is_current = (st.session_state.get('current_world_file') == f_basename)
554
- btn_load = st.button("Load", key=f"load_{f_basename}", help=f"Load {f_basename}", disabled=is_current)
555
- with col3: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
556
- if btn_load:
557
- print(f"Load button clicked for: {f_basename}")
558
- world_dict = load_world_from_md(f_basename)
559
- if world_dict is not None:
560
- st.session_state.world_to_load_data = world_dict
561
- st.session_state.current_world_file = f_basename
562
- add_action_log(f"Loading world: {f_basename}")
563
- st.rerun()
564
- else: st.error(f"Failed to parse world file: {f_basename}")
565
- if show_expander_trigger: # If this was the trigger, stop outer loop
566
- break
567
- displayed_count += 1
568
 
569
 
570
  # --- Build Tools ---
@@ -576,24 +681,22 @@ def render_sidebar():
576
  try: tool_index = tool_options.index(current_tool_name)
577
  except ValueError: tool_index = 0
578
 
 
 
579
  selected_tool = st.radio(
580
  "Select Tool:", options=tool_options, index=tool_index,
581
  format_func=lambda name: f"{TOOLS_MAP.get(name, '')} {name}",
582
- key="tool_selector_radio", horizontal=True
583
  )
 
584
  if selected_tool != current_tool_name:
585
  st.session_state.selected_object = selected_tool
586
  add_action_log(f"Selected tool: {selected_tool}")
587
- # Update JS using synchronous call if needed immediately, or rely on injection
588
- try:
589
  streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
590
- except Exception as e:
591
- print(f"JS tool update error: {e}")
592
- # Rerun might still be useful to ensure radio button visual updates correctly,
593
- # although state change should handle it. Let's keep it for now.
594
  st.rerun()
595
 
596
-
597
  # --- Action Log ---
598
  st.markdown("---")
599
  st.header("πŸ“ Action Log")
@@ -603,7 +706,6 @@ def render_sidebar():
603
  if log_entries: st.code('\n'.join(log_entries), language="log")
604
  else: st.caption("No actions recorded yet.")
605
 
606
-
607
  # --- Voice/User ---
608
  st.markdown("---")
609
  st.header("πŸ—£οΈ Voice & User")
@@ -624,27 +726,25 @@ def render_main_content():
624
  """Renders the main content area with tabs."""
625
  st.title(f"{Site_Name} - User: {st.session_state.username}")
626
 
627
- # Check if world data needs to be sent to JS (loaded via sidebar button)
628
  world_data_to_load = st.session_state.pop('world_to_load_data', None)
629
  if world_data_to_load is not None:
630
  print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
631
  try:
632
- # Call JS function loadWorldState, pass data as JSON
633
  streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
634
  st.toast("World loaded in 3D view.", icon="πŸ”„")
635
  except Exception as e:
636
  st.error(f"Failed to send loaded world state to JS: {e}")
637
 
638
  # Set up the mechanism for JS to call Python when an object is placed
 
639
  streamlit_js_eval(
640
  js_code="""
641
- // Ensure function exists and avoid re-defining listener if possible
642
  if (!window.sendPlacedObjectToPython) {
643
  console.log('Defining sendPlacedObjectToPython for JS->Python comms...');
644
  window.sendPlacedObjectToPython = (objectData) => {
645
  console.log('JS sending placed object:', objectData);
646
- // Call Python function 'handle_js_object_placed' via its unique key
647
- // Pass data directly as object - streamlit_js_eval handles serialization
648
  streamlit_js_eval(python_code='handle_js_object_placed(data=' + JSON.stringify(objectData) + ')', key='js_place_event_handler');
649
  }
650
  }
@@ -652,13 +752,17 @@ def render_main_content():
652
  key="setup_js_place_event_handler" # Key for the setup code itself
653
  )
654
 
655
- # Check if the Python handler function was triggered and stored data
656
- placed_data = st.session_state.pop('js_object_placed_data', None) # Use pop to consume
657
- if placed_data:
658
- print(f"Python processed stored placed object data: {placed_data.get('obj_id')}")
659
- # Action log already added in handle_js_object_placed.
660
- # The object exists client-side. No server-side dict to update in this model.
661
- pass # No further action needed in main loop for placement event
 
 
 
 
662
 
663
 
664
  # Define Tabs
@@ -672,32 +776,28 @@ def render_main_content():
672
  if current_file_basename:
673
  full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
674
  if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
675
- else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None # Clear if missing
676
- else: st.info("Live State Active (Save to persist changes)")
677
 
678
  # Embed HTML Component
679
  html_file_path = 'index.html'
680
  try:
681
  with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
682
- # Inject state needed by JS (Tool, Constants)
683
- # The initial world state is loaded via explicit JS call if world_to_load_data exists,
684
- # OR on first load if nothing else triggered a load. We need to send *something* initially.
685
- initial_world_data = {} # Default to empty
686
- if 'world_to_load_data' in st.session_state and st.session_state.world_to_load_data is not None:
687
- # This should have been popped and sent via JS call, but as fallback:
688
- initial_world_data = st.session_state.world_to_load_data
689
- print("Warning: Sending world data via injection as fallback.")
690
- elif st.session_state.get('current_world_file'):
691
- # If a file is loaded but data wasn't popped, try loading it again for injection
692
- loaded_dict = load_world_from_md(st.session_state.current_world_file)
693
- if loaded_dict: initial_world_data = loaded_dict
694
 
695
  js_injection_script = f"""<script>
696
  window.USERNAME = {json.dumps(st.session_state.username)};
697
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
698
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
699
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
700
- // Send current state for initial draw IF NOT handled by explicit loadWorldState call
701
  window.INITIAL_WORLD_OBJECTS = {json.dumps(initial_world_data)};
702
  console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjects: {len(initial_world_data)} }});
703
  </script>"""
@@ -716,23 +816,18 @@ def render_main_content():
716
  if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:])))
717
  else: st.caption("No chat messages yet.")
718
 
719
- # Define callback for chat input clear
720
- def clear_chat_input_callback():
721
- st.session_state.message_input = ""
722
-
723
  message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
724
  send_button_clicked = st.button("Send Chat", key="send_chat_button", on_click=clear_chat_input_callback)
725
 
726
- if send_button_clicked: # Process only on button click
727
  message_to_send = message_value # Value before potential clear by callback
728
  if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
729
  st.session_state.last_message = message_to_send
730
  voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
731
- # Use run_async for background tasks
732
  run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
733
  # Rerun is handled implicitly by button click + callback
734
  elif send_button_clicked: st.toast("Message empty or same as last.")
735
- # Removed autosend checkbox for simplicity
736
 
737
  # --- PDF Tab ---
738
  with tab_pdf:
@@ -750,7 +845,6 @@ def render_main_content():
750
  st.subheader("πŸ’Ύ World Management")
751
  current_file_basename = st.session_state.get('current_world_file', None)
752
 
753
- # Save Current Version Button (if a world is loaded)
754
  if current_file_basename:
755
  full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
756
  save_label = f"Save Changes to '{current_file_basename}'"
@@ -771,7 +865,6 @@ def render_main_content():
771
  else: st.warning("Did not receive world state from client.")
772
  else: st.info("Load a world or use 'Save As New Version' below.")
773
 
774
- # Save As New Version Section
775
  st.subheader("Save As New Version")
776
  new_name_files = st.text_input("World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
777
  if st.button("πŸ’Ύ Save Current View as New Version", key="save_new_version_files"):
@@ -794,7 +887,7 @@ def render_main_content():
794
  else: st.warning("Did not receive world state from client.")
795
  else: st.warning("Please enter a name.")
796
 
797
- # --- Removed Server Status Section ---
798
 
799
  st.subheader("πŸ—‘οΈ Delete Files")
800
  st.warning("Deletion is permanent!", icon="⚠️")
@@ -804,6 +897,7 @@ def render_main_content():
804
  with col_del2:
805
  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()
806
  with col_del3:
 
807
  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()
808
  with col_del4:
809
  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()
@@ -822,7 +916,8 @@ def render_main_content():
822
  st.caption("Existing Zip Files:")
823
  for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
824
  else:
825
- st.caption("No zip archives found.") # Correct indentation
 
826
 
827
 
828
  # ==============================================================================
@@ -850,7 +945,7 @@ def initialize_app():
850
  if world_dict is not None:
851
  st.session_state.world_to_load_data = world_dict # Queue data to be sent to JS
852
  st.session_state.current_world_file = latest_world_file_basename # Set as current
853
- else: print("Failed to load most recent world for initial state.")
854
  else:
855
  print("No saved worlds found, starting empty.");
856
  st.session_state.world_to_load_data = {} # Send empty state to JS initially
 
1
+ # app.py (Full Code - Fixes Applied, Single Statements per Line)
2
  import streamlit as st
3
  import asyncio
4
  import uuid
 
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
 
59
  WORLD_STATE_FILE_MD_PREFIX = "🌍_"
60
  MAX_ACTION_LOG_SIZE = 30
61
 
62
+ # User/Chat Constants
63
  FUN_USERNAMES = {
64
  "BuilderBot πŸ€–": "en-US-AriaNeural", "WorldWeaver πŸ•ΈοΈ": "en-US-JennyNeural",
65
  "Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
 
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)
 
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]
 
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
 
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
 
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."""
235
  try:
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
 
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,
 
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 = {}
 
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)
328
  # ==============================================================================
329
  # --- Text & File Helpers ---
330
  def clean_text_for_tts(text):
 
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:
 
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
  # ==============================================================================
578
  # Streamlit UI Layout Functions
 
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)
 
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.")
 
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 ---
 
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")
 
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")
 
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
  }
 
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
 
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>"""
 
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:
 
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}'"
 
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"):
 
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="⚠️")
 
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()
 
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
  # ==============================================================================
 
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