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
|