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