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 uuid
|
@@ -21,7 +21,7 @@ import threading
|
|
21 |
import json
|
22 |
import zipfile
|
23 |
from dotenv import load_dotenv
|
24 |
-
# from streamlit_marquee import streamlit_marquee #
|
25 |
from collections import defaultdict, Counter, deque
|
26 |
from streamlit_js_eval import streamlit_js_eval # Correct import
|
27 |
from PIL import Image
|
@@ -59,7 +59,7 @@ PLOT_DEPTH = 50.0
|
|
59 |
WORLD_STATE_FILE_MD_PREFIX = "π_"
|
60 |
MAX_ACTION_LOG_SIZE = 30
|
61 |
|
62 |
-
# User/Chat Constants
|
63 |
FUN_USERNAMES = {
|
64 |
"BuilderBot π€": "en-US-AriaNeural", "WorldWeaver πΈοΈ": "en-US-JennyNeural",
|
65 |
"Terraformer π±": "en-GB-SoniaNeural", "SkyArchitect βοΈ": "en-AU-NatashaNeural",
|
@@ -82,48 +82,69 @@ PRIMITIVE_MAP = {
|
|
82 |
TOOLS_MAP = {"None": "π«"}
|
83 |
TOOLS_MAP.update({name: emoji for emoji, name in PRIMITIVE_MAP.items()})
|
84 |
|
85 |
-
# --- Directories ---
|
86 |
for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
|
87 |
os.makedirs(d, exist_ok=True)
|
88 |
|
89 |
# --- API Keys (Placeholder) ---
|
90 |
load_dotenv()
|
91 |
|
|
|
|
|
|
|
92 |
# ==============================================================================
|
93 |
# Utility Functions
|
94 |
# ==============================================================================
|
95 |
|
96 |
def get_current_time_str(tz='UTC'):
|
|
|
97 |
try:
|
98 |
timezone = pytz.timezone(tz)
|
99 |
now_aware = datetime.now(timezone)
|
100 |
-
except
|
|
|
|
|
|
|
101 |
now_aware = datetime.now(pytz.utc)
|
102 |
return now_aware.strftime('%Y%m%d_%H%M%S')
|
103 |
|
104 |
def clean_filename_part(text, max_len=25):
|
|
|
105 |
if not isinstance(text, str): text = "invalid_name"
|
106 |
text = re.sub(r'\s+', '_', text)
|
107 |
text = re.sub(r'[^\w\-.]', '', text)
|
108 |
return text[:max_len]
|
109 |
|
110 |
def run_async(async_func, *args, **kwargs):
|
111 |
-
"""Runs an async function safely from a sync context."""
|
|
|
|
|
112 |
try:
|
113 |
loop = asyncio.get_running_loop()
|
|
|
114 |
return loop.create_task(async_func(*args, **kwargs))
|
115 |
-
except RuntimeError:
|
116 |
-
|
117 |
-
|
118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
119 |
|
120 |
-
def ensure_dir(dir_path):
|
|
|
|
|
121 |
|
122 |
# ==============================================================================
|
123 |
# World State File Handling (Markdown + JSON)
|
124 |
# ==============================================================================
|
125 |
|
126 |
def generate_world_save_filename(username="User", world_name="World"):
|
|
|
127 |
timestamp = get_current_time_str()
|
128 |
clean_user = clean_filename_part(username, 15)
|
129 |
clean_world = clean_filename_part(world_name, 20)
|
@@ -131,6 +152,7 @@ def generate_world_save_filename(username="User", world_name="World"):
|
|
131 |
return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
|
132 |
|
133 |
def parse_world_filename(filename):
|
|
|
134 |
basename = os.path.basename(filename)
|
135 |
if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
|
136 |
core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
|
@@ -142,7 +164,8 @@ def parse_world_filename(filename):
|
|
142 |
dt_obj = None
|
143 |
try:
|
144 |
dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
|
145 |
-
except Exception:
|
|
|
146 |
return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
|
147 |
|
148 |
# Fallback
|
@@ -150,11 +173,13 @@ def parse_world_filename(filename):
|
|
150 |
try:
|
151 |
mtime = os.path.getmtime(filename)
|
152 |
dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
|
153 |
-
except Exception:
|
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 save_world_to_md(target_filename_base, world_data_dict):
|
|
|
158 |
save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
|
159 |
print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
|
160 |
success = False
|
@@ -169,34 +194,55 @@ def save_world_to_md(target_filename_base, world_data_dict):
|
|
169 |
{json.dumps(world_data_dict, indent=2)}
|
170 |
```"""
|
171 |
try:
|
172 |
-
ensure_dir(SAVED_WORLDS_DIR)
|
173 |
-
with open(save_path, 'w', encoding='utf-8') as f:
|
174 |
-
|
175 |
-
|
|
|
|
|
|
|
176 |
return success
|
177 |
|
|
|
178 |
def load_world_from_md(filename_base):
|
|
|
179 |
load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
|
180 |
print(f"Loading world state dictionary from MD file: {load_path}...")
|
181 |
-
if not os.path.exists(load_path):
|
|
|
|
|
182 |
try:
|
183 |
-
with open(load_path, 'r', encoding='utf-8') as f:
|
|
|
|
|
184 |
json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE)
|
185 |
-
if not json_match:
|
|
|
|
|
186 |
world_data_dict = json.loads(json_match.group(1))
|
187 |
print(f"Parsed {len(world_data_dict)} objects from {filename_base}.")
|
188 |
return world_data_dict # Return the dictionary
|
189 |
-
except json.JSONDecodeError as e:
|
190 |
-
|
|
|
|
|
|
|
|
|
|
|
191 |
|
192 |
def get_saved_worlds():
|
|
|
193 |
try:
|
194 |
ensure_dir(SAVED_WORLDS_DIR);
|
195 |
world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
|
196 |
parsed_worlds = [parse_world_filename(f) for f in world_files]
|
|
|
197 |
parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
|
198 |
return parsed_worlds
|
199 |
-
except Exception as e:
|
|
|
|
|
|
|
200 |
|
201 |
# ==============================================================================
|
202 |
# User State & Session Init
|
@@ -214,6 +260,7 @@ def load_username():
|
|
214 |
return None
|
215 |
|
216 |
def init_session_state():
|
|
|
217 |
defaults = {
|
218 |
'last_chat_update': 0, 'message_input': "", 'audio_cache': {},
|
219 |
'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [], 'enable_audio': True,
|
@@ -228,9 +275,14 @@ def init_session_state():
|
|
228 |
}
|
229 |
for k, v in defaults.items():
|
230 |
if k not in st.session_state:
|
231 |
-
|
232 |
-
|
233 |
-
|
|
|
|
|
|
|
|
|
|
|
234 |
if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
|
235 |
if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
|
236 |
if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
|
@@ -240,32 +292,39 @@ def init_session_state():
|
|
240 |
# Action Log Helper
|
241 |
# ==============================================================================
|
242 |
def add_action_log(message):
|
|
|
243 |
if 'action_log' not in st.session_state:
|
244 |
st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
|
245 |
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
|
246 |
st.session_state.action_log.appendleft(f"[{timestamp}] {message}")
|
247 |
|
248 |
# ==============================================================================
|
249 |
# JS Communication Handler Function
|
250 |
# ==============================================================================
|
|
|
251 |
def handle_js_object_placed(data):
|
252 |
"""Callback triggered by JS when an object is placed. Stores data in state."""
|
253 |
print(f"Python received object placed event data: {type(data)}")
|
254 |
processed_data = None
|
255 |
-
|
|
|
|
|
|
|
256 |
try: processed_data = json.loads(data)
|
257 |
except json.JSONDecodeError: print("Failed decode JSON from JS object place event."); return False
|
258 |
-
elif isinstance(data, dict): processed_data = data
|
259 |
else: print(f"Received unexpected data type from JS place event: {type(data)}"); return False
|
260 |
|
261 |
if processed_data and 'obj_id' in processed_data and 'type' in processed_data:
|
262 |
st.session_state.js_object_placed_data = processed_data # Store for main loop processing
|
263 |
add_action_log(f"Placed {processed_data.get('type', 'object')} ({processed_data.get('obj_id', 'N/A')[:6]}...)")
|
|
|
|
|
264 |
else: print("Received invalid object placement data structure from JS."); return False
|
265 |
-
|
266 |
|
267 |
# ==============================================================================
|
268 |
-
# Audio / TTS / Chat / File Handling Helpers
|
269 |
# ==============================================================================
|
270 |
# --- Text & File Helpers ---
|
271 |
def clean_text_for_tts(text):
|
@@ -391,28 +450,30 @@ def delete_files(file_patterns, exclude_files=None):
|
|
391 |
# --- Image Handling ---
|
392 |
async def save_pasted_image(image, username):
|
393 |
if not image: return None
|
394 |
-
try:
|
|
|
|
|
395 |
except Exception as e: print(f"Failed image save: {e}"); return None
|
396 |
|
397 |
def paste_image_component():
|
398 |
pasted_img = None; img_type = None
|
399 |
-
|
|
|
400 |
if st.button("Process Pasted Image π", key="process_paste_button"):
|
401 |
-
st.session_state.paste_image_base64_input = paste_input_value
|
402 |
if paste_input_value and paste_input_value.startswith('data:image'):
|
403 |
try:
|
404 |
mime_type = paste_input_value.split(';')[0].split(':')[1]; base64_str = paste_input_value.split(',')[1]; img_bytes = base64.b64decode(base64_str); pasted_img = Image.open(io.BytesIO(img_bytes)); img_type = mime_type.split('/')[1]
|
405 |
-
st.image(pasted_img, caption=f"Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str
|
406 |
-
|
407 |
-
st.
|
408 |
-
|
409 |
-
except
|
410 |
-
|
411 |
-
|
412 |
-
if
|
413 |
-
|
414 |
-
|
415 |
-
|
416 |
|
417 |
# --- PDF Processing ---
|
418 |
class AudioProcessor:
|
@@ -435,47 +496,83 @@ class AudioProcessor:
|
|
435 |
|
436 |
def process_pdf_tab(pdf_file, max_pages, voice):
|
437 |
st.subheader("PDF Processing Results")
|
438 |
-
if pdf_file is None:
|
|
|
|
|
439 |
audio_processor = AudioProcessor()
|
440 |
try:
|
441 |
-
reader=PdfReader(pdf_file)
|
442 |
-
if reader.is_encrypted:
|
443 |
-
|
|
|
|
|
|
|
444 |
st.write(f"Processing first {pages_to_process} of {total_pages_in_pdf} pages from '{pdf_file.name}'...")
|
445 |
texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
|
446 |
|
447 |
def process_page_sync(page_num, page_text):
|
448 |
async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
|
449 |
try:
|
450 |
-
|
451 |
-
|
452 |
-
|
453 |
-
except Exception as page_e:
|
|
|
454 |
|
|
|
455 |
for i in range(pages_to_process):
|
456 |
-
try:
|
457 |
-
|
458 |
-
|
459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
460 |
|
|
|
461 |
progress_bar = st.progress(0.0, text="Processing pages...")
|
462 |
-
total_threads = len(page_threads)
|
|
|
463 |
while any(t.is_alive() for t in page_threads):
|
464 |
-
completed_threads = total_threads - sum(t.is_alive() for t in page_threads)
|
|
|
465 |
progress_bar.progress(min(progress, 1.0), text=f"Processed {completed_threads}/{total_threads} pages...")
|
466 |
-
if time.time() - start_join_time > 600:
|
467 |
-
|
|
|
|
|
|
|
468 |
progress_bar.progress(1.0, text="Processing complete.")
|
469 |
|
|
|
|
|
470 |
for i in range(pages_to_process):
|
471 |
with st.expander(f"Page {i+1}"):
|
472 |
st.markdown(texts.get(i, "[Error getting text]"))
|
473 |
-
audio_file = audios.get(i)
|
474 |
-
if audio_file:
|
475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
476 |
|
477 |
-
except ImportError: st.error("PyPDF2 library needed.")
|
478 |
-
except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
|
479 |
|
480 |
# ==============================================================================
|
481 |
# Streamlit UI Layout Functions
|
@@ -489,19 +586,22 @@ def render_sidebar():
|
|
489 |
# --- World Save ---
|
490 |
current_file = st.session_state.get('current_world_file')
|
491 |
current_world_name = "Live State"
|
492 |
-
default_save_name =
|
493 |
if current_file:
|
494 |
parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
|
495 |
current_world_name = parsed.get("name", current_file)
|
496 |
default_save_name = current_world_name # Default to overwriting current name
|
497 |
|
498 |
world_save_name = st.text_input(
|
499 |
-
"World Name for Save:",
|
|
|
|
|
500 |
help="Enter name to save as new, or keep current name to overwrite."
|
501 |
)
|
502 |
|
503 |
if st.button("πΎ Save Current World View", key="sidebar_save_world"):
|
504 |
-
if not world_save_name.strip():
|
|
|
505 |
else:
|
506 |
with st.spinner("Requesting world state & saving..."):
|
507 |
js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_sidebar_save", want_result=True)
|
@@ -509,19 +609,27 @@ def render_sidebar():
|
|
509 |
try:
|
510 |
world_data_dict = json.loads(js_world_state_str)
|
511 |
if isinstance(world_data_dict, dict):
|
512 |
-
filename_to_save = ""
|
|
|
513 |
if current_file:
|
514 |
parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
|
515 |
-
if
|
516 |
-
|
|
|
|
|
|
|
|
|
|
|
517 |
|
518 |
if save_world_to_md(filename_to_save, world_data_dict):
|
519 |
action = "Overwritten" if is_overwrite else "Saved new"
|
520 |
-
st.success(f"World {action}: {filename_to_save}")
|
|
|
521 |
st.session_state.current_world_file = filename_to_save # Track saved file
|
522 |
-
st.
|
523 |
-
|
524 |
-
|
|
|
525 |
except json.JSONDecodeError: st.error("Failed to decode state from client.")
|
526 |
except Exception as e: st.error(f"Save error: {e}")
|
527 |
else: st.warning("Did not receive world state from client.")
|
@@ -530,41 +638,38 @@ def render_sidebar():
|
|
530 |
st.markdown("---")
|
531 |
st.header("π Load World")
|
532 |
saved_worlds = get_saved_worlds()
|
|
|
533 |
if not saved_worlds: st.caption("No saved worlds found.")
|
534 |
else:
|
535 |
-
|
536 |
-
|
537 |
-
|
538 |
-
|
539 |
-
|
540 |
-
|
541 |
-
|
542 |
-
|
|
|
|
|
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 |
-
|
547 |
-
|
548 |
-
|
549 |
-
|
550 |
-
|
551 |
-
|
552 |
-
|
553 |
-
|
554 |
-
|
555 |
-
|
556 |
-
|
557 |
-
|
558 |
-
|
559 |
-
|
560 |
-
|
561 |
-
|
562 |
-
add_action_log(f"Loading world: {f_basename}")
|
563 |
-
st.rerun()
|
564 |
-
else: st.error(f"Failed to parse world file: {f_basename}")
|
565 |
-
if show_expander_trigger: # If this was the trigger, stop outer loop
|
566 |
-
break
|
567 |
-
displayed_count += 1
|
568 |
|
569 |
|
570 |
# --- Build Tools ---
|
@@ -576,24 +681,22 @@ def render_sidebar():
|
|
576 |
try: tool_index = tool_options.index(current_tool_name)
|
577 |
except ValueError: tool_index = 0
|
578 |
|
|
|
|
|
579 |
selected_tool = st.radio(
|
580 |
"Select Tool:", options=tool_options, index=tool_index,
|
581 |
format_func=lambda name: f"{TOOLS_MAP.get(name, '')} {name}",
|
582 |
-
key="tool_selector_radio", horizontal=True
|
583 |
)
|
|
|
584 |
if selected_tool != current_tool_name:
|
585 |
st.session_state.selected_object = selected_tool
|
586 |
add_action_log(f"Selected tool: {selected_tool}")
|
587 |
-
#
|
588 |
-
try:
|
589 |
streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
|
590 |
-
except Exception as e:
|
591 |
-
print(f"JS tool update error: {e}")
|
592 |
-
# Rerun might still be useful to ensure radio button visual updates correctly,
|
593 |
-
# although state change should handle it. Let's keep it for now.
|
594 |
st.rerun()
|
595 |
|
596 |
-
|
597 |
# --- Action Log ---
|
598 |
st.markdown("---")
|
599 |
st.header("π Action Log")
|
@@ -603,7 +706,6 @@ def render_sidebar():
|
|
603 |
if log_entries: st.code('\n'.join(log_entries), language="log")
|
604 |
else: st.caption("No actions recorded yet.")
|
605 |
|
606 |
-
|
607 |
# --- Voice/User ---
|
608 |
st.markdown("---")
|
609 |
st.header("π£οΈ Voice & User")
|
@@ -624,27 +726,25 @@ def render_main_content():
|
|
624 |
"""Renders the main content area with tabs."""
|
625 |
st.title(f"{Site_Name} - User: {st.session_state.username}")
|
626 |
|
627 |
-
# Check if world data needs to be sent to JS
|
628 |
world_data_to_load = st.session_state.pop('world_to_load_data', None)
|
629 |
if world_data_to_load is not None:
|
630 |
print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
|
631 |
try:
|
632 |
-
# Call JS function loadWorldState, pass data as JSON
|
633 |
streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
|
634 |
st.toast("World loaded in 3D view.", icon="π")
|
635 |
except Exception as e:
|
636 |
st.error(f"Failed to send loaded world state to JS: {e}")
|
637 |
|
638 |
# Set up the mechanism for JS to call Python when an object is placed
|
|
|
639 |
streamlit_js_eval(
|
640 |
js_code="""
|
641 |
-
// Ensure function exists and avoid re-defining listener if possible
|
642 |
if (!window.sendPlacedObjectToPython) {
|
643 |
console.log('Defining sendPlacedObjectToPython for JS->Python comms...');
|
644 |
window.sendPlacedObjectToPython = (objectData) => {
|
645 |
console.log('JS sending placed object:', objectData);
|
646 |
-
// Call Python function
|
647 |
-
// Pass data directly as object - streamlit_js_eval handles serialization
|
648 |
streamlit_js_eval(python_code='handle_js_object_placed(data=' + JSON.stringify(objectData) + ')', key='js_place_event_handler');
|
649 |
}
|
650 |
}
|
@@ -652,13 +752,17 @@ def render_main_content():
|
|
652 |
key="setup_js_place_event_handler" # Key for the setup code itself
|
653 |
)
|
654 |
|
655 |
-
# Check if the Python handler function was triggered
|
656 |
-
|
657 |
-
|
658 |
-
|
659 |
-
|
660 |
-
|
661 |
-
|
|
|
|
|
|
|
|
|
662 |
|
663 |
|
664 |
# Define Tabs
|
@@ -672,32 +776,28 @@ def render_main_content():
|
|
672 |
if current_file_basename:
|
673 |
full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
|
674 |
if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
|
675 |
-
else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
|
676 |
-
else: st.info("Live State Active (Save to persist
|
677 |
|
678 |
# Embed HTML Component
|
679 |
html_file_path = 'index.html'
|
680 |
try:
|
681 |
with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
|
682 |
-
# Inject state needed by JS
|
683 |
-
#
|
684 |
-
|
685 |
-
|
686 |
-
|
687 |
-
|
688 |
-
|
689 |
-
|
690 |
-
elif st.session_state.get('current_world_file'):
|
691 |
-
# If a file is loaded but data wasn't popped, try loading it again for injection
|
692 |
-
loaded_dict = load_world_from_md(st.session_state.current_world_file)
|
693 |
-
if loaded_dict: initial_world_data = loaded_dict
|
694 |
|
695 |
js_injection_script = f"""<script>
|
696 |
window.USERNAME = {json.dumps(st.session_state.username)};
|
697 |
window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
|
698 |
window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
|
699 |
window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
|
700 |
-
// Send current state
|
701 |
window.INITIAL_WORLD_OBJECTS = {json.dumps(initial_world_data)};
|
702 |
console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjects: {len(initial_world_data)} }});
|
703 |
</script>"""
|
@@ -716,23 +816,18 @@ def render_main_content():
|
|
716 |
if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:])))
|
717 |
else: st.caption("No chat messages yet.")
|
718 |
|
719 |
-
|
720 |
-
def clear_chat_input_callback():
|
721 |
-
st.session_state.message_input = ""
|
722 |
-
|
723 |
message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
|
724 |
send_button_clicked = st.button("Send Chat", key="send_chat_button", on_click=clear_chat_input_callback)
|
725 |
|
726 |
-
if send_button_clicked:
|
727 |
message_to_send = message_value # Value before potential clear by callback
|
728 |
if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
|
729 |
st.session_state.last_message = message_to_send
|
730 |
voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
|
731 |
-
# Use run_async for background tasks
|
732 |
run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
|
733 |
# Rerun is handled implicitly by button click + callback
|
734 |
elif send_button_clicked: st.toast("Message empty or same as last.")
|
735 |
-
# Removed autosend checkbox for simplicity
|
736 |
|
737 |
# --- PDF Tab ---
|
738 |
with tab_pdf:
|
@@ -750,7 +845,6 @@ def render_main_content():
|
|
750 |
st.subheader("πΎ World Management")
|
751 |
current_file_basename = st.session_state.get('current_world_file', None)
|
752 |
|
753 |
-
# Save Current Version Button (if a world is loaded)
|
754 |
if current_file_basename:
|
755 |
full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
|
756 |
save_label = f"Save Changes to '{current_file_basename}'"
|
@@ -771,7 +865,6 @@ def render_main_content():
|
|
771 |
else: st.warning("Did not receive world state from client.")
|
772 |
else: st.info("Load a world or use 'Save As New Version' below.")
|
773 |
|
774 |
-
# Save As New Version Section
|
775 |
st.subheader("Save As New Version")
|
776 |
new_name_files = st.text_input("World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
|
777 |
if st.button("πΎ Save Current View as New Version", key="save_new_version_files"):
|
@@ -794,7 +887,7 @@ def render_main_content():
|
|
794 |
else: st.warning("Did not receive world state from client.")
|
795 |
else: st.warning("Please enter a name.")
|
796 |
|
797 |
-
#
|
798 |
|
799 |
st.subheader("ποΈ Delete Files")
|
800 |
st.warning("Deletion is permanent!", icon="β οΈ")
|
@@ -804,6 +897,7 @@ def render_main_content():
|
|
804 |
with col_del2:
|
805 |
if st.button("ποΈ Audio", key="del_audio_mp3"): delete_files([os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3")]); st.session_state.audio_cache = {}; st.rerun()
|
806 |
with col_del3:
|
|
|
807 |
if st.button("ποΈ Worlds", key="del_worlds_md"): delete_files([os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")]); st.session_state.current_world_file = None; st.rerun()
|
808 |
with col_del4:
|
809 |
if st.button("ποΈ All Gen", key="del_all_gen"): delete_files([os.path.join(CHAT_DIR, "*.md"), os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3"), os.path.join(SAVED_WORLDS_DIR, "*.md"), os.path.join(MEDIA_DIR, "*.zip")]); st.session_state.chat_history = []; st.session_state.audio_cache = {}; st.session_state.current_world_file = None; st.rerun()
|
@@ -822,7 +916,8 @@ def render_main_content():
|
|
822 |
st.caption("Existing Zip Files:")
|
823 |
for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
|
824 |
else:
|
825 |
-
|
|
|
826 |
|
827 |
|
828 |
# ==============================================================================
|
@@ -850,7 +945,7 @@ def initialize_app():
|
|
850 |
if world_dict is not None:
|
851 |
st.session_state.world_to_load_data = world_dict # Queue data to be sent to JS
|
852 |
st.session_state.current_world_file = latest_world_file_basename # Set as current
|
853 |
-
else: print("Failed to load most recent world
|
854 |
else:
|
855 |
print("No saved worlds found, starting empty.");
|
856 |
st.session_state.world_to_load_data = {} # Send empty state to JS initially
|
|
|
1 |
+
# app.py (Full Code - Fixes Applied, Single Statements per Line)
|
2 |
import streamlit as st
|
3 |
import asyncio
|
4 |
import uuid
|
|
|
21 |
import json
|
22 |
import zipfile
|
23 |
from dotenv import load_dotenv
|
24 |
+
# from streamlit_marquee import streamlit_marquee # Import if needed
|
25 |
from collections import defaultdict, Counter, deque
|
26 |
from streamlit_js_eval import streamlit_js_eval # Correct import
|
27 |
from PIL import Image
|
|
|
59 |
WORLD_STATE_FILE_MD_PREFIX = "π_"
|
60 |
MAX_ACTION_LOG_SIZE = 30
|
61 |
|
62 |
+
# User/Chat Constants
|
63 |
FUN_USERNAMES = {
|
64 |
"BuilderBot π€": "en-US-AriaNeural", "WorldWeaver πΈοΈ": "en-US-JennyNeural",
|
65 |
"Terraformer π±": "en-GB-SoniaNeural", "SkyArchitect βοΈ": "en-AU-NatashaNeural",
|
|
|
82 |
TOOLS_MAP = {"None": "π«"}
|
83 |
TOOLS_MAP.update({name: emoji for emoji, name in PRIMITIVE_MAP.items()})
|
84 |
|
85 |
+
# --- Ensure Directories Exist ---
|
86 |
for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
|
87 |
os.makedirs(d, exist_ok=True)
|
88 |
|
89 |
# --- API Keys (Placeholder) ---
|
90 |
load_dotenv()
|
91 |
|
92 |
+
# --- Lock for Action Log (Session State is generally per-session, but use if needed) ---
|
93 |
+
# action_log_lock = threading.Lock() # Usually not needed for session_state modifications
|
94 |
+
|
95 |
# ==============================================================================
|
96 |
# Utility Functions
|
97 |
# ==============================================================================
|
98 |
|
99 |
def get_current_time_str(tz='UTC'):
|
100 |
+
"""Gets formatted timestamp string in specified timezone (default UTC)."""
|
101 |
try:
|
102 |
timezone = pytz.timezone(tz)
|
103 |
now_aware = datetime.now(timezone)
|
104 |
+
except pytz.UnknownTimeZoneError:
|
105 |
+
now_aware = datetime.now(pytz.utc)
|
106 |
+
except Exception as e:
|
107 |
+
print(f"Timezone error ({tz}), using UTC. Error: {e}")
|
108 |
now_aware = datetime.now(pytz.utc)
|
109 |
return now_aware.strftime('%Y%m%d_%H%M%S')
|
110 |
|
111 |
def clean_filename_part(text, max_len=25):
|
112 |
+
"""Cleans a string part for use in a filename."""
|
113 |
if not isinstance(text, str): text = "invalid_name"
|
114 |
text = re.sub(r'\s+', '_', text)
|
115 |
text = re.sub(r'[^\w\-.]', '', text)
|
116 |
return text[:max_len]
|
117 |
|
118 |
def run_async(async_func, *args, **kwargs):
|
119 |
+
"""Runs an async function safely from a sync context using create_task or asyncio.run."""
|
120 |
+
# This helper attempts to schedule the async function as a background task.
|
121 |
+
# Note: Background tasks in Streamlit might have limitations accessing session state later.
|
122 |
try:
|
123 |
loop = asyncio.get_running_loop()
|
124 |
+
# Create task to run concurrently
|
125 |
return loop.create_task(async_func(*args, **kwargs))
|
126 |
+
except RuntimeError: # No running loop in this thread
|
127 |
+
# Fallback: Run in a new loop (blocks until completion)
|
128 |
+
# print(f"Warning: Running async func {async_func.__name__} in new event loop.")
|
129 |
+
try:
|
130 |
+
return asyncio.run(async_func(*args, **kwargs))
|
131 |
+
except Exception as e:
|
132 |
+
print(f"Error running async func {async_func.__name__} in new loop: {e}")
|
133 |
+
return None
|
134 |
+
except Exception as e:
|
135 |
+
print(f"Error scheduling async task {async_func.__name__}: {e}")
|
136 |
+
return None
|
137 |
|
138 |
+
def ensure_dir(dir_path):
|
139 |
+
"""Creates directory if it doesn't exist."""
|
140 |
+
os.makedirs(dir_path, exist_ok=True)
|
141 |
|
142 |
# ==============================================================================
|
143 |
# World State File Handling (Markdown + JSON)
|
144 |
# ==============================================================================
|
145 |
|
146 |
def generate_world_save_filename(username="User", world_name="World"):
|
147 |
+
"""Generates a filename for saving world state MD files."""
|
148 |
timestamp = get_current_time_str()
|
149 |
clean_user = clean_filename_part(username, 15)
|
150 |
clean_world = clean_filename_part(world_name, 20)
|
|
|
152 |
return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
|
153 |
|
154 |
def parse_world_filename(filename):
|
155 |
+
"""Extracts info from filename if possible, otherwise returns defaults."""
|
156 |
basename = os.path.basename(filename)
|
157 |
if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
|
158 |
core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
|
|
|
164 |
dt_obj = None
|
165 |
try:
|
166 |
dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
|
167 |
+
except Exception:
|
168 |
+
dt_obj = None
|
169 |
return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
|
170 |
|
171 |
# Fallback
|
|
|
173 |
try:
|
174 |
mtime = os.path.getmtime(filename)
|
175 |
dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
|
176 |
+
except Exception:
|
177 |
+
pass
|
178 |
return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
|
179 |
|
180 |
+
|
181 |
def save_world_to_md(target_filename_base, world_data_dict):
|
182 |
+
"""Saves the provided world state dictionary to a specific MD file."""
|
183 |
save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
|
184 |
print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
|
185 |
success = False
|
|
|
194 |
{json.dumps(world_data_dict, indent=2)}
|
195 |
```"""
|
196 |
try:
|
197 |
+
ensure_dir(SAVED_WORLDS_DIR);
|
198 |
+
with open(save_path, 'w', encoding='utf-8') as f:
|
199 |
+
f.write(md_content)
|
200 |
+
print(f"World state saved successfully to {target_filename_base}")
|
201 |
+
success = True
|
202 |
+
except Exception as e:
|
203 |
+
print(f"Error saving world state to {save_path}: {e}")
|
204 |
return success
|
205 |
|
206 |
+
|
207 |
def load_world_from_md(filename_base):
|
208 |
+
"""Loads world state dict from an MD file (basename), returns dict or None."""
|
209 |
load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
|
210 |
print(f"Loading world state dictionary from MD file: {load_path}...")
|
211 |
+
if not os.path.exists(load_path):
|
212 |
+
st.error(f"World file not found: {filename_base}")
|
213 |
+
return None
|
214 |
try:
|
215 |
+
with open(load_path, 'r', encoding='utf-8') as f:
|
216 |
+
content = f.read()
|
217 |
+
# Find JSON block more robustly
|
218 |
json_match = re.search(r"```json\s*(\{[\s\S]*?\})\s*```", content, re.IGNORECASE)
|
219 |
+
if not json_match:
|
220 |
+
st.error(f"Could not find valid JSON block in {filename_base}")
|
221 |
+
return None
|
222 |
world_data_dict = json.loads(json_match.group(1))
|
223 |
print(f"Parsed {len(world_data_dict)} objects from {filename_base}.")
|
224 |
return world_data_dict # Return the dictionary
|
225 |
+
except json.JSONDecodeError as e:
|
226 |
+
st.error(f"Invalid JSON found in {filename_base}: {e}")
|
227 |
+
return None
|
228 |
+
except Exception as e:
|
229 |
+
st.error(f"Error loading world state from {filename_base}: {e}")
|
230 |
+
st.exception(e)
|
231 |
+
return None
|
232 |
|
233 |
def get_saved_worlds():
|
234 |
+
"""Scans the saved worlds directory for world MD files and parses them."""
|
235 |
try:
|
236 |
ensure_dir(SAVED_WORLDS_DIR);
|
237 |
world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
|
238 |
parsed_worlds = [parse_world_filename(f) for f in world_files]
|
239 |
+
# Sort by datetime object (newest first), handle None dt values
|
240 |
parsed_worlds.sort(key=lambda x: x['dt'] if x['dt'] else datetime.min.replace(tzinfo=pytz.utc), reverse=True)
|
241 |
return parsed_worlds
|
242 |
+
except Exception as e:
|
243 |
+
print(f"Error scanning saved worlds: {e}")
|
244 |
+
st.error(f"Could not scan saved worlds: {e}")
|
245 |
+
return []
|
246 |
|
247 |
# ==============================================================================
|
248 |
# User State & Session Init
|
|
|
260 |
return None
|
261 |
|
262 |
def init_session_state():
|
263 |
+
"""Initializes Streamlit session state variables."""
|
264 |
defaults = {
|
265 |
'last_chat_update': 0, 'message_input': "", 'audio_cache': {},
|
266 |
'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [], 'enable_audio': True,
|
|
|
275 |
}
|
276 |
for k, v in defaults.items():
|
277 |
if k not in st.session_state:
|
278 |
+
# Use copy for mutable defaults like deque to avoid shared reference issue
|
279 |
+
if isinstance(v, deque):
|
280 |
+
st.session_state[k] = v.copy()
|
281 |
+
elif isinstance(v, (dict, list)): # Also copy dicts/lists if needed
|
282 |
+
st.session_state[k] = v.copy()
|
283 |
+
else:
|
284 |
+
st.session_state[k] = v
|
285 |
+
# Ensure complex types are correctly initialized if session reloads partially
|
286 |
if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
|
287 |
if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
|
288 |
if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
|
|
|
292 |
# Action Log Helper
|
293 |
# ==============================================================================
|
294 |
def add_action_log(message):
|
295 |
+
"""Adds a message to the session's action log."""
|
296 |
if 'action_log' not in st.session_state:
|
297 |
st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
|
298 |
timestamp = datetime.now().strftime("%H:%M:%S")
|
299 |
+
# Prepend so newest is at top
|
300 |
st.session_state.action_log.appendleft(f"[{timestamp}] {message}")
|
301 |
|
302 |
# ==============================================================================
|
303 |
# JS Communication Handler Function
|
304 |
# ==============================================================================
|
305 |
+
# This function needs to be defined globally for streamlit_js_eval to find it by name
|
306 |
def handle_js_object_placed(data):
|
307 |
"""Callback triggered by JS when an object is placed. Stores data in state."""
|
308 |
print(f"Python received object placed event data: {type(data)}")
|
309 |
processed_data = None
|
310 |
+
# Logic assumes streamlit_js_eval passes the JS object directly as Python dict/list
|
311 |
+
if isinstance(data, dict):
|
312 |
+
processed_data = data
|
313 |
+
elif isinstance(data, str): # Fallback if it comes as JSON string
|
314 |
try: processed_data = json.loads(data)
|
315 |
except json.JSONDecodeError: print("Failed decode JSON from JS object place event."); return False
|
|
|
316 |
else: print(f"Received unexpected data type from JS place event: {type(data)}"); return False
|
317 |
|
318 |
if processed_data and 'obj_id' in processed_data and 'type' in processed_data:
|
319 |
st.session_state.js_object_placed_data = processed_data # Store for main loop processing
|
320 |
add_action_log(f"Placed {processed_data.get('type', 'object')} ({processed_data.get('obj_id', 'N/A')[:6]}...)")
|
321 |
+
# Return value isn't used by the JS call, but good practice
|
322 |
+
return True
|
323 |
else: print("Received invalid object placement data structure from JS."); return False
|
324 |
+
|
325 |
|
326 |
# ==============================================================================
|
327 |
+
# Audio / TTS / Chat / File Handling Helpers (Keep implementations)
|
328 |
# ==============================================================================
|
329 |
# --- Text & File Helpers ---
|
330 |
def clean_text_for_tts(text):
|
|
|
450 |
# --- Image Handling ---
|
451 |
async def save_pasted_image(image, username):
|
452 |
if not image: return None
|
453 |
+
try:
|
454 |
+
img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]; timestamp = format_timestamp_prefix(username); filename = f"{timestamp}_pasted_{img_hash}.png"; filepath = os.path.join(MEDIA_DIR, filename)
|
455 |
+
image.save(filepath, "PNG"); print(f"Pasted image saved: {filepath}"); return filepath
|
456 |
except Exception as e: print(f"Failed image save: {e}"); return None
|
457 |
|
458 |
def paste_image_component():
|
459 |
pasted_img = None; img_type = None
|
460 |
+
# Simplified paste component logic
|
461 |
+
paste_input_value = st.text_area("Paste Image Data Here", key="paste_input_area", height=50)
|
462 |
if st.button("Process Pasted Image π", key="process_paste_button"):
|
|
|
463 |
if paste_input_value and paste_input_value.startswith('data:image'):
|
464 |
try:
|
465 |
mime_type = paste_input_value.split(';')[0].split(':')[1]; base64_str = paste_input_value.split(',')[1]; img_bytes = base64.b64decode(base64_str); pasted_img = Image.open(io.BytesIO(img_bytes)); img_type = mime_type.split('/')[1]
|
466 |
+
st.image(pasted_img, caption=f"Pasted ({img_type.upper()})", width=150); st.session_state.paste_image_base64 = base64_str # Store processed base64
|
467 |
+
# Clear input area state for next run - using callback is better if possible
|
468 |
+
# st.session_state.paste_input_area = "" # Direct modification fails
|
469 |
+
st.rerun() # Rerun necessary to potentially process image
|
470 |
+
except ImportError: st.error("Pillow library needed.")
|
471 |
+
except Exception as e: st.error(f"Img decode err: {e}"); st.session_state.paste_image_base64 = ""
|
472 |
+
else: st.warning("No valid image data pasted."); st.session_state.paste_image_base64 = ""
|
473 |
+
# Return the image if successfully processed in *this* run after button press
|
474 |
+
# This is tricky due to rerun, might need state flag
|
475 |
+
return pasted_img
|
476 |
+
|
477 |
|
478 |
# --- PDF Processing ---
|
479 |
class AudioProcessor:
|
|
|
496 |
|
497 |
def process_pdf_tab(pdf_file, max_pages, voice):
|
498 |
st.subheader("PDF Processing Results")
|
499 |
+
if pdf_file is None:
|
500 |
+
st.info("Upload a PDF file and click 'Process PDF' to begin.")
|
501 |
+
return
|
502 |
audio_processor = AudioProcessor()
|
503 |
try:
|
504 |
+
reader=PdfReader(pdf_file)
|
505 |
+
if reader.is_encrypted:
|
506 |
+
st.warning("PDF is encrypted.")
|
507 |
+
return
|
508 |
+
total_pages_in_pdf = len(reader.pages)
|
509 |
+
pages_to_process = min(total_pages_in_pdf, max_pages);
|
510 |
st.write(f"Processing first {pages_to_process} of {total_pages_in_pdf} pages from '{pdf_file.name}'...")
|
511 |
texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
|
512 |
|
513 |
def process_page_sync(page_num, page_text):
|
514 |
async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
|
515 |
try:
|
516 |
+
audio_path = asyncio.run(run_async_audio()) # asyncio.run is simpler here
|
517 |
+
if audio_path:
|
518 |
+
with results_lock: audios[page_num] = audio_path
|
519 |
+
except Exception as page_e:
|
520 |
+
print(f"Err process page {page_num+1}: {page_e}")
|
521 |
|
522 |
+
# Start threads
|
523 |
for i in range(pages_to_process):
|
524 |
+
try:
|
525 |
+
page = reader.pages[i]
|
526 |
+
text = page.extract_text()
|
527 |
+
if text and text.strip():
|
528 |
+
texts[i]=text
|
529 |
+
thread = threading.Thread(target=process_page_sync, args=(i, text))
|
530 |
+
page_threads.append(thread)
|
531 |
+
thread.start()
|
532 |
+
else:
|
533 |
+
texts[i] = "[No text extracted or page empty]"
|
534 |
+
print(f"Page {i+1}: No text extracted.")
|
535 |
+
except Exception as extract_e:
|
536 |
+
texts[i] = f"[Error extracting text: {extract_e}]"
|
537 |
+
print(f"Error page {i+1} extract: {extract_e}")
|
538 |
|
539 |
+
# Wait for threads and display progress
|
540 |
progress_bar = st.progress(0.0, text="Processing pages...")
|
541 |
+
total_threads = len(page_threads)
|
542 |
+
start_join_time = time.time()
|
543 |
while any(t.is_alive() for t in page_threads):
|
544 |
+
completed_threads = total_threads - sum(t.is_alive() for t in page_threads)
|
545 |
+
progress = completed_threads / total_threads if total_threads > 0 else 1.0
|
546 |
progress_bar.progress(min(progress, 1.0), text=f"Processed {completed_threads}/{total_threads} pages...")
|
547 |
+
if time.time() - start_join_time > 600: # 10 min timeout
|
548 |
+
print("PDF processing timed out.")
|
549 |
+
st.warning("Processing timed out.")
|
550 |
+
break
|
551 |
+
time.sleep(0.5) # Avoid busy-waiting
|
552 |
progress_bar.progress(1.0, text="Processing complete.")
|
553 |
|
554 |
+
# Display results
|
555 |
+
st.write("Displaying results:")
|
556 |
for i in range(pages_to_process):
|
557 |
with st.expander(f"Page {i+1}"):
|
558 |
st.markdown(texts.get(i, "[Error getting text]"))
|
559 |
+
audio_file = audios.get(i) # Get result from shared dict
|
560 |
+
if audio_file:
|
561 |
+
play_and_download_audio(audio_file)
|
562 |
+
else:
|
563 |
+
# Check if text existed to differentiate between skipped vs failed
|
564 |
+
page_text = texts.get(i,"")
|
565 |
+
if page_text.strip() and page_text != "[No text extracted or page empty]" and not page_text.startswith("[Error"):
|
566 |
+
st.caption("Audio generation failed or timed out.")
|
567 |
+
#else: # No text or error extracting text
|
568 |
+
# st.caption("No text to generate audio from.") # Implicit
|
569 |
+
|
570 |
+
except ImportError:
|
571 |
+
st.error("PyPDF2 library needed for PDF processing.")
|
572 |
+
except Exception as pdf_e:
|
573 |
+
st.error(f"Error reading PDF '{pdf_file.name}': {pdf_e}");
|
574 |
+
st.exception(pdf_e)
|
575 |
|
|
|
|
|
576 |
|
577 |
# ==============================================================================
|
578 |
# Streamlit UI Layout Functions
|
|
|
586 |
# --- World Save ---
|
587 |
current_file = st.session_state.get('current_world_file')
|
588 |
current_world_name = "Live State"
|
589 |
+
default_save_name = st.session_state.get('new_world_name', 'MyWorld')
|
590 |
if current_file:
|
591 |
parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
|
592 |
current_world_name = parsed.get("name", current_file)
|
593 |
default_save_name = current_world_name # Default to overwriting current name
|
594 |
|
595 |
world_save_name = st.text_input(
|
596 |
+
"World Name for Save:",
|
597 |
+
key="world_save_name_input",
|
598 |
+
value=default_save_name,
|
599 |
help="Enter name to save as new, or keep current name to overwrite."
|
600 |
)
|
601 |
|
602 |
if st.button("πΎ Save Current World View", key="sidebar_save_world"):
|
603 |
+
if not world_save_name.strip():
|
604 |
+
st.warning("Please enter a World Name.")
|
605 |
else:
|
606 |
with st.spinner("Requesting world state & saving..."):
|
607 |
js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_sidebar_save", want_result=True)
|
|
|
609 |
try:
|
610 |
world_data_dict = json.loads(js_world_state_str)
|
611 |
if isinstance(world_data_dict, dict):
|
612 |
+
filename_to_save = ""
|
613 |
+
is_overwrite = False
|
614 |
if current_file:
|
615 |
parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
|
616 |
+
# Check if input name matches the name part of the current file
|
617 |
+
if world_save_name == parsed_current.get('name', ''):
|
618 |
+
filename_to_save = current_file # Use existing basename
|
619 |
+
is_overwrite = True
|
620 |
+
|
621 |
+
if not filename_to_save: # Create new filename if not overwriting
|
622 |
+
filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
|
623 |
|
624 |
if save_world_to_md(filename_to_save, world_data_dict):
|
625 |
action = "Overwritten" if is_overwrite else "Saved new"
|
626 |
+
st.success(f"World {action}: {filename_to_save}")
|
627 |
+
add_action_log(f"Saved world: {filename_to_save}")
|
628 |
st.session_state.current_world_file = filename_to_save # Track saved file
|
629 |
+
st.session_state.new_world_name = "MyWorld" # Reset default
|
630 |
+
st.rerun() # Refresh sidebar list
|
631 |
+
else: st.error("Failed to save world state to file.")
|
632 |
+
else: st.error("Invalid state format received from client.")
|
633 |
except json.JSONDecodeError: st.error("Failed to decode state from client.")
|
634 |
except Exception as e: st.error(f"Save error: {e}")
|
635 |
else: st.warning("Did not receive world state from client.")
|
|
|
638 |
st.markdown("---")
|
639 |
st.header("π Load World")
|
640 |
saved_worlds = get_saved_worlds()
|
641 |
+
|
642 |
if not saved_worlds: st.caption("No saved worlds found.")
|
643 |
else:
|
644 |
+
st.caption("Click button to load state.")
|
645 |
+
cols_header = st.columns([4, 1, 1]) # Adjusted column ratio
|
646 |
+
with cols_header[0]: st.write("**Name** (User, Time)")
|
647 |
+
with cols_header[1]: st.write("**Load**")
|
648 |
+
with cols_header[2]: st.write("**DL**")
|
649 |
+
|
650 |
+
# Simple list without expander for now
|
651 |
+
for world_info in saved_worlds:
|
652 |
+
f_basename = os.path.basename(world_info['filename'])
|
653 |
+
f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename)
|
654 |
display_name = world_info.get('name', f_basename); user = world_info.get('user', 'N/A'); timestamp = world_info.get('timestamp', 'N/A')
|
655 |
display_text = f"{display_name} ({user}, {timestamp})"
|
656 |
+
|
657 |
+
col1, col2, col3 = st.columns([4, 1, 1])
|
658 |
+
with col1: st.write(f"<small>{display_text}</small>", unsafe_allow_html=True)
|
659 |
+
with col2:
|
660 |
+
is_current = (st.session_state.get('current_world_file') == f_basename)
|
661 |
+
btn_load = st.button("Load", key=f"load_{f_basename}", help=f"Load {f_basename}", disabled=is_current)
|
662 |
+
with col3: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
|
663 |
+
|
664 |
+
if btn_load: # Handle click if not disabled
|
665 |
+
print(f"Load button clicked for: {f_basename}")
|
666 |
+
world_dict = load_world_from_md(f_basename)
|
667 |
+
if world_dict is not None:
|
668 |
+
st.session_state.world_to_load_data = world_dict # Queue data for JS
|
669 |
+
st.session_state.current_world_file = f_basename
|
670 |
+
add_action_log(f"Loading world: {f_basename}")
|
671 |
+
st.rerun() # Trigger rerun to send data via injection/call
|
672 |
+
else: st.error(f"Failed to parse world file: {f_basename}")
|
|
|
|
|
|
|
|
|
|
|
|
|
673 |
|
674 |
|
675 |
# --- Build Tools ---
|
|
|
681 |
try: tool_index = tool_options.index(current_tool_name)
|
682 |
except ValueError: tool_index = 0
|
683 |
|
684 |
+
# Use columns for horizontal layout feel for Radio buttons
|
685 |
+
cols_tools = st.columns(len(tool_options))
|
686 |
selected_tool = st.radio(
|
687 |
"Select Tool:", options=tool_options, index=tool_index,
|
688 |
format_func=lambda name: f"{TOOLS_MAP.get(name, '')} {name}",
|
689 |
+
key="tool_selector_radio", horizontal=True, label_visibility="collapsed" # Hide label, use header
|
690 |
)
|
691 |
+
|
692 |
if selected_tool != current_tool_name:
|
693 |
st.session_state.selected_object = selected_tool
|
694 |
add_action_log(f"Selected tool: {selected_tool}")
|
695 |
+
try: # Use streamlit_js_eval, not sync
|
|
|
696 |
streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
|
697 |
+
except Exception as e: print(f"JS tool update error: {e}")
|
|
|
|
|
|
|
698 |
st.rerun()
|
699 |
|
|
|
700 |
# --- Action Log ---
|
701 |
st.markdown("---")
|
702 |
st.header("π Action Log")
|
|
|
706 |
if log_entries: st.code('\n'.join(log_entries), language="log")
|
707 |
else: st.caption("No actions recorded yet.")
|
708 |
|
|
|
709 |
# --- Voice/User ---
|
710 |
st.markdown("---")
|
711 |
st.header("π£οΈ Voice & User")
|
|
|
726 |
"""Renders the main content area with tabs."""
|
727 |
st.title(f"{Site_Name} - User: {st.session_state.username}")
|
728 |
|
729 |
+
# Check if world data needs to be sent to JS
|
730 |
world_data_to_load = st.session_state.pop('world_to_load_data', None)
|
731 |
if world_data_to_load is not None:
|
732 |
print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
|
733 |
try:
|
|
|
734 |
streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
|
735 |
st.toast("World loaded in 3D view.", icon="π")
|
736 |
except Exception as e:
|
737 |
st.error(f"Failed to send loaded world state to JS: {e}")
|
738 |
|
739 |
# Set up the mechanism for JS to call Python when an object is placed
|
740 |
+
# This defines the JS function `window.sendPlacedObjectToPython`
|
741 |
streamlit_js_eval(
|
742 |
js_code="""
|
|
|
743 |
if (!window.sendPlacedObjectToPython) {
|
744 |
console.log('Defining sendPlacedObjectToPython for JS->Python comms...');
|
745 |
window.sendPlacedObjectToPython = (objectData) => {
|
746 |
console.log('JS sending placed object:', objectData);
|
747 |
+
// Call Python function handle_js_object_placed, passing data directly
|
|
|
748 |
streamlit_js_eval(python_code='handle_js_object_placed(data=' + JSON.stringify(objectData) + ')', key='js_place_event_handler');
|
749 |
}
|
750 |
}
|
|
|
752 |
key="setup_js_place_event_handler" # Key for the setup code itself
|
753 |
)
|
754 |
|
755 |
+
# Check if the Python handler function was triggered in the previous interaction
|
756 |
+
if 'js_place_event_handler' in st.session_state:
|
757 |
+
# The handle_js_object_placed function should have stored data in this key
|
758 |
+
placed_data = st.session_state.pop('js_object_placed_data', None)
|
759 |
+
if placed_data:
|
760 |
+
print(f"Python processed stored placed object data: {placed_data.get('obj_id')}")
|
761 |
+
# Action log already added in handle_js_object_placed.
|
762 |
+
# No server-side dict to update, client manages its state until save.
|
763 |
+
pass
|
764 |
+
# Remove the trigger key itself to prevent re-processing
|
765 |
+
del st.session_state['js_place_event_handler']
|
766 |
|
767 |
|
768 |
# Define Tabs
|
|
|
776 |
if current_file_basename:
|
777 |
full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
|
778 |
if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
|
779 |
+
else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
|
780 |
+
else: st.info("Live State Active (Save to persist)")
|
781 |
|
782 |
# Embed HTML Component
|
783 |
html_file_path = 'index.html'
|
784 |
try:
|
785 |
with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
|
786 |
+
# Inject state needed by JS
|
787 |
+
# Load initial data for injection *only if* no specific load is pending
|
788 |
+
initial_world_data = {}
|
789 |
+
if world_data_to_load is None: # Check if data was *not* popped above
|
790 |
+
if st.session_state.get('current_world_file'):
|
791 |
+
loaded_dict = load_world_from_md(st.session_state.current_world_file)
|
792 |
+
if loaded_dict: initial_world_data = loaded_dict
|
793 |
+
# If current_world_file is None AND world_data_to_load is None, initial_world_data remains {}
|
|
|
|
|
|
|
|
|
794 |
|
795 |
js_injection_script = f"""<script>
|
796 |
window.USERNAME = {json.dumps(st.session_state.username)};
|
797 |
window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
|
798 |
window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
|
799 |
window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
|
800 |
+
// Send current state ONLY if not handled by explicit loadWorldState call
|
801 |
window.INITIAL_WORLD_OBJECTS = {json.dumps(initial_world_data)};
|
802 |
console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjects: {len(initial_world_data)} }});
|
803 |
</script>"""
|
|
|
816 |
if chat_history_list: st.markdown("----\n".join(reversed(chat_history_list[-50:])))
|
817 |
else: st.caption("No chat messages yet.")
|
818 |
|
819 |
+
def clear_chat_input_callback(): st.session_state.message_input = ""
|
|
|
|
|
|
|
820 |
message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
|
821 |
send_button_clicked = st.button("Send Chat", key="send_chat_button", on_click=clear_chat_input_callback)
|
822 |
|
823 |
+
if send_button_clicked:
|
824 |
message_to_send = message_value # Value before potential clear by callback
|
825 |
if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
|
826 |
st.session_state.last_message = message_to_send
|
827 |
voice = st.session_state.get('tts_voice', DEFAULT_TTS_VOICE)
|
|
|
828 |
run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
|
829 |
# Rerun is handled implicitly by button click + callback
|
830 |
elif send_button_clicked: st.toast("Message empty or same as last.")
|
|
|
831 |
|
832 |
# --- PDF Tab ---
|
833 |
with tab_pdf:
|
|
|
845 |
st.subheader("πΎ World Management")
|
846 |
current_file_basename = st.session_state.get('current_world_file', None)
|
847 |
|
|
|
848 |
if current_file_basename:
|
849 |
full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
|
850 |
save_label = f"Save Changes to '{current_file_basename}'"
|
|
|
865 |
else: st.warning("Did not receive world state from client.")
|
866 |
else: st.info("Load a world or use 'Save As New Version' below.")
|
867 |
|
|
|
868 |
st.subheader("Save As New Version")
|
869 |
new_name_files = st.text_input("World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
|
870 |
if st.button("πΎ Save Current View as New Version", key="save_new_version_files"):
|
|
|
887 |
else: st.warning("Did not receive world state from client.")
|
888 |
else: st.warning("Please enter a name.")
|
889 |
|
890 |
+
# Removed Server Status Section
|
891 |
|
892 |
st.subheader("ποΈ Delete Files")
|
893 |
st.warning("Deletion is permanent!", icon="β οΈ")
|
|
|
897 |
with col_del2:
|
898 |
if st.button("ποΈ Audio", key="del_audio_mp3"): delete_files([os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3")]); st.session_state.audio_cache = {}; st.rerun()
|
899 |
with col_del3:
|
900 |
+
# Corrected delete pattern using prefix
|
901 |
if st.button("ποΈ Worlds", key="del_worlds_md"): delete_files([os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")]); st.session_state.current_world_file = None; st.rerun()
|
902 |
with col_del4:
|
903 |
if st.button("ποΈ All Gen", key="del_all_gen"): delete_files([os.path.join(CHAT_DIR, "*.md"), os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3"), os.path.join(SAVED_WORLDS_DIR, "*.md"), os.path.join(MEDIA_DIR, "*.zip")]); st.session_state.chat_history = []; st.session_state.audio_cache = {}; st.session_state.current_world_file = None; st.rerun()
|
|
|
916 |
st.caption("Existing Zip Files:")
|
917 |
for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
|
918 |
else:
|
919 |
+
# Correct indentation confirmed here
|
920 |
+
st.caption("No zip archives found.")
|
921 |
|
922 |
|
923 |
# ==============================================================================
|
|
|
945 |
if world_dict is not None:
|
946 |
st.session_state.world_to_load_data = world_dict # Queue data to be sent to JS
|
947 |
st.session_state.current_world_file = latest_world_file_basename # Set as current
|
948 |
+
else: print("Failed to load most recent world.")
|
949 |
else:
|
950 |
print("No saved worlds found, starting empty.");
|
951 |
st.session_state.world_to_load_data = {} # Send empty state to JS initially
|