Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -1,7 +1,6 @@
|
|
1 |
-
# app.py
|
2 |
import streamlit as st
|
3 |
import asyncio
|
4 |
-
# import websockets # Removed - Not using WS for state sync
|
5 |
import uuid
|
6 |
from datetime import datetime
|
7 |
import os
|
@@ -12,21 +11,20 @@ import glob
|
|
12 |
import base64
|
13 |
import io
|
14 |
import streamlit.components.v1 as components
|
15 |
-
import edge_tts
|
16 |
import nest_asyncio
|
17 |
import re
|
18 |
import pytz
|
19 |
import shutil
|
20 |
-
from PyPDF2 import PdfReader
|
21 |
import threading
|
22 |
import json
|
23 |
import zipfile
|
24 |
from dotenv import load_dotenv
|
25 |
-
# from streamlit_marquee import streamlit_marquee #
|
26 |
-
from collections import defaultdict, Counter, deque
|
27 |
-
|
28 |
-
from
|
29 |
-
from PIL import Image # Needed for paste_image_component
|
30 |
|
31 |
# ==============================================================================
|
32 |
# Configuration & Constants
|
@@ -37,20 +35,31 @@ nest_asyncio.apply()
|
|
37 |
|
38 |
# 🎨 Page Config
|
39 |
st.set_page_config(
|
40 |
-
page_title="
|
41 |
page_icon="🏗️",
|
42 |
layout="wide",
|
43 |
initial_sidebar_state="expanded"
|
44 |
)
|
45 |
|
46 |
# General Constants
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
|
53 |
-
# User/Chat Constants
|
54 |
FUN_USERNAMES = {
|
55 |
"BuilderBot 🤖": "en-US-AriaNeural", "WorldWeaver 🕸️": "en-US-JennyNeural",
|
56 |
"Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
|
@@ -58,34 +67,20 @@ FUN_USERNAMES = {
|
|
58 |
"CosmicCrafter ✨": "en-GB-RyanNeural", "GeoGuru 🗺️": "en-AU-WilliamNeural",
|
59 |
"BlockBard 🧱": "en-CA-LiamNeural", "SoundSculptor 🔊": "en-US-AnaNeural",
|
60 |
}
|
61 |
-
EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values()))
|
62 |
-
DEFAULT_TTS_VOICE = "en-US-AriaNeural"
|
63 |
-
CHAT_DIR = "chat_logs"
|
64 |
-
|
65 |
-
# Audio Constants
|
66 |
-
AUDIO_CACHE_DIR = "audio_cache"
|
67 |
-
AUDIO_DIR = "audio_logs"
|
68 |
-
|
69 |
-
# World Builder Constants
|
70 |
-
SAVED_WORLDS_DIR = "saved_worlds" # Directory for MD world files
|
71 |
-
PLOT_WIDTH = 50.0 # Needed for JS injection
|
72 |
-
PLOT_DEPTH = 50.0 # Needed for JS injection
|
73 |
-
WORLD_STATE_FILE_MD_PREFIX = "🌍_" # Prefix for world save files
|
74 |
-
MAX_ACTION_LOG_SIZE = 30 # Max entries in sidebar action log
|
75 |
|
76 |
# File Emojis
|
77 |
FILE_EMOJIS = {"md": "📝", "mp3": "🎵", "png": "🖼️", "mp4": "🎥", "zip": "📦", "json": "📄"}
|
78 |
|
79 |
-
# Primitives Map
|
80 |
PRIMITIVE_MAP = {
|
81 |
"Tree": "🌳", "Rock": "🗿", "Simple House": "🏛️", "Pine Tree": "🌲", "Brick Wall": "🧱",
|
82 |
"Sphere": "🔵", "Cube": "📦", "Cylinder": "🧴", "Cone": "🍦", "Torus": "🍩",
|
83 |
"Mushroom": "🍄", "Cactus": "🌵", "Campfire": "🔥", "Star": "⭐", "Gem": "💎",
|
84 |
"Tower": "🗼", "Barrier": "🚧", "Fountain": "⛲", "Lantern": "🏮", "Sign Post": "팻"
|
85 |
}
|
86 |
-
# Add None option for clearing tool
|
87 |
TOOLS_MAP = {"None": "🚫"}
|
88 |
-
TOOLS_MAP.update({name: emoji for emoji, name in PRIMITIVE_MAP.items()})
|
89 |
|
90 |
# --- Directories ---
|
91 |
for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
|
@@ -94,29 +89,30 @@ for d in [CHAT_DIR, AUDIO_DIR, AUDIO_CACHE_DIR, SAVED_WORLDS_DIR]:
|
|
94 |
# --- API Keys (Placeholder) ---
|
95 |
load_dotenv()
|
96 |
|
97 |
-
# --- Global State & Locks (Removed world_objects global, only client tracking if WS re-added) ---
|
98 |
-
# Lock might still be useful if background tasks modify shared resources (like file lists?) - Keep for now
|
99 |
-
app_lock = threading.Lock()
|
100 |
-
# connected_clients = set() # Removed WS client tracking
|
101 |
-
|
102 |
# ==============================================================================
|
103 |
# Utility Functions
|
104 |
# ==============================================================================
|
|
|
105 |
def get_current_time_str(tz='UTC'):
|
106 |
-
try:
|
107 |
-
|
|
|
|
|
|
|
108 |
return now_aware.strftime('%Y%m%d_%H%M%S')
|
109 |
|
110 |
def clean_filename_part(text, max_len=25):
|
111 |
if not isinstance(text, str): text = "invalid_name"
|
112 |
-
text = re.sub(r'\s+', '_', text)
|
|
|
113 |
return text[:max_len]
|
114 |
|
115 |
def run_async(async_func, *args, **kwargs):
|
116 |
-
"""Runs an async function safely from a sync context
|
117 |
-
try:
|
|
|
|
|
118 |
except RuntimeError:
|
119 |
-
# print(f"Warning: Running async func {async_func.__name__} in new event loop.")
|
120 |
try: return asyncio.run(async_func(*args, **kwargs))
|
121 |
except Exception as e: print(f"Error run_async new loop: {e}"); return None
|
122 |
except Exception as e: print(f"Error run_async schedule task: {e}"); return None
|
@@ -126,35 +122,43 @@ def ensure_dir(dir_path): os.makedirs(dir_path, exist_ok=True)
|
|
126 |
# ==============================================================================
|
127 |
# World State File Handling (Markdown + JSON)
|
128 |
# ==============================================================================
|
|
|
129 |
def generate_world_save_filename(username="User", world_name="World"):
|
130 |
-
|
131 |
-
|
132 |
-
clean_world = clean_filename_part(world_name, 20)
|
133 |
-
rand_hash = hashlib.md5(str(time.time()).encode()+username.encode()+world_name.encode()).hexdigest()[:4]
|
134 |
return f"{WORLD_STATE_FILE_MD_PREFIX}{clean_world}_by_{clean_user}_{timestamp}_{rand_hash}.md"
|
135 |
|
136 |
def parse_world_filename(filename):
|
137 |
-
"""Extracts info from filename (Name, User, Time, Hash)."""
|
138 |
basename = os.path.basename(filename)
|
139 |
if basename.startswith(WORLD_STATE_FILE_MD_PREFIX) and basename.endswith(".md"):
|
140 |
-
core = basename[len(WORLD_STATE_FILE_MD_PREFIX):-3]
|
|
|
141 |
if len(parts) >= 5 and parts[-3] == "by":
|
142 |
-
timestamp_str = parts[-2]
|
143 |
-
|
|
|
|
|
|
|
|
|
144 |
except Exception: dt_obj = None
|
145 |
return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
|
|
|
146 |
# Fallback
|
147 |
-
dt_fallback = None
|
|
|
|
|
|
|
148 |
except Exception: pass
|
|
|
149 |
return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
|
150 |
|
151 |
def save_world_to_md(target_filename_base, world_data_dict):
|
152 |
-
"""Saves the provided world state dictionary to a specific MD file."""
|
153 |
-
# No global state modification, just write the provided dict
|
154 |
save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
|
155 |
print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
|
156 |
success = False
|
157 |
-
parsed_info = parse_world_filename(save_path)
|
158 |
timestamp_save = get_current_time_str()
|
159 |
md_content = f"""# World State: {parsed_info['name']} by {parsed_info['user']}
|
160 |
* **File Saved:** {timestamp_save} (UTC)
|
@@ -165,14 +169,13 @@ def save_world_to_md(target_filename_base, world_data_dict):
|
|
165 |
{json.dumps(world_data_dict, indent=2)}
|
166 |
```"""
|
167 |
try:
|
168 |
-
ensure_dir(SAVED_WORLDS_DIR)
|
169 |
with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
|
170 |
print(f"World state saved successfully to {target_filename_base}"); success = True
|
171 |
except Exception as e: print(f"Error saving world state to {save_path}: {e}")
|
172 |
return success
|
173 |
|
174 |
def load_world_from_md(filename_base):
|
175 |
-
"""Loads world state dict from an MD file (basename), returns dict or None."""
|
176 |
load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
|
177 |
print(f"Loading world state dictionary from MD file: {load_path}...")
|
178 |
if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return None
|
@@ -187,7 +190,6 @@ def load_world_from_md(filename_base):
|
|
187 |
except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return None
|
188 |
|
189 |
def get_saved_worlds():
|
190 |
-
"""Scans the saved worlds directory for world MD files and parses them."""
|
191 |
try:
|
192 |
ensure_dir(SAVED_WORLDS_DIR);
|
193 |
world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
|
@@ -212,24 +214,23 @@ def load_username():
|
|
212 |
return None
|
213 |
|
214 |
def init_session_state():
|
215 |
-
"""Initializes Streamlit session state variables."""
|
216 |
defaults = {
|
217 |
'last_chat_update': 0, 'message_input': "", 'audio_cache': {},
|
218 |
'tts_voice': DEFAULT_TTS_VOICE, 'chat_history': [], 'enable_audio': True,
|
219 |
'download_link_cache': {}, 'username': None, 'autosend': False,
|
220 |
'last_message': "",
|
221 |
-
'selected_object': 'None',
|
222 |
'current_world_file': None, # Track loaded world filename (basename)
|
223 |
'new_world_name': "MyWorld",
|
224 |
'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
|
225 |
-
'world_to_load_data': None, # Temp storage for state loaded from file
|
226 |
'js_object_placed_data': None # Temp storage for data coming from JS place event
|
227 |
}
|
228 |
for k, v in defaults.items():
|
229 |
if k not in st.session_state:
|
230 |
if k == 'action_log': st.session_state[k] = deque(maxlen=MAX_ACTION_LOG_SIZE)
|
231 |
else: st.session_state[k] = v
|
232 |
-
# Ensure complex types initialized correctly
|
233 |
if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
|
234 |
if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
|
235 |
if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
|
@@ -239,59 +240,47 @@ def init_session_state():
|
|
239 |
# Action Log Helper
|
240 |
# ==============================================================================
|
241 |
def add_action_log(message):
|
242 |
-
"""Adds a message to the session's action log."""
|
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 |
-
|
252 |
-
|
253 |
-
"
|
254 |
-
print(f"Python received object placed event data: {type(data)}") # Debug type
|
255 |
-
# Data from streamlit_js_eval might already be a dict if json_data='...' was NOT used
|
256 |
-
# Let's assume data is already a dict/list from JS object sent.
|
257 |
-
# If it comes as a string, we need json.loads()
|
258 |
processed_data = None
|
259 |
if isinstance(data, str):
|
260 |
-
try:
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
return False # Indicate failure
|
265 |
-
elif isinstance(data, dict):
|
266 |
-
processed_data = data # Assume it's already a dict
|
267 |
-
else:
|
268 |
-
print(f"Received unexpected data type from JS place event: {type(data)}")
|
269 |
-
return False
|
270 |
|
271 |
if processed_data and 'obj_id' in processed_data and 'type' in processed_data:
|
272 |
-
|
273 |
-
st.session_state.js_object_placed_data = processed_data
|
274 |
add_action_log(f"Placed {processed_data.get('type', 'object')} ({processed_data.get('obj_id', 'N/A')[:6]}...)")
|
275 |
-
else:
|
276 |
-
|
277 |
-
return False
|
278 |
-
return True # Acknowledge receipt to JS
|
279 |
|
280 |
# ==============================================================================
|
281 |
-
# Audio / TTS / Chat / File Handling Helpers
|
282 |
# ==============================================================================
|
283 |
-
# ---
|
284 |
-
def clean_text_for_tts(text):
|
285 |
if not isinstance(text, str): return "No text"
|
286 |
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
|
287 |
text = ' '.join(text.split()); return text[:250] or "No text"
|
288 |
-
|
|
|
289 |
if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
|
290 |
ensure_dir(os.path.dirname(save_path))
|
291 |
try:
|
292 |
with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
|
293 |
except Exception as e: print(f"Error creating file {save_path}: {e}"); return None
|
294 |
-
|
|
|
295 |
if not file_path or not os.path.exists(file_path): basename = os.path.basename(file_path) if file_path else "N/A"; return f"<small>Not found: {basename}</small>"
|
296 |
try: mtime = os.path.getmtime(file_path)
|
297 |
except OSError: mtime = 0
|
@@ -306,7 +295,9 @@ def get_download_link(file_path, file_type="md"): # ... implementation ...
|
|
306 |
st.session_state.download_link_cache[cache_key] = link_html
|
307 |
except Exception as e: print(f"Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
|
308 |
return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
|
309 |
-
|
|
|
|
|
310 |
if not text: return None
|
311 |
cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
|
312 |
if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
|
@@ -321,21 +312,31 @@ async def async_edge_tts_generate(text, voice, username): # ... implementation .
|
|
321 |
if os.path.exists(save_path) and os.path.getsize(save_path) > 0: st.session_state.audio_cache[cache_key] = save_path; return save_path
|
322 |
else: print(f"Audio file {save_path} failed generation."); return None
|
323 |
except Exception as e: print(f"Edge TTS Error: {e}"); return None
|
324 |
-
|
|
|
325 |
if file_path and os.path.exists(file_path):
|
326 |
-
try:
|
|
|
|
|
|
|
327 |
except Exception as e: st.error(f"Audio display error for {os.path.basename(file_path)}: {e}")
|
328 |
-
|
|
|
|
|
329 |
if not message.strip(): return None, None
|
330 |
-
timestamp_str = get_current_time_str();
|
|
|
331 |
md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
|
332 |
md_file = create_file(entry, username, "md", save_path=md_file_path)
|
333 |
if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
|
334 |
st.session_state.chat_history.append(entry)
|
335 |
audio_file = None;
|
336 |
-
if st.session_state.get('enable_audio', True):
|
|
|
|
|
337 |
return md_file, audio_file
|
338 |
-
|
|
|
339 |
if 'chat_history' not in st.session_state: st.session_state.chat_history = []
|
340 |
if not st.session_state.chat_history:
|
341 |
ensure_dir(CHAT_DIR); print("Loading chat history from files...")
|
@@ -348,7 +349,9 @@ async def load_chat_history(): # ... implementation ...
|
|
348 |
st.session_state.chat_history = temp_history
|
349 |
print(f"Loaded {loaded_count} chat entries from files.")
|
350 |
return st.session_state.chat_history
|
351 |
-
|
|
|
|
|
352 |
if not files_to_zip: st.warning("No files provided to zip."); return None
|
353 |
timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
|
354 |
try:
|
@@ -359,7 +362,8 @@ def create_zip_of_files(files_to_zip, prefix="Archive"): # ... implementation ..
|
|
359 |
else: print(f"Skip zip missing: {f}")
|
360 |
print("Zip success."); st.success(f"Created {zip_name}"); return zip_name
|
361 |
except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
|
362 |
-
|
|
|
363 |
protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
|
364 |
current_world_base = st.session_state.get('current_world_file')
|
365 |
if current_world_base: protected.append(current_world_base)
|
@@ -383,11 +387,14 @@ def delete_files(file_patterns, exclude_files=None): # ... implementation ...
|
|
383 |
elif deleted_count > 0: st.success(msg)
|
384 |
else: st.info("No matching files found to delete.")
|
385 |
st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
|
386 |
-
|
|
|
|
|
387 |
if not image: return None
|
388 |
try: img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]; timestamp = format_timestamp_prefix(username); filename = f"{timestamp}_pasted_{img_hash}.png"; filepath = os.path.join(MEDIA_DIR, filename); image.save(filepath, "PNG"); print(f"Pasted image saved: {filepath}"); return filepath
|
389 |
except Exception as e: print(f"Failed image save: {e}"); return None
|
390 |
-
|
|
|
391 |
pasted_img = None; img_type = None
|
392 |
paste_input_value = st.text_area("Paste Image Data Here", key="paste_input_area", height=50, value=st.session_state.get('paste_image_base64_input', ""))
|
393 |
if st.button("Process Pasted Image 📋", key="process_paste_button"):
|
@@ -406,7 +413,9 @@ def paste_image_component(): # ... implementation ...
|
|
406 |
try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
|
407 |
except Exception: return None
|
408 |
return None
|
409 |
-
|
|
|
|
|
410 |
def __init__(self): self.cache_dir=AUDIO_CACHE_DIR; ensure_dir(self.cache_dir); self.metadata=json.load(open(f"{self.cache_dir}/metadata.json", 'r')) if os.path.exists(f"{self.cache_dir}/metadata.json") else {}
|
411 |
def _save_metadata(self):
|
412 |
try:
|
@@ -423,27 +432,32 @@ class AudioProcessor: # ... implementation ...
|
|
423 |
if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0: self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text_cleaned), 'voice': voice}; self._save_metadata(); return cache_path
|
424 |
else: return None
|
425 |
except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
|
426 |
-
|
427 |
-
|
|
|
428 |
if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
|
429 |
-
audio_processor = AudioProcessor()
|
430 |
try:
|
431 |
reader=PdfReader(pdf_file);
|
432 |
if reader.is_encrypted: st.warning("PDF is encrypted."); return
|
433 |
-
|
434 |
-
st.write(f"Processing first {
|
435 |
texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
|
|
|
436 |
def process_page_sync(page_num, page_text):
|
437 |
async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
|
438 |
-
try:
|
|
|
439 |
if audio_path:
|
440 |
with results_lock: audios[page_num] = audio_path
|
441 |
except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
|
442 |
-
|
|
|
443 |
try: page = reader.pages[i]; text = page.extract_text();
|
444 |
if text and text.strip(): texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
|
445 |
else: texts[i] = "[No text extracted]"
|
446 |
except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
|
|
|
447 |
progress_bar = st.progress(0.0, text="Processing pages...")
|
448 |
total_threads = len(page_threads); start_join_time = time.time()
|
449 |
while any(t.is_alive() for t in page_threads):
|
@@ -452,15 +466,16 @@ def process_pdf_tab(pdf_file, max_pages, voice): # ... implementation ...
|
|
452 |
if time.time() - start_join_time > 600: print("PDF processing timed out."); break
|
453 |
time.sleep(0.5)
|
454 |
progress_bar.progress(1.0, text="Processing complete.")
|
455 |
-
|
|
|
456 |
with st.expander(f"Page {i+1}"):
|
457 |
st.markdown(texts.get(i, "[Error getting text]"))
|
458 |
audio_file = audios.get(i)
|
459 |
if audio_file: play_and_download_audio(audio_file)
|
460 |
else: st.caption("Audio generation failed or was skipped.")
|
461 |
-
except ImportError: st.error("PyPDF2 library needed for PDF processing.")
|
462 |
-
except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
|
463 |
|
|
|
|
|
464 |
|
465 |
# ==============================================================================
|
466 |
# Streamlit UI Layout Functions
|
@@ -474,21 +489,19 @@ def render_sidebar():
|
|
474 |
# --- World Save ---
|
475 |
current_file = st.session_state.get('current_world_file')
|
476 |
current_world_name = "Live State"
|
|
|
477 |
if current_file:
|
478 |
-
|
479 |
-
|
|
|
480 |
|
481 |
-
# Input for Save name (used for both Save and Save As New)
|
482 |
world_save_name = st.text_input(
|
483 |
-
"World Name for Save:",
|
484 |
-
key="world_save_name_input",
|
485 |
-
value=current_world_name if current_file else st.session_state.get('new_world_name', 'MyWorld'),
|
486 |
help="Enter name to save as new, or keep current name to overwrite."
|
487 |
)
|
488 |
|
489 |
-
if st.button("💾 Save Current World View", key="sidebar_save_world"
|
490 |
-
if not world_save_name.strip():
|
491 |
-
st.warning("Please enter a World Name before saving.")
|
492 |
else:
|
493 |
with st.spinner("Requesting world state & saving..."):
|
494 |
js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_sidebar_save", want_result=True)
|
@@ -496,28 +509,17 @@ def render_sidebar():
|
|
496 |
try:
|
497 |
world_data_dict = json.loads(js_world_state_str)
|
498 |
if isinstance(world_data_dict, dict):
|
499 |
-
|
500 |
-
filename_to_save = ""
|
501 |
-
is_overwrite = False
|
502 |
if current_file:
|
503 |
parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
|
504 |
-
|
505 |
-
|
506 |
-
filename_to_save = current_file
|
507 |
-
is_overwrite = True
|
508 |
-
|
509 |
-
if not filename_to_save: # Create new filename if not overwriting
|
510 |
-
filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
|
511 |
|
512 |
if save_world_to_md(filename_to_save, world_data_dict):
|
513 |
action = "Overwritten" if is_overwrite else "Saved new"
|
514 |
-
st.success(f"World {action}: {filename_to_save}")
|
515 |
-
add_action_log(f"Saved world: {filename_to_save}")
|
516 |
st.session_state.current_world_file = filename_to_save # Track saved file
|
517 |
-
st.
|
518 |
-
# Clear the input only if it was a new save? Optional.
|
519 |
-
# st.session_state.world_save_name_input = current_world_name if is_overwrite else "MyWorld"
|
520 |
-
st.rerun() # Refresh sidebar list
|
521 |
else: st.error("Failed to save world state.")
|
522 |
else: st.error("Invalid state format from client.")
|
523 |
except json.JSONDecodeError: st.error("Failed to decode state from client.")
|
@@ -528,52 +530,41 @@ def render_sidebar():
|
|
528 |
st.markdown("---")
|
529 |
st.header("📂 Load World")
|
530 |
saved_worlds = get_saved_worlds()
|
531 |
-
|
532 |
if not saved_worlds: st.caption("No saved worlds found.")
|
533 |
else:
|
534 |
st.caption("Click button to load state.")
|
535 |
-
cols_header = st.columns([3, 1, 1])
|
536 |
with cols_header[0]: st.write("**Name** (User, Time)")
|
537 |
with cols_header[1]: st.write("**Load**")
|
538 |
with cols_header[2]: st.write("**DL**")
|
539 |
display_limit = 15; displayed_count = 0
|
540 |
-
for world_info in saved_worlds:
|
541 |
-
f_basename = os.path.basename(world_info['filename'])
|
542 |
-
f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename)
|
543 |
display_name = world_info.get('name', f_basename); user = world_info.get('user', 'N/A'); timestamp = world_info.get('timestamp', 'N/A')
|
544 |
display_text = f"{display_name} ({user}, {timestamp})"
|
545 |
-
|
546 |
-
# Use expander for items beyond limit
|
547 |
container = st
|
548 |
-
if displayed_count >= display_limit:
|
549 |
-
|
550 |
-
container
|
551 |
-
|
552 |
-
|
553 |
-
|
554 |
-
|
555 |
-
|
556 |
-
|
557 |
-
|
558 |
-
|
559 |
-
|
560 |
-
|
561 |
-
|
562 |
-
|
563 |
-
|
564 |
-
|
565 |
-
|
566 |
-
|
567 |
-
|
568 |
-
add_action_log(f"Loading world: {f_basename}")
|
569 |
-
st.rerun()
|
570 |
-
else: st.error(f"Failed to parse world file: {f_basename}")
|
571 |
-
|
572 |
displayed_count += 1
|
573 |
-
if displayed_count >= display_limit and len(saved_worlds) > display_limit:
|
574 |
-
# Toggle expander state if needed for next items - complex logic, maybe remove expander for simplicity?
|
575 |
-
# For now, just rely on the single expander after the limit is hit.
|
576 |
-
pass
|
577 |
|
578 |
|
579 |
# --- Build Tools ---
|
@@ -583,7 +574,7 @@ def render_sidebar():
|
|
583 |
tool_options = list(TOOLS_MAP.keys())
|
584 |
current_tool_name = st.session_state.get('selected_object', 'None')
|
585 |
try: tool_index = tool_options.index(current_tool_name)
|
586 |
-
except ValueError: tool_index = 0
|
587 |
|
588 |
selected_tool = st.radio(
|
589 |
"Select Tool:", options=tool_options, index=tool_index,
|
@@ -593,9 +584,16 @@ def render_sidebar():
|
|
593 |
if selected_tool != current_tool_name:
|
594 |
st.session_state.selected_object = selected_tool
|
595 |
add_action_log(f"Selected tool: {selected_tool}")
|
596 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
597 |
st.rerun()
|
598 |
|
|
|
599 |
# --- Action Log ---
|
600 |
st.markdown("---")
|
601 |
st.header("📝 Action Log")
|
@@ -605,6 +603,7 @@ def render_sidebar():
|
|
605 |
if log_entries: st.code('\n'.join(log_entries), language="log")
|
606 |
else: st.caption("No actions recorded yet.")
|
607 |
|
|
|
608 |
# --- Voice/User ---
|
609 |
st.markdown("---")
|
610 |
st.header("🗣️ Voice & User")
|
@@ -625,37 +624,41 @@ def render_main_content():
|
|
625 |
"""Renders the main content area with tabs."""
|
626 |
st.title(f"{Site_Name} - User: {st.session_state.username}")
|
627 |
|
628 |
-
# Check if world data needs to be sent to JS
|
629 |
world_data_to_load = st.session_state.pop('world_to_load_data', None)
|
630 |
if world_data_to_load is not None:
|
631 |
print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
|
632 |
-
|
633 |
-
|
634 |
-
|
635 |
-
|
|
|
|
|
636 |
|
637 |
# Set up the mechanism for JS to call Python when an object is placed
|
638 |
streamlit_js_eval(
|
639 |
js_code="""
|
|
|
640 |
if (!window.sendPlacedObjectToPython) {
|
|
|
641 |
window.sendPlacedObjectToPython = (objectData) => {
|
|
|
642 |
// Call Python function 'handle_js_object_placed' via its unique key
|
643 |
-
streamlit_js_eval
|
|
|
644 |
}
|
645 |
}
|
646 |
""",
|
647 |
key="setup_js_place_event_handler" # Key for the setup code itself
|
648 |
)
|
649 |
|
650 |
-
# Check if the Python handler function was triggered
|
651 |
-
# Note: The Python function `handle_js_object_placed` should have been defined globally
|
652 |
-
# and it likely stored data in st.session_state.js_object_placed_data
|
653 |
placed_data = st.session_state.pop('js_object_placed_data', None) # Use pop to consume
|
654 |
if placed_data:
|
655 |
-
print(f"
|
656 |
# Action log already added in handle_js_object_placed.
|
657 |
-
# No
|
658 |
-
pass
|
659 |
|
660 |
|
661 |
# Define Tabs
|
@@ -664,26 +667,39 @@ def render_main_content():
|
|
664 |
# --- World Builder Tab ---
|
665 |
with tab_world:
|
666 |
st.header("Shared 3D World")
|
667 |
-
st.caption("Place objects using sidebar tools. Use Sidebar
|
668 |
current_file_basename = st.session_state.get('current_world_file', None)
|
669 |
if current_file_basename:
|
670 |
full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
|
671 |
if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
|
672 |
-
else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None
|
673 |
-
else: st.info("Live State Active (Save to persist)")
|
674 |
|
675 |
# Embed HTML Component
|
676 |
html_file_path = 'index.html'
|
677 |
try:
|
678 |
with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
|
679 |
-
# Inject state needed by JS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
680 |
js_injection_script = f"""<script>
|
681 |
window.USERNAME = {json.dumps(st.session_state.username)};
|
682 |
window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
|
683 |
window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
|
684 |
window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
|
685 |
-
//
|
686 |
-
|
|
|
687 |
</script>"""
|
688 |
html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
|
689 |
components.html(html_content_with_state, height=700, scrolling=False)
|
@@ -706,9 +722,8 @@ def render_main_content():
|
|
706 |
|
707 |
message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
|
708 |
send_button_clicked = st.button("Send Chat", key="send_chat_button", on_click=clear_chat_input_callback)
|
709 |
-
# Removed autosend for simplicity
|
710 |
|
711 |
-
if send_button_clicked: # Process only on button click
|
712 |
message_to_send = message_value # Value before potential clear by callback
|
713 |
if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
|
714 |
st.session_state.last_message = message_to_send
|
@@ -717,6 +732,7 @@ def render_main_content():
|
|
717 |
run_async(save_chat_entry, st.session_state.username, message_to_send, voice)
|
718 |
# Rerun is handled implicitly by button click + callback
|
719 |
elif send_button_clicked: st.toast("Message empty or same as last.")
|
|
|
720 |
|
721 |
# --- PDF Tab ---
|
722 |
with tab_pdf:
|
@@ -734,6 +750,7 @@ def render_main_content():
|
|
734 |
st.subheader("💾 World Management")
|
735 |
current_file_basename = st.session_state.get('current_world_file', None)
|
736 |
|
|
|
737 |
if current_file_basename:
|
738 |
full_path_for_parse = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
|
739 |
save_label = f"Save Changes to '{current_file_basename}'"
|
@@ -754,6 +771,7 @@ def render_main_content():
|
|
754 |
else: st.warning("Did not receive world state from client.")
|
755 |
else: st.info("Load a world or use 'Save As New Version' below.")
|
756 |
|
|
|
757 |
st.subheader("Save As New Version")
|
758 |
new_name_files = st.text_input("World Name:", key="new_world_name_files_tab", value=st.session_state.get('new_world_name', 'MyWorld'))
|
759 |
if st.button("💾 Save Current View as New Version", key="save_new_version_files"):
|
@@ -776,7 +794,7 @@ def render_main_content():
|
|
776 |
else: st.warning("Did not receive world state from client.")
|
777 |
else: st.warning("Please enter a name.")
|
778 |
|
779 |
-
# Removed Server Status Section
|
780 |
|
781 |
st.subheader("🗑️ Delete Files")
|
782 |
st.warning("Deletion is permanent!", icon="⚠️")
|
@@ -793,7 +811,8 @@ def render_main_content():
|
|
793 |
st.subheader("📦 Download Archives")
|
794 |
col_zip1, col_zip2, col_zip3 = st.columns(3)
|
795 |
with col_zip1:
|
796 |
-
|
|
|
797 |
with col_zip2:
|
798 |
if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
|
799 |
with col_zip3:
|
@@ -803,7 +822,7 @@ def render_main_content():
|
|
803 |
st.caption("Existing Zip Files:")
|
804 |
for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
|
805 |
else:
|
806 |
-
st.caption("No zip archives found.")
|
807 |
|
808 |
|
809 |
# ==============================================================================
|
@@ -811,7 +830,7 @@ def render_main_content():
|
|
811 |
# ==============================================================================
|
812 |
|
813 |
def initialize_app():
|
814 |
-
"""Handles session init and initial world load."""
|
815 |
init_session_state()
|
816 |
# Load username
|
817 |
if not st.session_state.username:
|
@@ -819,22 +838,25 @@ def initialize_app():
|
|
819 |
if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
|
820 |
else: st.session_state.username = random.choice(list(FUN_USERNAMES.keys())) if FUN_USERNAMES else "User"; st.session_state.tts_voice = FUN_USERNAMES.get(st.session_state.username, DEFAULT_TTS_VOICE); save_username(st.session_state.username)
|
821 |
|
822 |
-
#
|
823 |
-
if
|
824 |
-
|
825 |
-
|
826 |
-
|
827 |
-
|
828 |
-
|
829 |
-
|
830 |
-
|
831 |
-
|
832 |
-
|
833 |
-
|
834 |
-
|
|
|
|
|
|
|
835 |
|
836 |
|
837 |
if __name__ == "__main__":
|
838 |
-
initialize_app() # Initialize state, user,
|
839 |
render_sidebar() # Render sidebar UI (includes load buttons)
|
840 |
-
render_main_content() # Render main UI (includes logic to send
|
|
|
1 |
+
# app.py
|
2 |
import streamlit as st
|
3 |
import asyncio
|
|
|
4 |
import uuid
|
5 |
from datetime import datetime
|
6 |
import os
|
|
|
11 |
import base64
|
12 |
import io
|
13 |
import streamlit.components.v1 as components
|
14 |
+
import edge_tts
|
15 |
import nest_asyncio
|
16 |
import re
|
17 |
import pytz
|
18 |
import shutil
|
19 |
+
from PyPDF2 import PdfReader
|
20 |
import threading
|
21 |
import json
|
22 |
import zipfile
|
23 |
from dotenv import load_dotenv
|
24 |
+
# from streamlit_marquee import streamlit_marquee # Can uncomment if marquee is used
|
25 |
+
from collections import defaultdict, Counter, deque
|
26 |
+
from streamlit_js_eval import streamlit_js_eval # Correct import
|
27 |
+
from PIL import Image
|
|
|
28 |
|
29 |
# ==============================================================================
|
30 |
# Configuration & Constants
|
|
|
35 |
|
36 |
# 🎨 Page Config
|
37 |
st.set_page_config(
|
38 |
+
page_title="🏗️ World Action Builder 🏆",
|
39 |
page_icon="🏗️",
|
40 |
layout="wide",
|
41 |
initial_sidebar_state="expanded"
|
42 |
)
|
43 |
|
44 |
# General Constants
|
45 |
+
Site_Name = '🏗️ World Action Builder'
|
46 |
+
MEDIA_DIR = "."
|
47 |
+
STATE_FILE = "user_state.txt"
|
48 |
+
DEFAULT_TTS_VOICE = "en-US-AriaNeural"
|
49 |
+
|
50 |
+
# Directories
|
51 |
+
CHAT_DIR = "chat_logs"
|
52 |
+
AUDIO_CACHE_DIR = "audio_cache"
|
53 |
+
AUDIO_DIR = "audio_logs"
|
54 |
+
SAVED_WORLDS_DIR = "saved_worlds"
|
55 |
+
|
56 |
+
# World Builder Constants
|
57 |
+
PLOT_WIDTH = 50.0
|
58 |
+
PLOT_DEPTH = 50.0
|
59 |
+
WORLD_STATE_FILE_MD_PREFIX = "🌍_"
|
60 |
+
MAX_ACTION_LOG_SIZE = 30
|
61 |
|
62 |
+
# User/Chat Constants (Re-added)
|
63 |
FUN_USERNAMES = {
|
64 |
"BuilderBot 🤖": "en-US-AriaNeural", "WorldWeaver 🕸️": "en-US-JennyNeural",
|
65 |
"Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
|
|
|
67 |
"CosmicCrafter ✨": "en-GB-RyanNeural", "GeoGuru 🗺️": "en-AU-WilliamNeural",
|
68 |
"BlockBard 🧱": "en-CA-LiamNeural", "SoundSculptor 🔊": "en-US-AnaNeural",
|
69 |
}
|
70 |
+
EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
|
72 |
# File Emojis
|
73 |
FILE_EMOJIS = {"md": "📝", "mp3": "🎵", "png": "🖼️", "mp4": "🎥", "zip": "📦", "json": "📄"}
|
74 |
|
75 |
+
# Primitives Map
|
76 |
PRIMITIVE_MAP = {
|
77 |
"Tree": "🌳", "Rock": "🗿", "Simple House": "🏛️", "Pine Tree": "🌲", "Brick Wall": "🧱",
|
78 |
"Sphere": "🔵", "Cube": "📦", "Cylinder": "🧴", "Cone": "🍦", "Torus": "🍩",
|
79 |
"Mushroom": "🍄", "Cactus": "🌵", "Campfire": "🔥", "Star": "⭐", "Gem": "💎",
|
80 |
"Tower": "🗼", "Barrier": "🚧", "Fountain": "⛲", "Lantern": "🏮", "Sign Post": "팻"
|
81 |
}
|
|
|
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]:
|
|
|
89 |
# --- API Keys (Placeholder) ---
|
90 |
load_dotenv()
|
91 |
|
|
|
|
|
|
|
|
|
|
|
92 |
# ==============================================================================
|
93 |
# Utility Functions
|
94 |
# ==============================================================================
|
95 |
+
|
96 |
def get_current_time_str(tz='UTC'):
|
97 |
+
try:
|
98 |
+
timezone = pytz.timezone(tz)
|
99 |
+
now_aware = datetime.now(timezone)
|
100 |
+
except Exception:
|
101 |
+
now_aware = datetime.now(pytz.utc)
|
102 |
return now_aware.strftime('%Y%m%d_%H%M%S')
|
103 |
|
104 |
def clean_filename_part(text, max_len=25):
|
105 |
if not isinstance(text, str): text = "invalid_name"
|
106 |
+
text = re.sub(r'\s+', '_', text)
|
107 |
+
text = re.sub(r'[^\w\-.]', '', text)
|
108 |
return text[:max_len]
|
109 |
|
110 |
def run_async(async_func, *args, **kwargs):
|
111 |
+
"""Runs an async function safely from a sync context."""
|
112 |
+
try:
|
113 |
+
loop = asyncio.get_running_loop()
|
114 |
+
return loop.create_task(async_func(*args, **kwargs))
|
115 |
except RuntimeError:
|
|
|
116 |
try: return asyncio.run(async_func(*args, **kwargs))
|
117 |
except Exception as e: print(f"Error run_async new loop: {e}"); return None
|
118 |
except Exception as e: print(f"Error run_async schedule task: {e}"); return None
|
|
|
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)
|
130 |
+
rand_hash = hashlib.md5(str(time.time()).encode() + username.encode() + world_name.encode()).hexdigest()[:4]
|
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]
|
137 |
+
parts = core.split('_')
|
138 |
if len(parts) >= 5 and parts[-3] == "by":
|
139 |
+
timestamp_str = parts[-2]
|
140 |
+
username = parts[-4]
|
141 |
+
world_name = " ".join(parts[:-4])
|
142 |
+
dt_obj = None
|
143 |
+
try:
|
144 |
+
dt_obj = pytz.utc.localize(datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S'))
|
145 |
except Exception: dt_obj = None
|
146 |
return {"name": world_name or "Untitled", "user": username, "timestamp": timestamp_str, "dt": dt_obj, "filename": filename}
|
147 |
+
|
148 |
# Fallback
|
149 |
+
dt_fallback = None
|
150 |
+
try:
|
151 |
+
mtime = os.path.getmtime(filename)
|
152 |
+
dt_fallback = datetime.fromtimestamp(mtime, tz=pytz.utc)
|
153 |
except Exception: pass
|
154 |
+
# Corrected indentation for this return statement
|
155 |
return {"name": basename.replace('.md','').replace(WORLD_STATE_FILE_MD_PREFIX, ''), "user": "Unknown", "timestamp": "Unknown", "dt": dt_fallback, "filename": filename}
|
156 |
|
157 |
def save_world_to_md(target_filename_base, world_data_dict):
|
|
|
|
|
158 |
save_path = os.path.join(SAVED_WORLDS_DIR, target_filename_base)
|
159 |
print(f"Saving {len(world_data_dict)} objects to MD file: {save_path}...")
|
160 |
success = False
|
161 |
+
parsed_info = parse_world_filename(save_path)
|
162 |
timestamp_save = get_current_time_str()
|
163 |
md_content = f"""# World State: {parsed_info['name']} by {parsed_info['user']}
|
164 |
* **File Saved:** {timestamp_save} (UTC)
|
|
|
169 |
{json.dumps(world_data_dict, indent=2)}
|
170 |
```"""
|
171 |
try:
|
172 |
+
ensure_dir(SAVED_WORLDS_DIR)
|
173 |
with open(save_path, 'w', encoding='utf-8') as f: f.write(md_content)
|
174 |
print(f"World state saved successfully to {target_filename_base}"); success = True
|
175 |
except Exception as e: print(f"Error saving world state to {save_path}: {e}")
|
176 |
return success
|
177 |
|
178 |
def load_world_from_md(filename_base):
|
|
|
179 |
load_path = os.path.join(SAVED_WORLDS_DIR, filename_base)
|
180 |
print(f"Loading world state dictionary from MD file: {load_path}...")
|
181 |
if not os.path.exists(load_path): st.error(f"World file not found: {filename_base}"); return None
|
|
|
190 |
except Exception as e: st.error(f"Error loading world state from {filename_base}: {e}"); st.exception(e); return None
|
191 |
|
192 |
def get_saved_worlds():
|
|
|
193 |
try:
|
194 |
ensure_dir(SAVED_WORLDS_DIR);
|
195 |
world_files = glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md"))
|
|
|
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,
|
220 |
'download_link_cache': {}, 'username': None, 'autosend': False,
|
221 |
'last_message': "",
|
222 |
+
'selected_object': 'None',
|
223 |
'current_world_file': None, # Track loaded world filename (basename)
|
224 |
'new_world_name': "MyWorld",
|
225 |
'action_log': deque(maxlen=MAX_ACTION_LOG_SIZE),
|
226 |
+
'world_to_load_data': None, # Temp storage for state loaded from file
|
227 |
'js_object_placed_data': None # Temp storage for data coming from JS place event
|
228 |
}
|
229 |
for k, v in defaults.items():
|
230 |
if k not in st.session_state:
|
231 |
if k == 'action_log': st.session_state[k] = deque(maxlen=MAX_ACTION_LOG_SIZE)
|
232 |
else: st.session_state[k] = v
|
233 |
+
# Ensure complex types initialized correctly
|
234 |
if not isinstance(st.session_state.chat_history, list): st.session_state.chat_history = []
|
235 |
if not isinstance(st.session_state.audio_cache, dict): st.session_state.audio_cache = {}
|
236 |
if not isinstance(st.session_state.download_link_cache, dict): st.session_state.download_link_cache = {}
|
|
|
240 |
# Action Log Helper
|
241 |
# ==============================================================================
|
242 |
def add_action_log(message):
|
|
|
243 |
if 'action_log' not in st.session_state:
|
244 |
st.session_state.action_log = deque(maxlen=MAX_ACTION_LOG_SIZE)
|
245 |
timestamp = datetime.now().strftime("%H:%M:%S")
|
246 |
+
st.session_state.action_log.appendleft(f"[{timestamp}] {message}")
|
247 |
|
248 |
# ==============================================================================
|
249 |
# JS Communication Handler Function
|
250 |
# ==============================================================================
|
251 |
+
def handle_js_object_placed(data):
|
252 |
+
"""Callback triggered by JS when an object is placed. Stores data in state."""
|
253 |
+
print(f"Python received object placed event data: {type(data)}")
|
|
|
|
|
|
|
|
|
254 |
processed_data = None
|
255 |
if isinstance(data, str):
|
256 |
+
try: processed_data = json.loads(data)
|
257 |
+
except json.JSONDecodeError: print("Failed decode JSON from JS object place event."); return False
|
258 |
+
elif isinstance(data, dict): processed_data = data
|
259 |
+
else: print(f"Received unexpected data type from JS place event: {type(data)}"); return False
|
|
|
|
|
|
|
|
|
|
|
|
|
260 |
|
261 |
if processed_data and 'obj_id' in processed_data and 'type' in processed_data:
|
262 |
+
st.session_state.js_object_placed_data = processed_data # Store for main loop processing
|
|
|
263 |
add_action_log(f"Placed {processed_data.get('type', 'object')} ({processed_data.get('obj_id', 'N/A')[:6]}...)")
|
264 |
+
else: print("Received invalid object placement data structure from JS."); return False
|
265 |
+
return True
|
|
|
|
|
266 |
|
267 |
# ==============================================================================
|
268 |
+
# Audio / TTS / Chat / File Handling Helpers
|
269 |
# ==============================================================================
|
270 |
+
# --- Text & File Helpers ---
|
271 |
+
def clean_text_for_tts(text):
|
272 |
if not isinstance(text, str): return "No text"
|
273 |
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text); text = re.sub(r'[#*_`!]', '', text)
|
274 |
text = ' '.join(text.split()); return text[:250] or "No text"
|
275 |
+
|
276 |
+
def create_file(content, username, file_type="md", save_path=None):
|
277 |
if not save_path: filename = generate_filename(content, username, file_type); save_path = os.path.join(MEDIA_DIR, filename)
|
278 |
ensure_dir(os.path.dirname(save_path))
|
279 |
try:
|
280 |
with open(save_path, 'w', encoding='utf-8') as f: f.write(content); return save_path
|
281 |
except Exception as e: print(f"Error creating file {save_path}: {e}"); return None
|
282 |
+
|
283 |
+
def get_download_link(file_path, file_type="md"):
|
284 |
if not file_path or not os.path.exists(file_path): basename = os.path.basename(file_path) if file_path else "N/A"; return f"<small>Not found: {basename}</small>"
|
285 |
try: mtime = os.path.getmtime(file_path)
|
286 |
except OSError: mtime = 0
|
|
|
295 |
st.session_state.download_link_cache[cache_key] = link_html
|
296 |
except Exception as e: print(f"Error generating DL link for {file_path}: {e}"); return f"<small>Err</small>"
|
297 |
return st.session_state.download_link_cache.get(cache_key, "<small>CacheErr</small>")
|
298 |
+
|
299 |
+
# --- Audio / TTS ---
|
300 |
+
async def async_edge_tts_generate(text, voice, username):
|
301 |
if not text: return None
|
302 |
cache_key = hashlib.md5(f"{text[:150]}_{voice}".encode()).hexdigest();
|
303 |
if 'audio_cache' not in st.session_state: st.session_state.audio_cache = {}
|
|
|
312 |
if os.path.exists(save_path) and os.path.getsize(save_path) > 0: st.session_state.audio_cache[cache_key] = save_path; return save_path
|
313 |
else: print(f"Audio file {save_path} failed generation."); return None
|
314 |
except Exception as e: print(f"Edge TTS Error: {e}"); return None
|
315 |
+
|
316 |
+
def play_and_download_audio(file_path):
|
317 |
if file_path and os.path.exists(file_path):
|
318 |
+
try:
|
319 |
+
st.audio(file_path)
|
320 |
+
file_type = file_path.split('.')[-1]
|
321 |
+
st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
|
322 |
except Exception as e: st.error(f"Audio display error for {os.path.basename(file_path)}: {e}")
|
323 |
+
|
324 |
+
# --- Chat ---
|
325 |
+
async def save_chat_entry(username, message, voice, is_markdown=False):
|
326 |
if not message.strip(): return None, None
|
327 |
+
timestamp_str = get_current_time_str();
|
328 |
+
entry = f"[{timestamp_str}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp_str}] {username} ({voice}):\n```markdown\n{message}\n```"
|
329 |
md_filename_base = generate_filename(message, username, "md"); md_file_path = os.path.join(CHAT_DIR, md_filename_base);
|
330 |
md_file = create_file(entry, username, "md", save_path=md_file_path)
|
331 |
if 'chat_history' not in st.session_state: st.session_state.chat_history = [];
|
332 |
st.session_state.chat_history.append(entry)
|
333 |
audio_file = None;
|
334 |
+
if st.session_state.get('enable_audio', True):
|
335 |
+
tts_message = message
|
336 |
+
audio_file = await async_edge_tts_generate(tts_message, voice, username)
|
337 |
return md_file, audio_file
|
338 |
+
|
339 |
+
async def load_chat_history():
|
340 |
if 'chat_history' not in st.session_state: st.session_state.chat_history = []
|
341 |
if not st.session_state.chat_history:
|
342 |
ensure_dir(CHAT_DIR); print("Loading chat history from files...")
|
|
|
349 |
st.session_state.chat_history = temp_history
|
350 |
print(f"Loaded {loaded_count} chat entries from files.")
|
351 |
return st.session_state.chat_history
|
352 |
+
|
353 |
+
# --- File Management ---
|
354 |
+
def create_zip_of_files(files_to_zip, prefix="Archive"):
|
355 |
if not files_to_zip: st.warning("No files provided to zip."); return None
|
356 |
timestamp = format_timestamp_prefix(f"Zip_{prefix}"); zip_name = f"{prefix}_{timestamp}.zip"
|
357 |
try:
|
|
|
362 |
else: print(f"Skip zip missing: {f}")
|
363 |
print("Zip success."); st.success(f"Created {zip_name}"); return zip_name
|
364 |
except Exception as e: print(f"Zip failed: {e}"); st.error(f"Zip failed: {e}"); return None
|
365 |
+
|
366 |
+
def delete_files(file_patterns, exclude_files=None):
|
367 |
protected = [STATE_FILE, "app.py", "index.html", "requirements.txt", "README.md"]
|
368 |
current_world_base = st.session_state.get('current_world_file')
|
369 |
if current_world_base: protected.append(current_world_base)
|
|
|
387 |
elif deleted_count > 0: st.success(msg)
|
388 |
else: st.info("No matching files found to delete.")
|
389 |
st.session_state['download_link_cache'] = {}; st.session_state['audio_cache'] = {}
|
390 |
+
|
391 |
+
# --- Image Handling ---
|
392 |
+
async def save_pasted_image(image, username):
|
393 |
if not image: return None
|
394 |
try: img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]; timestamp = format_timestamp_prefix(username); filename = f"{timestamp}_pasted_{img_hash}.png"; filepath = os.path.join(MEDIA_DIR, filename); image.save(filepath, "PNG"); print(f"Pasted image saved: {filepath}"); return filepath
|
395 |
except Exception as e: print(f"Failed image save: {e}"); return None
|
396 |
+
|
397 |
+
def paste_image_component():
|
398 |
pasted_img = None; img_type = None
|
399 |
paste_input_value = st.text_area("Paste Image Data Here", key="paste_input_area", height=50, value=st.session_state.get('paste_image_base64_input', ""))
|
400 |
if st.button("Process Pasted Image 📋", key="process_paste_button"):
|
|
|
413 |
try: img_bytes = base64.b64decode(processed_b64); return Image.open(io.BytesIO(img_bytes))
|
414 |
except Exception: return None
|
415 |
return None
|
416 |
+
|
417 |
+
# --- PDF Processing ---
|
418 |
+
class AudioProcessor:
|
419 |
def __init__(self): self.cache_dir=AUDIO_CACHE_DIR; ensure_dir(self.cache_dir); self.metadata=json.load(open(f"{self.cache_dir}/metadata.json", 'r')) if os.path.exists(f"{self.cache_dir}/metadata.json") else {}
|
420 |
def _save_metadata(self):
|
421 |
try:
|
|
|
432 |
if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0: self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text_cleaned), 'voice': voice}; self._save_metadata(); return cache_path
|
433 |
else: return None
|
434 |
except Exception as e: print(f"TTS Create Audio Error: {e}"); return None
|
435 |
+
|
436 |
+
def process_pdf_tab(pdf_file, max_pages, voice):
|
437 |
+
st.subheader("PDF Processing Results")
|
438 |
if pdf_file is None: st.info("Upload a PDF file and click 'Process PDF' to begin."); return
|
439 |
+
audio_processor = AudioProcessor()
|
440 |
try:
|
441 |
reader=PdfReader(pdf_file);
|
442 |
if reader.is_encrypted: st.warning("PDF is encrypted."); return
|
443 |
+
total_pages_in_pdf = len(reader.pages); pages_to_process = min(total_pages_in_pdf, max_pages);
|
444 |
+
st.write(f"Processing first {pages_to_process} of {total_pages_in_pdf} pages from '{pdf_file.name}'...")
|
445 |
texts, audios={}, {}; page_threads = []; results_lock = threading.Lock()
|
446 |
+
|
447 |
def process_page_sync(page_num, page_text):
|
448 |
async def run_async_audio(): return await audio_processor.create_audio(page_text, voice)
|
449 |
+
try:
|
450 |
+
audio_path = asyncio.run(run_async_audio())
|
451 |
if audio_path:
|
452 |
with results_lock: audios[page_num] = audio_path
|
453 |
except Exception as page_e: print(f"Err process page {page_num+1}: {page_e}")
|
454 |
+
|
455 |
+
for i in range(pages_to_process):
|
456 |
try: page = reader.pages[i]; text = page.extract_text();
|
457 |
if text and text.strip(): texts[i]=text; thread = threading.Thread(target=process_page_sync, args=(i, text)); page_threads.append(thread); thread.start()
|
458 |
else: texts[i] = "[No text extracted]"
|
459 |
except Exception as extract_e: texts[i] = f"[Error extract: {extract_e}]"; print(f"Error page {i+1} extract: {extract_e}")
|
460 |
+
|
461 |
progress_bar = st.progress(0.0, text="Processing pages...")
|
462 |
total_threads = len(page_threads); start_join_time = time.time()
|
463 |
while any(t.is_alive() for t in page_threads):
|
|
|
466 |
if time.time() - start_join_time > 600: print("PDF processing timed out."); break
|
467 |
time.sleep(0.5)
|
468 |
progress_bar.progress(1.0, text="Processing complete.")
|
469 |
+
|
470 |
+
for i in range(pages_to_process):
|
471 |
with st.expander(f"Page {i+1}"):
|
472 |
st.markdown(texts.get(i, "[Error getting text]"))
|
473 |
audio_file = audios.get(i)
|
474 |
if audio_file: play_and_download_audio(audio_file)
|
475 |
else: st.caption("Audio generation failed or was skipped.")
|
|
|
|
|
476 |
|
477 |
+
except ImportError: st.error("PyPDF2 library needed.")
|
478 |
+
except Exception as pdf_e: st.error(f"Err read PDF: {pdf_e}"); st.exception(pdf_e)
|
479 |
|
480 |
# ==============================================================================
|
481 |
# Streamlit UI Layout Functions
|
|
|
489 |
# --- World Save ---
|
490 |
current_file = st.session_state.get('current_world_file')
|
491 |
current_world_name = "Live State"
|
492 |
+
default_save_name = "MyWorld"
|
493 |
if current_file:
|
494 |
+
parsed = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
|
495 |
+
current_world_name = parsed.get("name", current_file)
|
496 |
+
default_save_name = current_world_name # Default to overwriting current name
|
497 |
|
|
|
498 |
world_save_name = st.text_input(
|
499 |
+
"World Name for Save:", key="world_save_name_input", value=default_save_name,
|
|
|
|
|
500 |
help="Enter name to save as new, or keep current name to overwrite."
|
501 |
)
|
502 |
|
503 |
+
if st.button("💾 Save Current World View", key="sidebar_save_world"):
|
504 |
+
if not world_save_name.strip(): st.warning("Please enter a World Name.")
|
|
|
505 |
else:
|
506 |
with st.spinner("Requesting world state & saving..."):
|
507 |
js_world_state_str = streamlit_js_eval("getWorldStateForSave();", key="get_world_state_sidebar_save", want_result=True)
|
|
|
509 |
try:
|
510 |
world_data_dict = json.loads(js_world_state_str)
|
511 |
if isinstance(world_data_dict, dict):
|
512 |
+
filename_to_save = ""; is_overwrite = False
|
|
|
|
|
513 |
if current_file:
|
514 |
parsed_current = parse_world_filename(os.path.join(SAVED_WORLDS_DIR, current_file))
|
515 |
+
if world_save_name == parsed_current.get('name', ''): filename_to_save = current_file; is_overwrite = True
|
516 |
+
if not filename_to_save: filename_to_save = generate_world_save_filename(st.session_state.username, world_save_name)
|
|
|
|
|
|
|
|
|
|
|
517 |
|
518 |
if save_world_to_md(filename_to_save, world_data_dict):
|
519 |
action = "Overwritten" if is_overwrite else "Saved new"
|
520 |
+
st.success(f"World {action}: {filename_to_save}"); add_action_log(f"Saved world: {filename_to_save}")
|
|
|
521 |
st.session_state.current_world_file = filename_to_save # Track saved file
|
522 |
+
st.rerun()
|
|
|
|
|
|
|
523 |
else: st.error("Failed to save world state.")
|
524 |
else: st.error("Invalid state format from client.")
|
525 |
except json.JSONDecodeError: st.error("Failed to decode state from client.")
|
|
|
530 |
st.markdown("---")
|
531 |
st.header("📂 Load World")
|
532 |
saved_worlds = get_saved_worlds()
|
|
|
533 |
if not saved_worlds: st.caption("No saved worlds found.")
|
534 |
else:
|
535 |
st.caption("Click button to load state.")
|
536 |
+
cols_header = st.columns([3, 1, 1]);
|
537 |
with cols_header[0]: st.write("**Name** (User, Time)")
|
538 |
with cols_header[1]: st.write("**Load**")
|
539 |
with cols_header[2]: st.write("**DL**")
|
540 |
display_limit = 15; displayed_count = 0
|
541 |
+
for i, world_info in enumerate(saved_worlds):
|
542 |
+
f_basename = os.path.basename(world_info['filename']); f_fullpath = os.path.join(SAVED_WORLDS_DIR, f_basename);
|
|
|
543 |
display_name = world_info.get('name', f_basename); user = world_info.get('user', 'N/A'); timestamp = world_info.get('timestamp', 'N/A')
|
544 |
display_text = f"{display_name} ({user}, {timestamp})"
|
545 |
+
is_last_displayed = i == display_limit -1; show_expander_trigger = len(saved_worlds) > display_limit and is_last_displayed
|
|
|
546 |
container = st
|
547 |
+
if displayed_count >= display_limit: container = st.expander(f"Show {len(saved_worlds)-display_limit} more...")
|
548 |
+
if displayed_count < display_limit or show_expander_trigger :
|
549 |
+
with container:
|
550 |
+
col1, col2, col3 = st.columns([3, 1, 1])
|
551 |
+
with col1: st.write(f"<small>{display_text}</small>", unsafe_allow_html=True)
|
552 |
+
with col2:
|
553 |
+
is_current = (st.session_state.get('current_world_file') == f_basename)
|
554 |
+
btn_load = st.button("Load", key=f"load_{f_basename}", help=f"Load {f_basename}", disabled=is_current)
|
555 |
+
with col3: st.markdown(get_download_link(f_fullpath, "md"), unsafe_allow_html=True)
|
556 |
+
if btn_load:
|
557 |
+
print(f"Load button clicked for: {f_basename}")
|
558 |
+
world_dict = load_world_from_md(f_basename)
|
559 |
+
if world_dict is not None:
|
560 |
+
st.session_state.world_to_load_data = world_dict
|
561 |
+
st.session_state.current_world_file = f_basename
|
562 |
+
add_action_log(f"Loading world: {f_basename}")
|
563 |
+
st.rerun()
|
564 |
+
else: st.error(f"Failed to parse world file: {f_basename}")
|
565 |
+
if show_expander_trigger: # If this was the trigger, stop outer loop
|
566 |
+
break
|
|
|
|
|
|
|
|
|
567 |
displayed_count += 1
|
|
|
|
|
|
|
|
|
568 |
|
569 |
|
570 |
# --- Build Tools ---
|
|
|
574 |
tool_options = list(TOOLS_MAP.keys())
|
575 |
current_tool_name = st.session_state.get('selected_object', 'None')
|
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,
|
|
|
584 |
if selected_tool != current_tool_name:
|
585 |
st.session_state.selected_object = selected_tool
|
586 |
add_action_log(f"Selected tool: {selected_tool}")
|
587 |
+
# Update JS using synchronous call if needed immediately, or rely on injection
|
588 |
+
try:
|
589 |
+
streamlit_js_eval(js_code=f"updateSelectedObjectType({json.dumps(selected_tool)});", key=f"update_tool_js_{selected_tool}")
|
590 |
+
except Exception as e:
|
591 |
+
print(f"JS tool update error: {e}")
|
592 |
+
# Rerun might still be useful to ensure radio button visual updates correctly,
|
593 |
+
# although state change should handle it. Let's keep it for now.
|
594 |
st.rerun()
|
595 |
|
596 |
+
|
597 |
# --- Action Log ---
|
598 |
st.markdown("---")
|
599 |
st.header("📝 Action Log")
|
|
|
603 |
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 |
"""Renders the main content area with tabs."""
|
625 |
st.title(f"{Site_Name} - User: {st.session_state.username}")
|
626 |
|
627 |
+
# Check if world data needs to be sent to JS (loaded via sidebar button)
|
628 |
world_data_to_load = st.session_state.pop('world_to_load_data', None)
|
629 |
if world_data_to_load is not None:
|
630 |
print(f"Sending loaded world state ({len(world_data_to_load)} objects) to JS...")
|
631 |
+
try:
|
632 |
+
# Call JS function loadWorldState, pass data as JSON
|
633 |
+
streamlit_js_eval(js_code=f"loadWorldState({json.dumps(world_data_to_load)});", key="load_world_js")
|
634 |
+
st.toast("World loaded in 3D view.", icon="🔄")
|
635 |
+
except Exception as e:
|
636 |
+
st.error(f"Failed to send loaded world state to JS: {e}")
|
637 |
|
638 |
# Set up the mechanism for JS to call Python when an object is placed
|
639 |
streamlit_js_eval(
|
640 |
js_code="""
|
641 |
+
// Ensure function exists and avoid re-defining listener if possible
|
642 |
if (!window.sendPlacedObjectToPython) {
|
643 |
+
console.log('Defining sendPlacedObjectToPython for JS->Python comms...');
|
644 |
window.sendPlacedObjectToPython = (objectData) => {
|
645 |
+
console.log('JS sending placed object:', objectData);
|
646 |
// Call Python function 'handle_js_object_placed' via its unique key
|
647 |
+
// Pass data directly as object - streamlit_js_eval handles serialization
|
648 |
+
streamlit_js_eval(python_code='handle_js_object_placed(data=' + JSON.stringify(objectData) + ')', key='js_place_event_handler');
|
649 |
}
|
650 |
}
|
651 |
""",
|
652 |
key="setup_js_place_event_handler" # Key for the setup code itself
|
653 |
)
|
654 |
|
655 |
+
# Check if the Python handler function was triggered and stored data
|
|
|
|
|
656 |
placed_data = st.session_state.pop('js_object_placed_data', None) # Use pop to consume
|
657 |
if placed_data:
|
658 |
+
print(f"Python processed stored placed object data: {placed_data.get('obj_id')}")
|
659 |
# Action log already added in handle_js_object_placed.
|
660 |
+
# The object exists client-side. No server-side dict to update in this model.
|
661 |
+
pass # No further action needed in main loop for placement event
|
662 |
|
663 |
|
664 |
# Define Tabs
|
|
|
667 |
# --- World Builder Tab ---
|
668 |
with tab_world:
|
669 |
st.header("Shared 3D World")
|
670 |
+
st.caption("Place objects using sidebar tools. Use Sidebar to Save/Load.")
|
671 |
current_file_basename = st.session_state.get('current_world_file', None)
|
672 |
if current_file_basename:
|
673 |
full_path = os.path.join(SAVED_WORLDS_DIR, current_file_basename)
|
674 |
if os.path.exists(full_path): parsed = parse_world_filename(full_path); st.info(f"Current World: **{parsed['name']}** (`{current_file_basename}`)")
|
675 |
+
else: st.warning(f"Loaded file '{current_file_basename}' missing."); st.session_state.current_world_file = None # Clear if missing
|
676 |
+
else: st.info("Live State Active (Save to persist changes)")
|
677 |
|
678 |
# Embed HTML Component
|
679 |
html_file_path = 'index.html'
|
680 |
try:
|
681 |
with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read()
|
682 |
+
# Inject state needed by JS (Tool, Constants)
|
683 |
+
# The initial world state is loaded via explicit JS call if world_to_load_data exists,
|
684 |
+
# OR on first load if nothing else triggered a load. We need to send *something* initially.
|
685 |
+
initial_world_data = {} # Default to empty
|
686 |
+
if 'world_to_load_data' in st.session_state and st.session_state.world_to_load_data is not None:
|
687 |
+
# This should have been popped and sent via JS call, but as fallback:
|
688 |
+
initial_world_data = st.session_state.world_to_load_data
|
689 |
+
print("Warning: Sending world data via injection as fallback.")
|
690 |
+
elif st.session_state.get('current_world_file'):
|
691 |
+
# If a file is loaded but data wasn't popped, try loading it again for injection
|
692 |
+
loaded_dict = load_world_from_md(st.session_state.current_world_file)
|
693 |
+
if loaded_dict: initial_world_data = loaded_dict
|
694 |
+
|
695 |
js_injection_script = f"""<script>
|
696 |
window.USERNAME = {json.dumps(st.session_state.username)};
|
697 |
window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
|
698 |
window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
|
699 |
window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
|
700 |
+
// Send current state for initial draw IF NOT handled by explicit loadWorldState call
|
701 |
+
window.INITIAL_WORLD_OBJECTS = {json.dumps(initial_world_data)};
|
702 |
+
console.log("Streamlit State Injected:", {{ username: window.USERNAME, selectedObject: window.SELECTED_OBJECT_TYPE, initialObjects: {len(initial_world_data)} }});
|
703 |
</script>"""
|
704 |
html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
|
705 |
components.html(html_content_with_state, height=700, scrolling=False)
|
|
|
722 |
|
723 |
message_value = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
|
724 |
send_button_clicked = st.button("Send Chat", key="send_chat_button", on_click=clear_chat_input_callback)
|
|
|
725 |
|
726 |
+
if send_button_clicked: # Process only on button click
|
727 |
message_to_send = message_value # Value before potential clear by callback
|
728 |
if message_to_send.strip() and message_to_send != st.session_state.get('last_message', ''):
|
729 |
st.session_state.last_message = message_to_send
|
|
|
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 |
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 |
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 |
else: st.warning("Did not receive world state from client.")
|
795 |
else: st.warning("Please enter a name.")
|
796 |
|
797 |
+
# --- Removed Server Status Section ---
|
798 |
|
799 |
st.subheader("🗑️ Delete Files")
|
800 |
st.warning("Deletion is permanent!", icon="⚠️")
|
|
|
811 |
st.subheader("📦 Download Archives")
|
812 |
col_zip1, col_zip2, col_zip3 = st.columns(3)
|
813 |
with col_zip1:
|
814 |
+
# Corrected path for zipping worlds
|
815 |
+
if st.button("Zip Worlds"): create_zip_of_files(glob.glob(os.path.join(SAVED_WORLDS_DIR, f"{WORLD_STATE_FILE_MD_PREFIX}*.md")), "Worlds")
|
816 |
with col_zip2:
|
817 |
if st.button("Zip Chats"): create_zip_of_files(glob.glob(os.path.join(CHAT_DIR, "*.md")), "Chats")
|
818 |
with col_zip3:
|
|
|
822 |
st.caption("Existing Zip Files:")
|
823 |
for zip_file in zip_files: st.markdown(get_download_link(zip_file, "zip"), unsafe_allow_html=True)
|
824 |
else:
|
825 |
+
st.caption("No zip archives found.") # Correct indentation
|
826 |
|
827 |
|
828 |
# ==============================================================================
|
|
|
830 |
# ==============================================================================
|
831 |
|
832 |
def initialize_app():
|
833 |
+
"""Handles session init and initial world load setup."""
|
834 |
init_session_state()
|
835 |
# Load username
|
836 |
if not st.session_state.username:
|
|
|
838 |
if loaded_user and loaded_user in FUN_USERNAMES: st.session_state.username = loaded_user; st.session_state.tts_voice = FUN_USERNAMES[loaded_user]
|
839 |
else: st.session_state.username = random.choice(list(FUN_USERNAMES.keys())) if FUN_USERNAMES else "User"; st.session_state.tts_voice = FUN_USERNAMES.get(st.session_state.username, DEFAULT_TTS_VOICE); save_username(st.session_state.username)
|
840 |
|
841 |
+
# Set up initial world state to load IF this is the first run AND no specific load is already pending
|
842 |
+
if 'world_to_load_data' not in st.session_state or st.session_state.world_to_load_data is None:
|
843 |
+
if st.session_state.get('current_world_file') is None: # Only load initially if no world is 'active'
|
844 |
+
print("Attempting initial load of most recent world...")
|
845 |
+
saved_worlds = get_saved_worlds()
|
846 |
+
if saved_worlds:
|
847 |
+
latest_world_file_basename = os.path.basename(saved_worlds[0]['filename'])
|
848 |
+
print(f"Queueing most recent world for load: {latest_world_file_basename}")
|
849 |
+
world_dict = load_world_from_md(latest_world_file_basename)
|
850 |
+
if world_dict is not None:
|
851 |
+
st.session_state.world_to_load_data = world_dict # Queue data to be sent to JS
|
852 |
+
st.session_state.current_world_file = latest_world_file_basename # Set as current
|
853 |
+
else: print("Failed to load most recent world for initial state.")
|
854 |
+
else:
|
855 |
+
print("No saved worlds found, starting empty.");
|
856 |
+
st.session_state.world_to_load_data = {} # Send empty state to JS initially
|
857 |
|
858 |
|
859 |
if __name__ == "__main__":
|
860 |
+
initialize_app() # Initialize state, user, queue initial world load data
|
861 |
render_sidebar() # Render sidebar UI (includes load buttons)
|
862 |
+
render_main_content() # Render main UI (includes logic to send queued world data to JS)
|