Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
# app.py (
|
2 |
import streamlit as st
|
3 |
import asyncio
|
4 |
import websockets # Re-added
|
@@ -22,9 +22,9 @@ import threading
|
|
22 |
import json
|
23 |
import zipfile
|
24 |
from dotenv import load_dotenv
|
25 |
-
# from streamlit_marquee import streamlit_marquee
|
26 |
from collections import defaultdict, Counter, deque
|
27 |
-
from streamlit_js_eval import streamlit_js_eval #
|
28 |
from PIL import Image
|
29 |
|
30 |
# ==============================================================================
|
@@ -149,11 +149,21 @@ def parse_world_filename(filename): # Define this before get_saved_worlds uses i
|
|
149 |
try: dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
|
150 |
except Exception: dt_obj = None
|
151 |
return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
|
152 |
-
|
153 |
-
|
154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
155 |
return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
|
156 |
|
|
|
157 |
def load_initial_world_from_file():
|
158 |
"""Loads the state from the most recent MD file found."""
|
159 |
print(f"[{time.time():.1f}] β³ Attempting to load initial world state from files...")
|
@@ -190,7 +200,15 @@ def get_world_state_manager():
|
|
190 |
"lock": threading.Lock(),
|
191 |
"state": load_initial_world_from_file() # Load initial state here
|
192 |
}
|
193 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
194 |
return manager
|
195 |
|
196 |
def get_current_world_state_copy():
|
@@ -210,12 +228,12 @@ def generate_world_save_filename(username="User", world_name="World"):
|
|
210 |
|
211 |
def save_world_state_to_md(target_filename_base):
|
212 |
"""Saves the current cached world state to a specific MD file."""
|
213 |
-
manager = get_world_state_manager()
|
214 |
save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
|
215 |
print(f"πΎ Acquiring lock to save world state to: {save_path}...")
|
216 |
success = False
|
217 |
-
with manager["lock"]:
|
218 |
-
world_data_dict = dict(manager["state"])
|
219 |
print(f"πΎ Saving {len(world_data_dict)} objects...")
|
220 |
parsed_info = parse_world_filename(save_path)
|
221 |
timestamp_save = get_current_time_str()
|
@@ -237,7 +255,7 @@ def save_world_state_to_md(target_filename_base):
|
|
237 |
|
238 |
def load_world_state_from_md(filename_base):
|
239 |
"""Loads world state from MD, updates cached state, returns success bool."""
|
240 |
-
manager = get_world_state_manager()
|
241 |
load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
|
242 |
print(f"π Loading world state from MD file: {load_path}...")
|
243 |
if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return False
|
@@ -248,9 +266,9 @@ def load_world_state_from_md(filename_base):
|
|
248 |
world_data_dict = json.loads(json_match.group(1))
|
249 |
|
250 |
print(f"βοΈ Acquiring lock to update cached world state from {filename_base}...")
|
251 |
-
with manager["lock"]:
|
252 |
-
manager["state"].clear()
|
253 |
-
for k, v in world_data_dict.items(): manager["state"][str(k)] = v
|
254 |
loaded_count = len(manager["state"])
|
255 |
print(f"β
Loaded {loaded_count} objects into cached state. Lock released.")
|
256 |
st.session_state.current_world_file = filename_base # Track loaded file
|
@@ -284,11 +302,10 @@ def init_session_state():
|
|
284 |
'download_link_cache': {}, 'username': None, 'autosend': False,
|
285 |
'last_message': "",
|
286 |
'selected_object': 'None',
|
287 |
-
# 'initial_world_state_loaded' flag removed, cache resource handles init
|
288 |
'current_world_file': None, # Track loaded world filename (basename)
|
289 |
'new_world_name': "MyDreamscape",
|
290 |
'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
|
291 |
-
#
|
292 |
}
|
293 |
for k, v in defaults.items():
|
294 |
if k not in st.session_state:
|
@@ -314,18 +331,17 @@ def add_action_log(message, emoji="β‘οΈ"):
|
|
314 |
# ==============================================================================
|
315 |
# 7. π§ Audio / TTS / Chat / File Handling Helpers
|
316 |
# ==============================================================================
|
317 |
-
|
318 |
-
def clean_text_for_tts(text): # ... implementation ...
|
319 |
if not isinstance(text, str): return "No text"
|
320 |
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
|
321 |
text = ' '.join(text.split()); return text[:250] or "No text"
|
322 |
-
def create_file(content, username, file_type="md", save_path=None):
|
323 |
if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
|
324 |
ensure_dir(os.path.dirname(save_path))
|
325 |
try:
|
326 |
with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
|
327 |
except Exception as e: print(f"β Error creating file {save_path}: {e}"); return None
|
328 |
-
def get_download_link(file_path, file_type="md"):
|
329 |
if not file_path or not os.path.exists(file_path): basename = os.path.basename(file_path) if file_path else "N/A"; return f"<small>Not found: {basename}</small>"
|
330 |
try: mtime = os.path.getmtime(file_path)
|
331 |
except OSError: mtime = 0
|
@@ -340,7 +356,7 @@ def get_download_link(file_path, file_type="md"): # ... implementation ...
|
|
340 |
st.session_state.download_link_cache[cache_key] = link_html
|
341 |
except Exception as e: print(f"β Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
|
342 |
return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
|
343 |
-
async def async_edge_tts_generate(text, voice, username):
|
344 |
if not text: return None
|
345 |
cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
|
346 |
if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
|
@@ -355,21 +371,27 @@ async def async_edge_tts_generate(text, voice, username): # ... implementation .
|
|
355 |
if os.path.exists(save_path) and os.path.getsize(save_path) > 0: st.session_state.audio_cache[cache_key] = save_path; return save_path
|
356 |
else: print(f"β Audio file {save_path} failed generation."); return None
|
357 |
except Exception as e: print(f"β Edge TTS Error: {e}"); return None
|
358 |
-
def play_and_download_audio(file_path):
|
359 |
if file_path and os.path.exists(file_path):
|
360 |
-
try:
|
|
|
|
|
|
|
361 |
except Exception as e: st.error(f"β Audio display error for {os.path.basename(file_path)}: {e}")
|
362 |
-
async def save_chat_entry(username, message, voice, is_markdown=False):
|
363 |
if not message.strip(): return None, None
|
364 |
-
timestamp_str = get_current_time_str();
|
|
|
365 |
md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
|
366 |
md_file = create_file(entry, username, "md", save_path=md_file_path)
|
367 |
if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
|
368 |
st.session_state.chat_history.append(entry)
|
369 |
audio_file = None;
|
370 |
-
if st.session_state.get('enable_audio', True):
|
|
|
|
|
371 |
return md_file, audio_file
|
372 |
-
async def load_chat_history():
|
373 |
if 'chat_history' not in st.session_state: st.session_state.chat_history = []
|
374 |
if not st.session_state.chat_history:
|
375 |
ensure_dir(CHAT_DIR); print("π Loading chat history from files...")
|
@@ -382,7 +404,7 @@ async def load_chat_history(): # ... implementation ...
|
|
382 |
st.session_state.chat_history = temp_history
|
383 |
print(f"β
Loaded {loaded_count} chat entries from files.")
|
384 |
return st.session_state.chat_history
|
385 |
-
def create_zip_of_files(files_to_zip, prefix="Archive"):
|
386 |
if not files_to_zip: st.warning("π¨ Nothing to gather into an archive."); return None
|
387 |
timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
|
388 |
try:
|
@@ -393,7 +415,7 @@ def create_zip_of_files(files_to_zip, prefix="Archive"): # ... implementation ..
|
|
393 |
else: print(f"π¨ Skip zip missing file: {f}")
|
394 |
print("β
Zip archive created successfully."); st.success(f"Created {zip_name}"); return zip_name
|
395 |
except Exception as e: print(f"β Zip creation failed: {e}"); st.error(f"Zip creation failed: {e}"); return None
|
396 |
-
def delete_files(file_patterns, exclude_files=None):
|
397 |
protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
|
398 |
current_world_base = st.session_state.get('current_world_file')
|
399 |
if current_world_base: protected.append(current_world_base)
|
@@ -410,18 +432,18 @@ def delete_files(file_patterns, exclude_files=None): # ... implementation ...
|
|
410 |
if os.path.isfile(f_path) and basename not in protected:
|
411 |
try: os.remove(f_path); print(f"ποΈ Deleted: {f_path}"); deleted_count += 1
|
412 |
except Exception as e: print(f"β Failed delete {f_path}: {e}"); errors += 1
|
413 |
-
#else: print(f"π« Skipping protected/directory: {f_path}")
|
414 |
except Exception as glob_e: print(f"β Error matching pattern {pattern}: {glob_e}"); errors += 1
|
415 |
msg = f"β
Successfully deleted {deleted_count} files." if errors == 0 and deleted_count > 0 else f"Deleted {deleted_count} files."
|
416 |
if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
|
417 |
elif deleted_count > 0: st.success(msg)
|
418 |
else: st.info("π¨ No matching unprotected files found to delete.")
|
419 |
st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
|
420 |
-
async def save_pasted_image(image, username):
|
421 |
if not image: return None
|
422 |
try: img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]; timestamp = format_timestamp_prefix(username); filename = f"{timestamp}_pasted_{img_hash}.png"; filepath = os.path.join(MEDIA_DIR, filename); image.save(filepath, "PNG"); print(f"πΌοΈ Pasted image saved: {filepath}"); return filepath
|
423 |
except Exception as e: print(f"β Failed image save: {e}"); return None
|
424 |
-
def paste_image_component():
|
425 |
pasted_img = None; img_type = None
|
426 |
paste_input_value = st.text_area("π Paste Image Data Here", key="paste_input_area", height=50, value=st.session_state.get('paste_image_base64_input', ""), help="Paste image data directly (e.g., from clipboard)")
|
427 |
if st.button("πΌοΈ Process Pasted Image", key="process_paste_button"):
|
@@ -440,7 +462,8 @@ def paste_image_component(): # ... implementation ...
|
|
440 |
try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
|
441 |
except Exception: return None
|
442 |
return None
|
443 |
-
|
|
|
444 |
def __init__(self): self.cache_dir=AUDIO_CACHE_DIR; ensure_dir(self.cache_dir); self.metadata=json.load(open(f"{self.cache_dir}/metadata.json", 'r')) if os.path.exists(f"{self.cache_dir}/metadata.json") else {}
|
445 |
def _save_metadata(self):
|
446 |
try:
|
@@ -458,7 +481,7 @@ class AudioProcessor: # ... implementation ...
|
|
458 |
else: return None
|
459 |
except Exception as e: print(f"β TTS Create Audio Error: {e}"); return None
|
460 |
|
461 |
-
def process_pdf_tab(pdf_file, max_pages, voice):
|
462 |
st.subheader("π PDF Processing Results")
|
463 |
if pdf_file is None: st.info("β¬οΈ Upload a PDF file and click 'Process PDF' to begin."); return
|
464 |
audio_processor = AudioProcessor()
|
@@ -471,12 +494,12 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
|
|
471 |
|
472 |
def process_page_sync(page_num, page_text):
|
473 |
async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
|
474 |
-
try:
|
475 |
-
audio_path = asyncio.run(run_async_audio()) # Use asyncio.run in thread
|
476 |
if audio_path:
|
477 |
with results_lock: audios[page_num] = audio_path
|
478 |
except Exception as page_e: print(f"β Err process page {page_num+1}: {page_e}")
|
479 |
|
|
|
480 |
for i in range(pages_to_process):
|
481 |
try: # Start try block for page processing
|
482 |
page = reader.pages[i]
|
@@ -488,12 +511,12 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
|
|
488 |
thread.start() # Start thread
|
489 |
else: # Handle empty extraction
|
490 |
texts[i] = "[π No text extracted or page empty]"
|
491 |
-
# print(f"Page {i+1}: No text extracted.") # Verbose
|
492 |
# Correctly indented except block
|
493 |
except Exception as extract_e:
|
494 |
texts[i] = f"[β Error extract: {extract_e}]" # Store error message
|
495 |
print(f"Error page {i+1} extract: {extract_e}") # Log error
|
496 |
|
|
|
497 |
progress_bar = st.progress(0.0, text="β¨ Transmuting pages to sound...")
|
498 |
total_threads = len(page_threads); start_join_time = time.time()
|
499 |
while any(t.is_alive() for t in page_threads):
|
@@ -503,6 +526,7 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
|
|
503 |
time.sleep(0.5)
|
504 |
progress_bar.progress(1.0, text="β
Processing complete.")
|
505 |
|
|
|
506 |
st.write("πΆ Results:")
|
507 |
for i in range(pages_to_process):
|
508 |
with st.expander(f"Page {i+1}"):
|
@@ -512,19 +536,19 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
|
|
512 |
else:
|
513 |
page_text = texts.get(i,"")
|
514 |
if page_text.strip() and not page_text.startswith("["): st.caption("π Audio generation failed or timed out.")
|
515 |
-
# else: st.caption("π No text to generate audio from.") # Implicit
|
516 |
|
517 |
except ImportError: st.error("β οΈ PyPDF2 library needed.")
|
518 |
except Exception as pdf_e: st.error(f"β Error reading PDF '{pdf_file.name}': {pdf_e}"); st.exception(pdf_e)
|
519 |
|
|
|
520 |
# ==============================================================================
|
521 |
-
# 8. πΈοΈ WebSocket Server Logic
|
522 |
# ==============================================================================
|
523 |
|
524 |
async def register_client(websocket):
|
525 |
"""Adds client to tracking structures, ensuring thread safety."""
|
526 |
client_id = str(websocket.id);
|
527 |
-
with clients_lock:
|
528 |
connected_clients.add(client_id);
|
529 |
if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict);
|
530 |
st.session_state.active_connections[client_id] = websocket;
|
@@ -540,49 +564,56 @@ async def unregister_client(websocket):
|
|
540 |
|
541 |
async def send_safely(websocket, message, client_id):
|
542 |
"""Wrapper to send message and handle potential connection errors."""
|
543 |
-
try:
|
544 |
-
|
545 |
-
except
|
546 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
547 |
|
548 |
async def broadcast_message(message, exclude_id=None):
|
549 |
"""Sends a message to all connected clients except the excluded one."""
|
550 |
# Create local copies under lock for thread safety
|
551 |
with clients_lock:
|
552 |
-
if not connected_clients:
|
|
|
553 |
current_client_ids = list(connected_clients)
|
554 |
# Ensure active_connections exists and make a copy
|
555 |
if 'active_connections' in st.session_state:
|
556 |
active_connections_copy = st.session_state.active_connections.copy()
|
557 |
else:
|
558 |
-
|
559 |
|
560 |
tasks = []
|
561 |
for client_id in current_client_ids:
|
562 |
-
if client_id == exclude_id:
|
|
|
563 |
websocket = active_connections_copy.get(client_id) # Use copy
|
564 |
if websocket:
|
565 |
tasks.append(asyncio.create_task(send_safely(websocket, message, client_id)))
|
566 |
|
567 |
if tasks:
|
568 |
-
|
569 |
-
# Optional: Check results for exceptions if specific error handling per client is needed
|
570 |
-
|
571 |
|
572 |
async def broadcast_world_update():
|
573 |
"""Broadcasts the current world state (from cache) to all clients."""
|
574 |
-
#
|
575 |
-
world_state_copy = get_current_world_state_copy()
|
576 |
update_msg = json.dumps({"type": "initial_state", "payload": world_state_copy})
|
577 |
print(f"π‘ Broadcasting full world update ({len(world_state_copy)} objects)...")
|
578 |
-
await broadcast_message(update_msg)
|
579 |
|
580 |
async def websocket_handler(websocket, path):
|
581 |
-
"""Handles WebSocket connections and messages
|
582 |
await register_client(websocket); client_id = str(websocket.id);
|
|
|
583 |
username = st.session_state.get('username', f"User_{client_id[:4]}")
|
584 |
|
585 |
-
try: # Send initial
|
586 |
initial_state_payload = get_current_world_state_copy() # Get state using cached helper
|
587 |
initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload});
|
588 |
await websocket.send(initial_state_msg)
|
@@ -595,7 +626,7 @@ async def websocket_handler(websocket, path):
|
|
595 |
async for message in websocket:
|
596 |
try:
|
597 |
data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
|
598 |
-
sender_username = payload.get("username", username) #
|
599 |
|
600 |
# --- Handle Different Message Types ---
|
601 |
manager = get_world_state_manager() # Get state manager for world updates
|
@@ -614,7 +645,8 @@ async def websocket_handler(websocket, path):
|
|
614 |
# Broadcast placement to others
|
615 |
broadcast_payload = json.dumps({"type": "object_placed", "payload": {"object_data": obj_data, "username": sender_username}});
|
616 |
await broadcast_message(broadcast_payload, exclude_id=client_id)
|
617 |
-
run_async
|
|
|
618 |
else: print(f"β οΈ WS Invalid place_object payload: {payload}")
|
619 |
|
620 |
elif msg_type == "delete_object":
|
@@ -626,7 +658,7 @@ async def websocket_handler(websocket, path):
|
|
626 |
if removed:
|
627 |
broadcast_payload = json.dumps({"type": "object_deleted", "payload": {"obj_id": obj_id, "username": sender_username}});
|
628 |
await broadcast_message(broadcast_payload, exclude_id=client_id)
|
629 |
-
run_async(lambda: add_action_log(f"Deleted obj ({obj_id[:6]}) by {sender_username}", "ποΈ"))
|
630 |
else: print(f"β οΈ WS Invalid delete_object payload: {payload}")
|
631 |
|
632 |
elif msg_type == "player_position":
|
@@ -644,7 +676,7 @@ async def websocket_handler(websocket, path):
|
|
644 |
except Exception as e: print(f"β WS Unexpected handler error {client_id}: {e}")
|
645 |
finally:
|
646 |
await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
|
647 |
-
await unregister_client(websocket)
|
648 |
|
649 |
|
650 |
async def run_websocket_server():
|
@@ -685,7 +717,6 @@ def start_websocket_server_thread():
|
|
685 |
st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5)
|
686 |
if not st.session_state.server_task.is_alive(): print("### β Server thread failed to stay alive!")
|
687 |
|
688 |
-
|
689 |
# ==============================================================================
|
690 |
# 9. π¨ Streamlit UI Layout Functions
|
691 |
# ==============================================================================
|
@@ -699,32 +730,37 @@ def render_sidebar():
|
|
699 |
|
700 |
# World Save Button
|
701 |
current_file = st.session_state.get('current_world_file')
|
702 |
-
|
703 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
704 |
|
705 |
if st.button("πΎ Save Current World View", key="sidebar_save_world"):
|
706 |
-
if not world_save_name.strip():
|
|
|
707 |
else:
|
708 |
-
# Save current state (which is managed by cache resource, updated by WS)
|
709 |
filename_to_save = ""; is_overwrite = False
|
710 |
if current_file:
|
711 |
-
try:
|
712 |
parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
|
713 |
if world_save_name == parsed_current.get('name', ''): filename_to_save = current_file; is_overwrite = True
|
714 |
-
except Exception: pass
|
715 |
-
|
716 |
if not filename_to_save: filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
|
717 |
|
718 |
op_text = f"Overwriting {filename_to_save}..." if is_overwrite else f"Saving as {filename_to_save}..."
|
719 |
with st.spinner(op_text):
|
720 |
-
|
|
|
721 |
action = "Overwritten" if is_overwrite else "Saved new"
|
722 |
st.success(f"World {action}: {filename_to_save}"); add_action_log(f"Saved world: {filename_to_save}", emoji="πΎ")
|
723 |
st.session_state.current_world_file = filename_to_save # Track saved file
|
724 |
st.rerun()
|
725 |
else: st.error("β Failed to save world state.")
|
726 |
|
727 |
-
|
728 |
# --- World Load ---
|
729 |
st.markdown("---")
|
730 |
st.header("2. π Load World")
|
@@ -754,12 +790,12 @@ def render_sidebar():
|
|
754 |
if btn_load:
|
755 |
print(f"π±οΈ Load button clicked for: {f_basename}")
|
756 |
with st.spinner(f"Loading {f_basename}..."):
|
757 |
-
# load_world_state_from_md
|
758 |
if load_world_state_from_md(f_basename):
|
759 |
run_async(broadcast_world_update) # Broadcast the newly loaded state
|
760 |
add_action_log(f"Loading world: {f_basename}", emoji="π")
|
761 |
st.toast("World loaded!", icon="β
")
|
762 |
-
st.rerun() # Rerun to update UI
|
763 |
else: st.error(f"β Failed to load world file: {f_basename}")
|
764 |
|
765 |
# --- Build Tools ---
|
@@ -780,7 +816,8 @@ def render_sidebar():
|
|
780 |
st.session_state.selected_object = selected_tool
|
781 |
tool_emoji = TOOLS_MAP.get(selected_tool, 'β')
|
782 |
add_action_log(f"Tool selected: {selected_tool}", emoji=tool_emoji)
|
783 |
-
try: streamlit_js_eval
|
|
|
784 |
except Exception as e: print(f"β JS tool update error: {e}")
|
785 |
st.rerun()
|
786 |
|
@@ -803,7 +840,8 @@ def render_sidebar():
|
|
803 |
current_username = st.session_state.get('username', "DefaultUser")
|
804 |
username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
|
805 |
current_index = 0;
|
806 |
-
try
|
|
|
807 |
if current_username in username_options: current_index = username_options.index(current_username)
|
808 |
except ValueError: pass # Keep index 0
|
809 |
|
@@ -822,10 +860,8 @@ def render_main_content():
|
|
822 |
"""Renders the main content area with tabs."""
|
823 |
st.title(f"{Site_Name} - User: {st.session_state.username}")
|
824 |
|
825 |
-
# NOTE:
|
826 |
-
#
|
827 |
-
# then triggers broadcast_world_update (via run_async), and reruns.
|
828 |
-
# The WS handler sends initial state from the cache on new connections.
|
829 |
|
830 |
# Define Tabs
|
831 |
tab_world, tab_chat, tab_pdf, tab_files = st.tabs(["ποΈ World Builder", "π£οΈ Chat", "π PDF Tools", "π Files & Settings"])
|
@@ -849,19 +885,19 @@ def render_main_content():
|
|
849 |
try: # Get WS URL (Best effort)
|
850 |
from streamlit.web.server.server import Server
|
851 |
session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id)
|
852 |
-
host_attr = getattr(session_info.ws.stream.request, 'host',
|
853 |
if host_attr: server_host = host_attr.host.split(':')[0]; ws_url = f"ws://{server_host}:8765"
|
854 |
else: raise AttributeError("Host attribute not found")
|
855 |
except Exception as e: print(f"β οΈ WS URL detection failed ({e}), using localhost.")
|
856 |
|
857 |
-
#
|
858 |
js_injection_script = f"""<script>
|
859 |
window.USERNAME = {json.dumps(st.session_state.username)};
|
860 |
window.WEBSOCKET_URL = {json.dumps(ws_url)}; // Needed by JS to connect
|
861 |
window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
|
862 |
window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
|
863 |
window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
|
864 |
-
//
|
865 |
console.log("π Streamlit State Injected:", {{ username: window.USERNAME, websocketUrl: window.WEBSOCKET_URL, selectedObject: window.SELECTED_OBJECT_TYPE }});
|
866 |
</script>"""
|
867 |
html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
|
@@ -889,7 +925,6 @@ def render_main_content():
|
|
889 |
st.session_state.last_message = message_to_send
|
890 |
voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
|
891 |
ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}})
|
892 |
-
# Use run_async for background tasks
|
893 |
run_async(broadcast_message, ws_message) # Broadcast Chat via WS
|
894 |
run_async(save_chat_entry, st.session_state.username, message_to_send, voice) # Save async
|
895 |
add_action_log(f"Sent chat: {message_to_send[:20]}...", emoji="π¬")
|
@@ -915,7 +950,7 @@ def render_main_content():
|
|
915 |
st.subheader("πΎ World Scroll Management")
|
916 |
current_file_basename = st.session_state.get('current_world_file', None)
|
917 |
|
918 |
-
#
|
919 |
if current_file_basename:
|
920 |
full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
|
921 |
save_label = f"Save Changes to '{current_file_basename}'"
|
@@ -924,25 +959,24 @@ def render_main_content():
|
|
924 |
if not os.path.exists(full_path): st.error(f"β Cannot save, file missing.")
|
925 |
else:
|
926 |
with st.spinner(f"Saving changes to {current_file_basename}..."):
|
927 |
-
# Save the
|
928 |
if save_world_state_to_md(current_file_basename):
|
929 |
st.success("β
Current world saved!"); add_action_log(f"Saved world: {current_file_basename}", emoji="πΎ")
|
930 |
else: st.error("β Failed to save world state.")
|
931 |
else: st.info("β‘οΈ Load a world from the sidebar to enable 'Save Changes'.")
|
932 |
|
933 |
-
# Save As New Version Section
|
934 |
st.subheader("β¨ Save As New Scroll")
|
935 |
new_name_files = st.text_input("New Scroll Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyDreamscape'))
|
936 |
if st.button("πΎ Save Current View as New Scroll", key="save_new_version_files"):
|
937 |
if new_name_files.strip():
|
938 |
with st.spinner(f"Saving new version '{new_name_files}'..."):
|
939 |
new_filename_base = generate_world_save_filename(st.session_state.username, new_name_files)
|
940 |
-
# Save the
|
941 |
if save_world_state_to_md(new_filename_base):
|
942 |
st.success(f"β
Saved as {new_filename_base}")
|
943 |
st.session_state.current_world_file = new_filename_base; st.session_state.new_world_name = "MyDreamscape";
|
944 |
add_action_log(f"Saved new world: {new_filename_base}", emoji="β¨")
|
945 |
-
st.rerun()
|
946 |
else: st.error("β Failed to save new version.")
|
947 |
else: st.warning("β οΈ Please enter a name.")
|
948 |
|
@@ -987,9 +1021,8 @@ def render_main_content():
|
|
987 |
else:
|
988 |
st.caption("π¬οΈ No archives found.")
|
989 |
|
990 |
-
|
991 |
# ==============================================================================
|
992 |
-
# Main Execution Logic
|
993 |
# ==============================================================================
|
994 |
|
995 |
def initialize_app():
|
@@ -1001,21 +1034,20 @@ def initialize_app():
|
|
1001 |
if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
|
1002 |
else: st.session_state.username = random.choice(list(FUN_USERNAMES.keys())) if FUN_USERNAMES else "User"; st.session_state.tts_voice = FUN_USERNAMES.get(st.session_state.username, DEFAULT_TTS_VOICE); save_username(st.session_state.username)
|
1003 |
|
1004 |
-
#
|
1005 |
server_thread = st.session_state.get('server_task'); server_alive = server_thread is not None and server_thread.is_alive()
|
1006 |
if not st.session_state.get('server_running_flag', False) and not server_alive: start_websocket_server_thread()
|
1007 |
elif server_alive and not st.session_state.get('server_running_flag', False): st.session_state.server_running_flag = True
|
1008 |
|
1009 |
# Trigger the cached resource initialization/retrieval
|
|
|
1010 |
try:
|
1011 |
-
|
1012 |
-
# Set initial current_world_file if needed (based on what cache loaded)
|
1013 |
-
if st.session_state.get('current_world_file') is None:
|
1014 |
-
|
1015 |
-
|
1016 |
-
|
1017 |
-
st.session_state.current_world_file = os.path.basename(saved_worlds[0]['filename'])
|
1018 |
-
print(f"π Set initial session 'current_world_file' to: {st.session_state.current_world_file}")
|
1019 |
except Exception as e:
|
1020 |
st.error(f"β Fatal error initializing world state manager: {e}"); st.exception(e); st.stop()
|
1021 |
|
|
|
1 |
+
# app.py (Full Code - Corrected Multi-statement/Indentation Errors)
|
2 |
import streamlit as st
|
3 |
import asyncio
|
4 |
import websockets # Re-added
|
|
|
22 |
import json
|
23 |
import zipfile
|
24 |
from dotenv import load_dotenv
|
25 |
+
# from streamlit_marquee import streamlit_marquee # Import if needed
|
26 |
from collections import defaultdict, Counter, deque
|
27 |
+
from streamlit_js_eval import streamlit_js_eval # Correct import
|
28 |
from PIL import Image
|
29 |
|
30 |
# ==============================================================================
|
|
|
149 |
try: dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
|
150 |
except Exception: dt_obj = None
|
151 |
return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
|
152 |
+
|
153 |
+
# Fallback for unknown format or failed parse
|
154 |
+
# print(f"Using fallback parsing for filename: {basename}") # Debug log
|
155 |
+
dt_fallback = None # Initialize on its own line
|
156 |
+
try: # Start try block on its own line
|
157 |
+
# Indented block under try
|
158 |
+
mtime = os.path.getmtime(filename)
|
159 |
+
dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
|
160 |
+
except Exception: # Statement aligned with try
|
161 |
+
# Indented block under except
|
162 |
+
pass # Statement on its own line
|
163 |
+
# Return statement aligned with the start of the fallback logic block
|
164 |
return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
|
165 |
|
166 |
+
|
167 |
def load_initial_world_from_file():
|
168 |
"""Loads the state from the most recent MD file found."""
|
169 |
print(f"[{time.time():.1f}] β³ Attempting to load initial world state from files...")
|
|
|
200 |
"lock": threading.Lock(),
|
201 |
"state": load_initial_world_from_file() # Load initial state here
|
202 |
}
|
203 |
+
# Set initial current_world_file if state was loaded successfully
|
204 |
+
if manager["state"]:
|
205 |
+
saved_worlds = get_saved_worlds()
|
206 |
+
if saved_worlds:
|
207 |
+
latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
|
208 |
+
if 'current_world_file' not in st.session_state: # Initialize only if not set
|
209 |
+
st.session_state.current_world_file = latest_world_file_basename
|
210 |
+
print(f"π Set initial current_world_file state to: {latest_world_file_basename}")
|
211 |
+
|
212 |
return manager
|
213 |
|
214 |
def get_current_world_state_copy():
|
|
|
228 |
|
229 |
def save_world_state_to_md(target_filename_base):
|
230 |
"""Saves the current cached world state to a specific MD file."""
|
231 |
+
manager = get_world_state_manager() # Get resource
|
232 |
save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
|
233 |
print(f"πΎ Acquiring lock to save world state to: {save_path}...")
|
234 |
success = False
|
235 |
+
with manager["lock"]: # Use resource's lock
|
236 |
+
world_data_dict = dict(manager["state"]) # Get copy from resource state
|
237 |
print(f"πΎ Saving {len(world_data_dict)} objects...")
|
238 |
parsed_info = parse_world_filename(save_path)
|
239 |
timestamp_save = get_current_time_str()
|
|
|
255 |
|
256 |
def load_world_state_from_md(filename_base):
|
257 |
"""Loads world state from MD, updates cached state, returns success bool."""
|
258 |
+
manager = get_world_state_manager() # Get resource
|
259 |
load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
|
260 |
print(f"π Loading world state from MD file: {load_path}...")
|
261 |
if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return False
|
|
|
266 |
world_data_dict = json.loads(json_match.group(1))
|
267 |
|
268 |
print(f"βοΈ Acquiring lock to update cached world state from {filename_base}...")
|
269 |
+
with manager["lock"]: # Use resource's lock
|
270 |
+
manager["state"].clear() # Clear the existing cached state dict
|
271 |
+
for k, v in world_data_dict.items(): manager["state"][str(k)] = v # Update with loaded data
|
272 |
loaded_count = len(manager["state"])
|
273 |
print(f"β
Loaded {loaded_count} objects into cached state. Lock released.")
|
274 |
st.session_state.current_world_file = filename_base # Track loaded file
|
|
|
302 |
'download_link_cache': {}, 'username': None, 'autosend': False,
|
303 |
'last_message': "",
|
304 |
'selected_object': 'None',
|
|
|
305 |
'current_world_file': None, # Track loaded world filename (basename)
|
306 |
'new_world_name': "MyDreamscape",
|
307 |
'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
|
308 |
+
# Removed temporary state holders 'world_to_load_data', 'js_object_placed_data'
|
309 |
}
|
310 |
for k, v in defaults.items():
|
311 |
if k not in st.session_state:
|
|
|
331 |
# ==============================================================================
|
332 |
# 7. π§ Audio / TTS / Chat / File Handling Helpers
|
333 |
# ==============================================================================
|
334 |
+
def clean_text_for_tts(text):
|
|
|
335 |
if not isinstance(text, str): return "No text"
|
336 |
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
|
337 |
text = ' '.join(text.split()); return text[:250] or "No text"
|
338 |
+
def create_file(content, username, file_type="md", save_path=None):
|
339 |
if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
|
340 |
ensure_dir(os.path.dirname(save_path))
|
341 |
try:
|
342 |
with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
|
343 |
except Exception as e: print(f"β Error creating file {save_path}: {e}"); return None
|
344 |
+
def get_download_link(file_path, file_type="md"):
|
345 |
if not file_path or not os.path.exists(file_path): basename = os.path.basename(file_path) if file_path else "N/A"; return f"<small>Not found: {basename}</small>"
|
346 |
try: mtime = os.path.getmtime(file_path)
|
347 |
except OSError: mtime = 0
|
|
|
356 |
st.session_state.download_link_cache[cache_key] = link_html
|
357 |
except Exception as e: print(f"β Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
|
358 |
return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
|
359 |
+
async def async_edge_tts_generate(text, voice, username):
|
360 |
if not text: return None
|
361 |
cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
|
362 |
if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
|
|
|
371 |
if os.path.exists(save_path) and os.path.getsize(save_path) > 0: st.session_state.audio_cache[cache_key] = save_path; return save_path
|
372 |
else: print(f"β Audio file {save_path} failed generation."); return None
|
373 |
except Exception as e: print(f"β Edge TTS Error: {e}"); return None
|
374 |
+
def play_and_download_audio(file_path):
|
375 |
if file_path and os.path.exists(file_path):
|
376 |
+
try:
|
377 |
+
st.audio(file_path)
|
378 |
+
file_type = file_path.split('.')[-1]
|
379 |
+
st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
|
380 |
except Exception as e: st.error(f"β Audio display error for {os.path.basename(file_path)}: {e}")
|
381 |
+
async def save_chat_entry(username, message, voice, is_markdown=False):
|
382 |
if not message.strip(): return None, None
|
383 |
+
timestamp_str = get_current_time_str();
|
384 |
+
entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
|
385 |
md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
|
386 |
md_file = create_file(entry, username, "md", save_path=md_file_path)
|
387 |
if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
|
388 |
st.session_state.chat_history.append(entry)
|
389 |
audio_file = None;
|
390 |
+
if st.session_state.get('enable_audio', True):
|
391 |
+
tts_message = message
|
392 |
+
audio_file = await async_edge_tts_generate(tts_message, voice, username)
|
393 |
return md_file, audio_file
|
394 |
+
async def load_chat_history():
|
395 |
if 'chat_history' not in st.session_state: st.session_state.chat_history = []
|
396 |
if not st.session_state.chat_history:
|
397 |
ensure_dir(CHAT_DIR); print("π Loading chat history from files...")
|
|
|
404 |
st.session_state.chat_history = temp_history
|
405 |
print(f"β
Loaded {loaded_count} chat entries from files.")
|
406 |
return st.session_state.chat_history
|
407 |
+
def create_zip_of_files(files_to_zip, prefix="Archive"):
|
408 |
if not files_to_zip: st.warning("π¨ Nothing to gather into an archive."); return None
|
409 |
timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
|
410 |
try:
|
|
|
415 |
else: print(f"π¨ Skip zip missing file: {f}")
|
416 |
print("β
Zip archive created successfully."); st.success(f"Created {zip_name}"); return zip_name
|
417 |
except Exception as e: print(f"β Zip creation failed: {e}"); st.error(f"Zip creation failed: {e}"); return None
|
418 |
+
def delete_files(file_patterns, exclude_files=None):
|
419 |
protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
|
420 |
current_world_base = st.session_state.get('current_world_file')
|
421 |
if current_world_base: protected.append(current_world_base)
|
|
|
432 |
if os.path.isfile(f_path) and basename not in protected:
|
433 |
try: os.remove(f_path); print(f"ποΈ Deleted: {f_path}"); deleted_count += 1
|
434 |
except Exception as e: print(f"β Failed delete {f_path}: {e}"); errors += 1
|
435 |
+
#else: print(f"π« Skipping protected/directory: {f_path}")
|
436 |
except Exception as glob_e: print(f"β Error matching pattern {pattern}: {glob_e}"); errors += 1
|
437 |
msg = f"β
Successfully deleted {deleted_count} files." if errors == 0 and deleted_count > 0 else f"Deleted {deleted_count} files."
|
438 |
if errors > 0: msg += f" Encountered {errors} errors."; st.warning(msg)
|
439 |
elif deleted_count > 0: st.success(msg)
|
440 |
else: st.info("π¨ No matching unprotected files found to delete.")
|
441 |
st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
|
442 |
+
async def save_pasted_image(image, username):
|
443 |
if not image: return None
|
444 |
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
|
445 |
except Exception as e: print(f"β Failed image save: {e}"); return None
|
446 |
+
def paste_image_component():
|
447 |
pasted_img = None; img_type = None
|
448 |
paste_input_value = st.text_area("π Paste Image Data Here", key="paste_input_area", height=50, value=st.session_state.get('paste_image_base64_input', ""), help="Paste image data directly (e.g., from clipboard)")
|
449 |
if st.button("πΌοΈ Process Pasted Image", key="process_paste_button"):
|
|
|
462 |
try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
|
463 |
except Exception: return None
|
464 |
return None
|
465 |
+
# --- PDF Processing ---
|
466 |
+
class AudioProcessor:
|
467 |
def __init__(self): self.cache_dir=AUDIO_CACHE_DIR; ensure_dir(self.cache_dir); self.metadata=json.load(open(f"{self.cache_dir}/metadata.json", 'r')) if os.path.exists(f"{self.cache_dir}/metadata.json") else {}
|
468 |
def _save_metadata(self):
|
469 |
try:
|
|
|
481 |
else: return None
|
482 |
except Exception as e: print(f"β TTS Create Audio Error: {e}"); return None
|
483 |
|
484 |
+
def process_pdf_tab(pdf_file, max_pages, voice):
|
485 |
st.subheader("π PDF Processing Results")
|
486 |
if pdf_file is None: st.info("β¬οΈ Upload a PDF file and click 'Process PDF' to begin."); return
|
487 |
audio_processor = AudioProcessor()
|
|
|
494 |
|
495 |
def process_page_sync(page_num, page_text):
|
496 |
async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
|
497 |
+
try: audio_path = asyncio.run(run_async_audio()) # Use asyncio.run in thread
|
|
|
498 |
if audio_path:
|
499 |
with results_lock: audios[page_num] = audio_path
|
500 |
except Exception as page_e: print(f"β Err process page {page_num+1}: {page_e}")
|
501 |
|
502 |
+
# Start threads
|
503 |
for i in range(pages_to_process):
|
504 |
try: # Start try block for page processing
|
505 |
page = reader.pages[i]
|
|
|
511 |
thread.start() # Start thread
|
512 |
else: # Handle empty extraction
|
513 |
texts[i] = "[π No text extracted or page empty]"
|
|
|
514 |
# Correctly indented except block
|
515 |
except Exception as extract_e:
|
516 |
texts[i] = f"[β Error extract: {extract_e}]" # Store error message
|
517 |
print(f"Error page {i+1} extract: {extract_e}") # Log error
|
518 |
|
519 |
+
# Wait for threads and display progress
|
520 |
progress_bar = st.progress(0.0, text="β¨ Transmuting pages to sound...")
|
521 |
total_threads = len(page_threads); start_join_time = time.time()
|
522 |
while any(t.is_alive() for t in page_threads):
|
|
|
526 |
time.sleep(0.5)
|
527 |
progress_bar.progress(1.0, text="β
Processing complete.")
|
528 |
|
529 |
+
# Display results
|
530 |
st.write("πΆ Results:")
|
531 |
for i in range(pages_to_process):
|
532 |
with st.expander(f"Page {i+1}"):
|
|
|
536 |
else:
|
537 |
page_text = texts.get(i,"")
|
538 |
if page_text.strip() and not page_text.startswith("["): st.caption("π Audio generation failed or timed out.")
|
|
|
539 |
|
540 |
except ImportError: st.error("β οΈ PyPDF2 library needed.")
|
541 |
except Exception as pdf_e: st.error(f"β Error reading PDF '{pdf_file.name}': {pdf_e}"); st.exception(pdf_e)
|
542 |
|
543 |
+
|
544 |
# ==============================================================================
|
545 |
+
# 8. πΈοΈ WebSocket Server Logic
|
546 |
# ==============================================================================
|
547 |
|
548 |
async def register_client(websocket):
|
549 |
"""Adds client to tracking structures, ensuring thread safety."""
|
550 |
client_id = str(websocket.id);
|
551 |
+
with clients_lock: # Use the dedicated lock for client structures
|
552 |
connected_clients.add(client_id);
|
553 |
if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict);
|
554 |
st.session_state.active_connections[client_id] = websocket;
|
|
|
564 |
|
565 |
async def send_safely(websocket, message, client_id):
|
566 |
"""Wrapper to send message and handle potential connection errors."""
|
567 |
+
try:
|
568 |
+
await websocket.send(message)
|
569 |
+
except websockets.ConnectionClosed:
|
570 |
+
print(f"β WS Send failed (Closed) client {client_id}")
|
571 |
+
raise # Re-raise to be caught by gather
|
572 |
+
except RuntimeError as e:
|
573 |
+
print(f"β WS Send failed (Runtime {e}) client {client_id}")
|
574 |
+
raise
|
575 |
+
except Exception as e:
|
576 |
+
print(f"β WS Send failed (Other {e}) client {client_id}")
|
577 |
+
raise
|
578 |
|
579 |
async def broadcast_message(message, exclude_id=None):
|
580 |
"""Sends a message to all connected clients except the excluded one."""
|
581 |
# Create local copies under lock for thread safety
|
582 |
with clients_lock:
|
583 |
+
if not connected_clients:
|
584 |
+
return
|
585 |
current_client_ids = list(connected_clients)
|
586 |
# Ensure active_connections exists and make a copy
|
587 |
if 'active_connections' in st.session_state:
|
588 |
active_connections_copy = st.session_state.active_connections.copy()
|
589 |
else:
|
590 |
+
active_connections_copy = {} # Should not happen if init runs first
|
591 |
|
592 |
tasks = []
|
593 |
for client_id in current_client_ids:
|
594 |
+
if client_id == exclude_id:
|
595 |
+
continue
|
596 |
websocket = active_connections_copy.get(client_id) # Use copy
|
597 |
if websocket:
|
598 |
tasks.append(asyncio.create_task(send_safely(websocket, message, client_id)))
|
599 |
|
600 |
if tasks:
|
601 |
+
await asyncio.gather(*tasks, return_exceptions=True) # Wait and ignore errors here
|
|
|
|
|
602 |
|
603 |
async def broadcast_world_update():
|
604 |
"""Broadcasts the current world state (from cache) to all clients."""
|
605 |
+
world_state_copy = get_current_world_state_copy() # Safely get a copy
|
|
|
606 |
update_msg = json.dumps({"type": "initial_state", "payload": world_state_copy})
|
607 |
print(f"π‘ Broadcasting full world update ({len(world_state_copy)} objects)...")
|
608 |
+
await broadcast_message(update_msg) # Send to all connected clients
|
609 |
|
610 |
async def websocket_handler(websocket, path):
|
611 |
+
"""Handles WebSocket connections and messages using cached world state."""
|
612 |
await register_client(websocket); client_id = str(websocket.id);
|
613 |
+
# Use username from main session state - might require adjustments if session state isn't thread-safe across contexts
|
614 |
username = st.session_state.get('username', f"User_{client_id[:4]}")
|
615 |
|
616 |
+
try: # Send initial state
|
617 |
initial_state_payload = get_current_world_state_copy() # Get state using cached helper
|
618 |
initial_state_msg = json.dumps({"type": "initial_state", "payload": initial_state_payload});
|
619 |
await websocket.send(initial_state_msg)
|
|
|
626 |
async for message in websocket:
|
627 |
try:
|
628 |
data = json.loads(message); msg_type = data.get("type"); payload = data.get("payload", {});
|
629 |
+
sender_username = payload.get("username", username) # Username sent from client
|
630 |
|
631 |
# --- Handle Different Message Types ---
|
632 |
manager = get_world_state_manager() # Get state manager for world updates
|
|
|
645 |
# Broadcast placement to others
|
646 |
broadcast_payload = json.dumps({"type": "object_placed", "payload": {"object_data": obj_data, "username": sender_username}});
|
647 |
await broadcast_message(broadcast_payload, exclude_id=client_id)
|
648 |
+
# Use run_async for session state update if needed from thread
|
649 |
+
# run_async(lambda: add_action_log(f"Placed {obj_data['type']} ({obj_data['obj_id'][:6]}) by {sender_username}", TOOLS_MAP.get(obj_data['type'], 'β')))
|
650 |
else: print(f"β οΈ WS Invalid place_object payload: {payload}")
|
651 |
|
652 |
elif msg_type == "delete_object":
|
|
|
658 |
if removed:
|
659 |
broadcast_payload = json.dumps({"type": "object_deleted", "payload": {"obj_id": obj_id, "username": sender_username}});
|
660 |
await broadcast_message(broadcast_payload, exclude_id=client_id)
|
661 |
+
# run_async(lambda: add_action_log(f"Deleted obj ({obj_id[:6]}) by {sender_username}", "ποΈ"))
|
662 |
else: print(f"β οΈ WS Invalid delete_object payload: {payload}")
|
663 |
|
664 |
elif msg_type == "player_position":
|
|
|
676 |
except Exception as e: print(f"β WS Unexpected handler error {client_id}: {e}")
|
677 |
finally:
|
678 |
await broadcast_message(json.dumps({"type": "user_leave", "payload": {"username": username, "id": client_id}}), exclude_id=client_id);
|
679 |
+
await unregister_client(websocket) # Cleanup
|
680 |
|
681 |
|
682 |
async def run_websocket_server():
|
|
|
717 |
st.session_state.server_task = threading.Thread(target=run_loop, daemon=True); st.session_state.server_task.start(); time.sleep(1.5)
|
718 |
if not st.session_state.server_task.is_alive(): print("### β Server thread failed to stay alive!")
|
719 |
|
|
|
720 |
# ==============================================================================
|
721 |
# 9. π¨ Streamlit UI Layout Functions
|
722 |
# ==============================================================================
|
|
|
730 |
|
731 |
# World Save Button
|
732 |
current_file = st.session_state.get('current_world_file')
|
733 |
+
current_world_name = "Live State"
|
734 |
+
default_save_name = st.session_state.get('new_world_name', 'MyDreamscape')
|
735 |
+
if current_file:
|
736 |
+
try: parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file)); current_world_name = parsed.get("name", current_file)
|
737 |
+
except Exception: pass
|
738 |
+
default_save_name = current_world_name
|
739 |
+
|
740 |
+
world_save_name = st.text_input("World Name:", key="world_save_name_input", value=default_save_name, help="Enter name to save.")
|
741 |
|
742 |
if st.button("πΎ Save Current World View", key="sidebar_save_world"):
|
743 |
+
if not world_save_name.strip():
|
744 |
+
st.warning("β οΈ Please enter a World Name.")
|
745 |
else:
|
|
|
746 |
filename_to_save = ""; is_overwrite = False
|
747 |
if current_file:
|
748 |
+
try:
|
749 |
parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
|
750 |
if world_save_name == parsed_current.get('name', ''): filename_to_save = current_file; is_overwrite = True
|
751 |
+
except Exception: pass
|
|
|
752 |
if not filename_to_save: filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
|
753 |
|
754 |
op_text = f"Overwriting {filename_to_save}..." if is_overwrite else f"Saving as {filename_to_save}..."
|
755 |
with st.spinner(op_text):
|
756 |
+
# Save uses the current state from the cached resource
|
757 |
+
if save_world_state_to_md(filename_to_save):
|
758 |
action = "Overwritten" if is_overwrite else "Saved new"
|
759 |
st.success(f"World {action}: {filename_to_save}"); add_action_log(f"Saved world: {filename_to_save}", emoji="πΎ")
|
760 |
st.session_state.current_world_file = filename_to_save # Track saved file
|
761 |
st.rerun()
|
762 |
else: st.error("β Failed to save world state.")
|
763 |
|
|
|
764 |
# --- World Load ---
|
765 |
st.markdown("---")
|
766 |
st.header("2. π Load World")
|
|
|
790 |
if btn_load:
|
791 |
print(f"π±οΈ Load button clicked for: {f_basename}")
|
792 |
with st.spinner(f"Loading {f_basename}..."):
|
793 |
+
# load_world_state_from_md updates the cached resource
|
794 |
if load_world_state_from_md(f_basename):
|
795 |
run_async(broadcast_world_update) # Broadcast the newly loaded state
|
796 |
add_action_log(f"Loading world: {f_basename}", emoji="π")
|
797 |
st.toast("World loaded!", icon="β
")
|
798 |
+
st.rerun() # Rerun to update UI
|
799 |
else: st.error(f"β Failed to load world file: {f_basename}")
|
800 |
|
801 |
# --- Build Tools ---
|
|
|
816 |
st.session_state.selected_object = selected_tool
|
817 |
tool_emoji = TOOLS_MAP.get(selected_tool, 'β')
|
818 |
add_action_log(f"Tool selected: {selected_tool}", emoji=tool_emoji)
|
819 |
+
try: # Use streamlit_js_eval, not sync
|
820 |
+
streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
|
821 |
except Exception as e: print(f"β JS tool update error: {e}")
|
822 |
st.rerun()
|
823 |
|
|
|
840 |
current_username = st.session_state.get('username', "DefaultUser")
|
841 |
username_options = list(FUN_USERNAMES.keys()) if FUN_USERNAMES else [current_username]
|
842 |
current_index = 0;
|
843 |
+
# Corrected try-except for finding index
|
844 |
+
try:
|
845 |
if current_username in username_options: current_index = username_options.index(current_username)
|
846 |
except ValueError: pass # Keep index 0
|
847 |
|
|
|
860 |
"""Renders the main content area with tabs."""
|
861 |
st.title(f"{Site_Name} - User: {st.session_state.username}")
|
862 |
|
863 |
+
# NOTE: World loading is handled by button click -> load_world_state_from_md -> broadcast -> rerun
|
864 |
+
# Initial world state is sent by WS handler on connect
|
|
|
|
|
865 |
|
866 |
# Define Tabs
|
867 |
tab_world, tab_chat, tab_pdf, tab_files = st.tabs(["ποΈ World Builder", "π£οΈ Chat", "π PDF Tools", "π Files & Settings"])
|
|
|
885 |
try: # Get WS URL (Best effort)
|
886 |
from streamlit.web.server.server import Server
|
887 |
session_info = Server.get_current()._get_session_info(st.runtime.scriptrunner.get_script_run_ctx().session_id)
|
888 |
+
host_attr = getattr(session_info.ws.stream.request, 'host', getattr(getattr(session_info, 'client', None), 'request', None))
|
889 |
if host_attr: server_host = host_attr.host.split(':')[0]; ws_url = f"ws://{server_host}:8765"
|
890 |
else: raise AttributeError("Host attribute not found")
|
891 |
except Exception as e: print(f"β οΈ WS URL detection failed ({e}), using localhost.")
|
892 |
|
893 |
+
# Initial world state sent via WS, only need constants/UI state here
|
894 |
js_injection_script = f"""<script>
|
895 |
window.USERNAME = {json.dumps(st.session_state.username)};
|
896 |
window.WEBSOCKET_URL = {json.dumps(ws_url)}; // Needed by JS to connect
|
897 |
window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
|
898 |
window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
|
899 |
window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
|
900 |
+
// window.INITIAL_WORLD_OBJECTS not needed here, comes via WebSocket
|
901 |
console.log("π Streamlit State Injected:", {{ username: window.USERNAME, websocketUrl: window.WEBSOCKET_URL, selectedObject: window.SELECTED_OBJECT_TYPE }});
|
902 |
</script>"""
|
903 |
html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
|
|
|
925 |
st.session_state.last_message = message_to_send
|
926 |
voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
|
927 |
ws_message = json.dumps({"type": "chat_message", "payload": {"username": st.session_state.username, "message": message_to_send, "voice": voice}})
|
|
|
928 |
run_async(broadcast_message, ws_message) # Broadcast Chat via WS
|
929 |
run_async(save_chat_entry, st.session_state.username, message_to_send, voice) # Save async
|
930 |
add_action_log(f"Sent chat: {message_to_send[:20]}...", emoji="π¬")
|
|
|
950 |
st.subheader("πΎ World Scroll Management")
|
951 |
current_file_basename = st.session_state.get('current_world_file', None)
|
952 |
|
953 |
+
# Button to save changes to the currently loaded file
|
954 |
if current_file_basename:
|
955 |
full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
|
956 |
save_label = f"Save Changes to '{current_file_basename}'"
|
|
|
959 |
if not os.path.exists(full_path): st.error(f"β Cannot save, file missing.")
|
960 |
else:
|
961 |
with st.spinner(f"Saving changes to {current_file_basename}..."):
|
962 |
+
# Save the state currently held in the cached resource
|
963 |
if save_world_state_to_md(current_file_basename):
|
964 |
st.success("β
Current world saved!"); add_action_log(f"Saved world: {current_file_basename}", emoji="πΎ")
|
965 |
else: st.error("β Failed to save world state.")
|
966 |
else: st.info("β‘οΈ Load a world from the sidebar to enable 'Save Changes'.")
|
967 |
|
|
|
968 |
st.subheader("β¨ Save As New Scroll")
|
969 |
new_name_files = st.text_input("New Scroll Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyDreamscape'))
|
970 |
if st.button("πΎ Save Current View as New Scroll", key="save_new_version_files"):
|
971 |
if new_name_files.strip():
|
972 |
with st.spinner(f"Saving new version '{new_name_files}'..."):
|
973 |
new_filename_base = generate_world_save_filename(st.session_state.username, new_name_files)
|
974 |
+
# Save the state currently held in the cached resource to a NEW file
|
975 |
if save_world_state_to_md(new_filename_base):
|
976 |
st.success(f"β
Saved as {new_filename_base}")
|
977 |
st.session_state.current_world_file = new_filename_base; st.session_state.new_world_name = "MyDreamscape";
|
978 |
add_action_log(f"Saved new world: {new_filename_base}", emoji="β¨")
|
979 |
+
st.rerun() # Rerun to update sidebar list
|
980 |
else: st.error("β Failed to save new version.")
|
981 |
else: st.warning("β οΈ Please enter a name.")
|
982 |
|
|
|
1021 |
else:
|
1022 |
st.caption("π¬οΈ No archives found.")
|
1023 |
|
|
|
1024 |
# ==============================================================================
|
1025 |
+
# 10. π Main Execution Logic
|
1026 |
# ==============================================================================
|
1027 |
|
1028 |
def initialize_app():
|
|
|
1034 |
if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
|
1035 |
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)
|
1036 |
|
1037 |
+
# Start WebSocket server thread if needed
|
1038 |
server_thread = st.session_state.get('server_task'); server_alive = server_thread is not None and server_thread.is_alive()
|
1039 |
if not st.session_state.get('server_running_flag', False) and not server_alive: start_websocket_server_thread()
|
1040 |
elif server_alive and not st.session_state.get('server_running_flag', False): st.session_state.server_running_flag = True
|
1041 |
|
1042 |
# Trigger the cached resource initialization/retrieval
|
1043 |
+
# This ensures the initial state is loaded from file if the resource is created now
|
1044 |
try:
|
1045 |
+
get_world_state_manager()
|
1046 |
+
# Set initial current_world_file state if needed (based on what cache loaded)
|
1047 |
+
if st.session_state.get('current_world_file') is None and '_initial_world_file_loaded' in st.session_state:
|
1048 |
+
st.session_state.current_world_file = st.session_state.pop('_initial_world_file_loaded') # Use and remove temp flag
|
1049 |
+
print(f"π Set initial session 'current_world_file' to: {st.session_state.current_world_file}")
|
1050 |
+
|
|
|
|
|
1051 |
except Exception as e:
|
1052 |
st.error(f"β Fatal error initializing world state manager: {e}"); st.exception(e); st.stop()
|
1053 |
|