File size: 40,880 Bytes
836475d
41c1a97
836475d
 
 
 
c6b8a39
836475d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6b8a39
836475d
 
 
 
 
c6b8a39
836475d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6b8a39
c910728
 
c6b8a39
836475d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6b8a39
836475d
 
c6b8a39
a9aa314
836475d
9447266
a9aa314
a2141e7
c6b8a39
836475d
 
a9aa314
c6b8a39
 
 
9447266
836475d
 
9447266
c910728
 
 
c6b8a39
 
9447266
 
 
c6b8a39
836475d
9447266
c6b8a39
 
 
a2141e7
9447266
 
836475d
9447266
 
836475d
9447266
836475d
 
 
9447266
836475d
9447266
 
 
 
836475d
 
9447266
 
836475d
9447266
c6b8a39
836475d
 
 
 
 
 
 
c910728
9447266
 
 
836475d
9447266
836475d
 
 
9447266
 
836475d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9447266
836475d
 
 
9447266
836475d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6b8a39
836475d
 
c910728
836475d
 
 
 
 
 
 
 
 
 
 
 
 
7825ef7
836475d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9aa314
a2141e7
836475d
a9aa314
836475d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9aa314
836475d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9aa314
836475d
 
 
 
a9aa314
836475d
7825ef7
836475d
 
 
 
a9aa314
c910728
9447266
836475d
 
 
a9aa314
4fa9b7a
 
836475d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
# app.py (Merged Version)
import streamlit as st
import asyncio
import websockets
import uuid
from datetime import datetime
import os
import random
import time
import hashlib
# from PIL import Image # Keep commented unless needed for image pasting->3D texture?
import glob
import base64
import io
import streamlit.components.v1 as components
import edge_tts
# from audio_recorder_streamlit import audio_recorder # Keep commented unless re-adding audio input
import nest_asyncio
import re
import pytz
import shutil
# import anthropic # Keep commented unless integrating Claude
# import openai # Keep commented unless integrating OpenAI
from PyPDF2 import PdfReader
import threading
import json
import zipfile
# from gradio_client import Client # Keep commented unless integrating ArXiv/Gradio
from dotenv import load_dotenv
from streamlit_marquee import streamlit_marquee
from collections import defaultdict, Counter
import pandas as pd
from streamlit_js_eval import streamlit_js_eval # Still needed for some UI interactions

# ๐Ÿ› ๏ธ Patch asyncio for nesting
nest_asyncio.apply()

# ๐ŸŽจ Page Config (From New App)
st.set_page_config(
    page_title="๐Ÿค–๐Ÿ—๏ธ Shared World Builder ๐Ÿ†",
    page_icon="๐Ÿ—๏ธ",
    layout="wide",
    initial_sidebar_state="expanded" # Keep sidebar open initially
)

# --- Constants (Combined & 3D Added) ---
# Chat/User Constants
icons = '๐Ÿค–๐Ÿ—๏ธ๐Ÿ—ฃ๏ธ' # Updated icons
Site_Name = '๐Ÿค–๐Ÿ—๏ธ Shared World Builder ๐Ÿ—ฃ๏ธ'
START_ROOM = "World Lobby ๐ŸŒ"
FUN_USERNAMES = { # Simplified for clarity, can expand later
    "BuilderBot ๐Ÿค–": "en-US-AriaNeural", "WorldWeaver ๐Ÿ•ธ๏ธ": "en-US-JennyNeural",
    "Terraformer ๐ŸŒฑ": "en-GB-SoniaNeural", "SkyArchitect โ˜๏ธ": "en-AU-NatashaNeural",
    "PixelPainter ๐ŸŽจ": "en-CA-ClaraNeural", "VoxelVortex ๐ŸŒช๏ธ": "en-US-GuyNeural",
    "CosmicCrafter โœจ": "en-GB-RyanNeural", "GeoGuru ๐Ÿ—บ๏ธ": "en-AU-WilliamNeural",
    "BlockBard ๐Ÿงฑ": "en-CA-LiamNeural", "SoundSculptor ๐Ÿ”Š": "en-US-AnaNeural",
}
EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values()))
FILE_EMOJIS = {"md": "๐Ÿ“", "mp3": "๐ŸŽต", "png": "๐Ÿ–ผ๏ธ", "mp4": "๐ŸŽฅ", "zip": "๐Ÿ“ฆ", "csv":"๐Ÿ“„"}

# 3D World Constants
SAVE_DIR = "saved_worlds"
PLOT_WIDTH = 50.0
PLOT_DEPTH = 50.0
CSV_COLUMNS = ['obj_id', 'type', 'pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z', 'rot_order']
WORLD_STATE_FILE = "world_state.json" # Using JSON for simpler in-memory<->disk state

# --- Directories (Combined) ---
for d in ["chat_logs", "audio_logs", "audio_cache", SAVE_DIR]: # Added SAVE_DIR
    os.makedirs(d, exist_ok=True)

CHAT_DIR = "chat_logs"
MEDIA_DIR = "." # Where general files are saved/served from
AUDIO_CACHE_DIR = "audio_cache"
AUDIO_DIR = "audio_logs"
STATE_FILE = "user_state.txt" # For remembering username

CHAT_FILE = os.path.join(CHAT_DIR, "global_chat.md")
# Removed vote files for simplicity

# --- API Keys (Keep placeholder logic) ---
load_dotenv()
# anthropic_key = os.getenv('ANTHROPIC_API_KEY', st.secrets.get('ANTHROPIC_API_KEY', ""))
# openai_api_key = os.getenv('OPENAI_API_KEY', st.secrets.get('OPENAI_API_KEY', ""))
# openai_client = openai.OpenAI(api_key=openai_api_key)

# --- Helper Functions (Combined & Adapted) ---

def format_timestamp_prefix(username=""):
    # Using UTC for consistency in logs/filenames across timezones potentially
    now = datetime.now(pytz.utc)
    # Simplified format
    return f"{now.strftime('%Y%m%d_%H%M%S')}_{username}"

# --- Performance Timer (Optional, Keep if desired) ---
class PerformanceTimer:
    # ... (keep class as is from new app.py if needed) ...
    pass

# --- 3D World State Management (Adapted from original + WebSocket focus) ---

# Global structure to hold the current state of the world IN MEMORY
# Use defaultdict for easier adding
# Needs thread safety if accessed by multiple websocket handlers simultaneously.
# For now, relying on Streamlit's single-thread-per-session execution
# and assuming broadcast updates are okay without strict locking for this scale.
# A lock would be needed for production robustness.
# world_objects_lock = threading.Lock() # Import threading if using lock
world_objects = defaultdict(dict) # Holds {obj_id: object_data}

def load_world_state_from_disk():
    """Loads world state from the JSON file or fallback to CSVs."""
    global world_objects
    loaded_count = 0
    if os.path.exists(WORLD_STATE_FILE):
        try:
            with open(WORLD_STATE_FILE, 'r') as f:
                data = json.load(f)
                # Ensure keys are strings if they got saved as ints somehow
                world_objects = defaultdict(dict, {str(k): v for k, v in data.items()})
                loaded_count = len(world_objects)
                print(f"Loaded {loaded_count} objects from {WORLD_STATE_FILE}")
        except json.JSONDecodeError:
            print(f"Error reading {WORLD_STATE_FILE}. Falling back to CSVs.")
            world_objects = defaultdict(dict) # Reset before loading from CSV
        except Exception as e:
            print(f"Error loading from {WORLD_STATE_FILE}: {e}. Falling back to CSVs.")
            world_objects = defaultdict(dict) # Reset

    # Fallback or initial load from CSVs if JSON fails or doesn't exist
    if not world_objects:
         print("Loading world state from CSV files...")
         # Use the cached CSV loading logic, but populate the global dict
         loaded_from_csv = get_all_world_objects_from_csv() # Gets list
         for obj in loaded_from_csv:
             world_objects[obj['obj_id']] = obj
         loaded_count = len(world_objects)
         print(f"Loaded {loaded_count} objects from CSVs.")
         # Save immediately to JSON for next time
         save_world_state_to_disk()

    return loaded_count

def save_world_state_to_disk():
    """Saves the current in-memory world state to a JSON file."""
    global world_objects
    print(f"Saving {len(world_objects)} objects to {WORLD_STATE_FILE}...")
    try:
        # with world_objects_lock: # Use lock if implementing thread safety
        with open(WORLD_STATE_FILE, 'w') as f:
             # Convert defaultdict back to regular dict for saving
             json.dump(dict(world_objects), f, indent=2)
        print("World state saved successfully.")
        return True
    except Exception as e:
        print(f"Error saving world state to {WORLD_STATE_FILE}: {e}")
        st.error(f"Failed to save world state: {e}")
        return False

# --- Functions to load from CSVs (kept for initial load/fallback) ---
@st.cache_data(ttl=3600)
def load_plot_metadata():
    """Scans save dir for plot_X*_Z*.csv, sorts, calculates metadata."""
    # ... (Keep function as is from original app.py) ...
    print(f"[{time.time():.2f}] Loading plot metadata...")
    plot_files = []
    try:
        plot_files = [f for f in os.listdir(SAVE_DIR) if f.endswith(".csv") and f.startswith("plot_X")]
    except FileNotFoundError: return []
    except Exception as e: return []

    parsed_plots = []
    for filename in plot_files:
        try:
            file_path = os.path.join(SAVE_DIR, filename)
            # Basic check for empty file before parsing name
            if not os.path.exists(file_path) or os.path.getsize(file_path) <= 2: continue

            parts = filename[:-4].split('_')
            grid_x = int(parts[1][1:])
            grid_z = int(parts[2][1:])
            plot_name = " ".join(parts[3:]) if len(parts) > 3 else f"Plot ({grid_x},{grid_z})"
            parsed_plots.append({
                'id': filename[:-4], 'filename': filename,
                'grid_x': grid_x, 'grid_z': grid_z, 'name': plot_name,
                'x_offset': grid_x * PLOT_WIDTH, 'z_offset': grid_z * PLOT_DEPTH
            })
        except Exception as e:
            st.warning(f"Error parsing metadata from filename '{filename}': {e}. Skipping.")
            continue
    parsed_plots.sort(key=lambda p: (p['grid_x'], p['grid_z']))
    return parsed_plots

def load_single_plot_objects_relative(filename):
    """Loads objects from a specific CSV file, keeping coordinates relative."""
    # ... (Keep function as is from previous merged version, including validation) ...
    file_path = os.path.join(SAVE_DIR, filename)
    try:
        if not os.path.exists(file_path) or os.path.getsize(file_path) == 0: return []
        df = pd.read_csv(file_path)
        if df.empty: return []
        if 'obj_id' not in df.columns: df['obj_id'] = [str(uuid.uuid4()) for _ in range(len(df))]
        else: df['obj_id'] = df['obj_id'].fillna(pd.Series([str(uuid.uuid4()) for _ in range(len(df))])).astype(str)
        for col in ['type', 'pos_x', 'pos_y', 'pos_z']:
            if col not in df.columns: return []
        for col, default in [('rot_x', 0.0), ('rot_y', 0.0), ('rot_z', 0.0), ('rot_order', 'XYZ')]:
            if col not in df.columns: df[col] = default
        df.fillna({'rot_x': 0.0, 'rot_y': 0.0, 'rot_z': 0.0, 'rot_order': 'XYZ'}, inplace=True)
        for col in ['pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z']:
             df[col] = pd.to_numeric(df[col], errors='coerce')
        df.dropna(subset=['pos_x', 'pos_y', 'pos_z'], inplace=True)
        df['type'] = df['type'].astype(str)
        return df[CSV_COLUMNS].to_dict('records')
    except Exception as e: return []


@st.cache_data(show_spinner="Loading initial world objects from CSVs...")
def get_all_world_objects_from_csv():
    """Loads ALL objects from ALL known plots into world coordinates FROM CSVs."""
    # ... (Keep function as is from previous merged version) ...
    print(f"[{time.time():.2f}] Reloading ALL world objects from CSV files...")
    all_objects = {}
    plots_meta = load_plot_metadata()
    for plot in plots_meta:
        relative_objects = load_single_plot_objects_relative(plot['filename'])
        for obj in relative_objects:
            obj_id = obj.get('obj_id')
            if not obj_id: continue
            world_obj = {
                'obj_id': obj_id, 'type': obj.get('type', 'Unknown'),
                'position': {'x': obj.get('pos_x', 0.0) + plot['x_offset'], 'y': obj.get('pos_y', 0.0), 'z': obj.get('pos_z', 0.0) + plot['z_offset']},
                'rotation': {'_x': obj.get('rot_x', 0.0), '_y': obj.get('rot_y', 0.0), '_z': obj.get('rot_z', 0.0), '_order': obj.get('rot_order', 'XYZ')}
            }
            all_objects[obj_id] = world_obj
    return list(all_objects.values())

# --- Session State Init (Combined & Expanded) ---
def init_session_state():
    defaults = {
        # From Chat App
        'server_running': False, 'server_task': None, 'active_connections': defaultdict(dict), # Use defaultdict
        'last_chat_update': 0, 'message_text': "", 'audio_cache': {},
        'tts_voice': "en-US-AriaNeural", 'chat_history': [], 'marquee_settings': {
            "background": "#1E1E1E", "color": "#FFFFFF", "font-size": "14px",
            "animationDuration": "20s", "width": "100%", "lineHeight": "35px"
        },
         'enable_audio': True, 'download_link_cache': {}, 'username': None,
         'autosend': True, 'last_message': "", 'timer_start': time.time(),
         'last_sent_transcript': "", 'last_refresh': time.time(),
         'auto_refresh': False, # Default auto-refresh off for world builder?
         'refresh_rate': 30, # Default refresh rate

        # From 3D World App (or adapted)
        'selected_object': 'None', # Current building tool
        # 'world_objects': defaultdict(dict), # In-memory state now global 'world_objects'
        'initial_world_state_loaded': False, # Flag to load state only once

        # Keep others if needed, removed some for clarity
        'operation_timings': {}, 'performance_metrics': defaultdict(list),
    }
    for k, v in defaults.items():
        if k not in st.session_state:
            st.session_state[k] = v
    # Ensure nested dicts are present
    if 'marquee_settings' not in st.session_state: st.session_state.marquee_settings = defaults['marquee_settings']
    if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict)

# --- Marquee Helpers (Keep from New App) ---
def update_marquee_settings_ui(): # ... (keep function as is) ...
    pass # Placeholder if not immediately needed
def display_marquee(text, settings, key_suffix=""): # ... (keep function as is) ...
    pass # Placeholder

# --- Text & File Helpers (Keep & Adapt from New App) ---
def clean_text_for_tts(text): # ... (keep function as is) ...
    return re.sub(r'[#*!\[\]]+', '', ' '.join(text.split()))[:200] or "No text"

def generate_filename(prompt, username, file_type="md", title=None): # ... (keep function as is) ...
     timestamp = format_timestamp_prefix(username)
     # Simplified filename generation
     base = clean_text_for_filename(title if title else prompt[:30])
     hash_val = hashlib.md5(prompt.encode()).hexdigest()[:6]
     return f"{timestamp}_{base}_{hash_val}.{file_type}"

def clean_text_for_filename(text): # ... (keep function as is) ...
    return '_'.join(re.sub(r'[^\w\s-]', '', text.lower()).split())[:50]

def create_file(content, username, file_type="md", title=None): # ... (keep function as is) ...
    filename = generate_filename(content, username, file_type, title)
    # Ensure saving to correct directory based on type? Assume current dir for now
    save_path = filename # os.path.join(MEDIA_DIR, filename)?
    try:
        with open(save_path, 'w', encoding='utf-8') as f:
            f.write(content)
        return save_path
    except Exception as e:
        print(f"Error creating file {save_path}: {e}")
        return None

def get_download_link(file, file_type="mp3"): # ... (keep function as is, ensure FILE_EMOJIS updated) ...
    cache_key = f"dl_{file}_{os.path.getmtime(file) if os.path.exists(file) else 0}"
    if cache_key not in st.session_state['download_link_cache']:
        if not os.path.exists(file): return "File not found"
        with open(file, "rb") as f: b64 = base64.b64encode(f.read()).decode()
        mime_types = {"mp3": "audio/mpeg", "png": "image/png", "mp4": "video/mp4", "md": "text/markdown", "zip": "application/zip", "csv": "text/csv", "json": "application/json"}
        st.session_state['download_link_cache'][cache_key] = f'<a href="data:{mime_types.get(file_type, "application/octet-stream")};base64,{b64}" download="{os.path.basename(file)}">{FILE_EMOJIS.get(file_type, "๐Ÿ“„")} Download {os.path.basename(file)}</a>'
    return st.session_state['download_link_cache'][cache_key]

def save_username(username): # ... (keep function as is) ...
    try:
        with open(STATE_FILE, 'w') as f: f.write(username)
    except Exception as e: print(f"Failed to save username: {e}")

def load_username(): # ... (keep function as is) ...
    if os.path.exists(STATE_FILE):
        try:
            with open(STATE_FILE, 'r') as f: return f.read().strip()
        except Exception as e: print(f"Failed to load username: {e}")
    return None

# --- Audio Processing (Keep from New App) ---
async def async_edge_tts_generate(text, voice, username): # Simplified args
    # ... (keep core logic, maybe save to AUDIO_DIR) ...
    cache_key = f"{text[:100]}_{voice}"
    if cache_key in st.session_state['audio_cache']: return st.session_state['audio_cache'][cache_key]
    text = clean_text_for_tts(text)
    if not text or text == "No text": return None
    filename_base = generate_filename(text, username, "mp3")
    save_path = os.path.join(AUDIO_DIR, filename_base)
    try:
        communicate = edge_tts.Communicate(text, voice)
        await communicate.save(save_path)
        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
        else: return None
    except Exception as e: return None

def play_and_download_audio(file_path): # ... (keep function as is) ...
    if file_path and os.path.exists(file_path):
        st.audio(file_path)
        file_type = file_path.split('.')[-1]
        st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)

# --- Chat Saving/Loading (Keep & Adapt from New App) ---
async def save_chat_entry(username, message, voice, is_markdown=False):
    # ... (keep core logic, save to CHAT_DIR) ...
    if not message.strip(): return None, None
    central = pytz.timezone('US/Central') # Or use UTC
    timestamp = datetime.now(central).strftime("%Y-%m-%d %H:%M:%S")
    entry = f"[{timestamp}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp}] {username} ({voice}):\n```markdown\n{message}\n```"
    md_filename_base = generate_filename(message, username, "md")
    md_file = create_file(entry, username, "md", os.path.join(CHAT_DIR, md_filename_base)) # Save to chat_logs
    # Simplified - don't write to global CHAT_FILE on every message, maybe periodically?
    # Append to session state history for immediate display
    st.session_state.chat_history.append(entry)
    # Generate audio
    audio_file = None
    if st.session_state.get('enable_audio', True): # Check if enabled
        audio_file = await async_edge_tts_generate(message, voice, username)
    return md_file, audio_file

async def load_chat_history():
     # Load from individual files in CHAT_DIR for resilience? Or just session state?
     # For now, rely on session state + initial load from files if needed.
     if not st.session_state.chat_history:
          chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime)
          for f in chat_files:
               try:
                    with open(f, 'r', encoding='utf-8') as file:
                         st.session_state.chat_history.append(file.read().strip())
               except Exception: pass # Ignore read errors
     return st.session_state.chat_history


# --- WebSocket Handling (Adapted for 3D State) ---
# Global set to track connected client IDs for efficient broadcast checks
connected_clients = set()

async def websocket_handler(websocket, path):
    client_id = str(websocket.id) # Use websocket's built-in ID
    connected_clients.add(client_id)
    username = st.session_state.get('username', f"User_{client_id[:4]}")
    print(f"Client connected: {client_id} ({username})")

    # Send initial world state to the new client
    try:
        # with world_objects_lock: # Lock if using threads
        initial_state_msg = json.dumps({
            "type": "initial_state",
            "payload": dict(world_objects) # Send current world state
        })
        await websocket.send(initial_state_msg)
        print(f"Sent initial state ({len(world_objects)} objects) to {client_id}")

        # Announce join (optional)
        await broadcast_message(json.dumps({
             "type": "user_join",
             "payload": {"username": username, "id": client_id}
        }), exclude_id=client_id) # Don't send to self

    except Exception as e:
        print(f"Error sending initial state to {client_id}: {e}")

    # Main message loop
    try:
        async for message in websocket:
            try:
                data = json.loads(message)
                msg_type = data.get("type")
                payload = data.get("payload")
                sender_username = payload.get("username", username) # Get username from payload or default

                if msg_type == "chat_message":
                    print(f"Received chat from {sender_username}: {payload.get('message')}")
                    voice = FUN_USERNAMES.get(sender_username, "en-US-AriaNeural")
                    # Save chat locally (optional async call)
                    asyncio.create_task(save_chat_entry(sender_username, payload.get('message', ''), voice))
                    # Broadcast chat to others
                    await broadcast_message(message, exclude_id=client_id)

                elif msg_type == "place_object":
                    obj_data = payload.get("object_data")
                    if obj_data and 'obj_id' in obj_data:
                        print(f"Received place_object from {sender_username}: {obj_data.get('type')} ({obj_data['obj_id']})")
                        # with world_objects_lock: # Lock if needed
                        world_objects[obj_data['obj_id']] = obj_data # Add/update in memory
                        # Broadcast placement to others
                        broadcast_payload = json.dumps({
                             "type": "object_placed",
                             "payload": {"object_data": obj_data, "username": sender_username}
                        })
                        await broadcast_message(broadcast_payload, exclude_id=client_id)
                        # Maybe trigger periodic save here? Or rely on manual save.
                    else:
                         print(f"Invalid place_object payload from {client_id}")

                elif msg_type == "delete_object":
                     obj_id = payload.get("obj_id")
                     if obj_id:
                          print(f"Received delete_object from {sender_username}: {obj_id}")
                          # with world_objects_lock: # Lock if needed
                          if obj_id in world_objects:
                              del world_objects[obj_id]
                              # Broadcast deletion
                              broadcast_payload = json.dumps({
                                   "type": "object_deleted",
                                   "payload": {"obj_id": obj_id, "username": sender_username}
                              })
                              await broadcast_message(broadcast_payload, exclude_id=client_id)

                # Add handlers for other types (player_move, request_save, etc.)

            except json.JSONDecodeError:
                print(f"Received invalid JSON from {client_id}: {message}")
            except Exception as e:
                print(f"Error processing message from {client_id}: {e}")

    except websockets.ConnectionClosedOK:
        print(f"Client disconnected normally: {client_id} ({username})")
    except websockets.ConnectionClosedError as e:
        print(f"Client connection closed with error: {client_id} ({username}) - {e}")
    finally:
        connected_clients.discard(client_id)
        # Announce leave (optional)
        await broadcast_message(json.dumps({
             "type": "user_leave",
             "payload": {"username": username, "id": client_id}
        }))
        print(f"Client disconnected: {client_id} ({username}). Remaining: {len(connected_clients)}")


# Modified broadcast to use the global set and skip sender
async def broadcast_message(message, exclude_id=None):
    tasks = []
    disconnected_clients_this_call = set()

    # Iterate over a copy of the client set in case it changes during iteration
    current_client_ids = list(connected_clients)

    for client_id in current_client_ids:
        if client_id == exclude_id:
            continue

        websocket = st.session_state.active_connections.get(client_id) # Get WS object
        if websocket:
             try:
                  # Create task for sending; allows concurrent sends
                  tasks.append(asyncio.create_task(websocket.send(message)))
             except websockets.ConnectionClosed:
                  print(f"Found disconnected client during broadcast prep: {client_id}")
                  disconnected_clients_this_call.add(client_id)
             except RuntimeError as e: # Handles 'Event loop is closed' during shutdown
                  print(f"RuntimeError during broadcast prep for {client_id}: {e}")
                  disconnected_clients_this_call.add(client_id)
             except Exception as e:
                  print(f"Unexpected error during broadcast prep for {client_id}: {e}")
                  disconnected_clients_this_call.add(client_id)
        else:
            # Websocket object not found in session state, likely already disconnected
            disconnected_clients_this_call.add(client_id)

    # Wait for all send tasks to complete
    if tasks:
        await asyncio.gather(*tasks, return_exceptions=True) # Handle exceptions during send

    # Clean up disconnected clients found during this specific broadcast attempt
    if disconnected_clients_this_call:
         print(f"Cleaning up {len(disconnected_clients_this_call)} disconnected clients after broadcast.")
         for client_id in disconnected_clients_this_call:
              connected_clients.discard(client_id)
              st.session_state.active_connections.pop(client_id, None)


async def run_websocket_server():
    # Check if already running - basic flag protection
    if st.session_state.get('server_running_flag', False):
        print("Server already seems to be running or starting.")
        return
    st.session_state['server_running_flag'] = True
    print("Starting WebSocket server on 0.0.0.0:8765...")
    try:
        # Use localhost for testing, 0.0.0.0 for broader access (requires firewall config)
        server = await websockets.serve(websocket_handler, 'localhost', 8765)
        print("WebSocket server started successfully.")
        st.session_state['server_instance'] = server # Store server instance if needed for graceful shutdown
        await server.wait_closed() # Keep server running
    except OSError as e:
         print(f"### FAILED TO START WEBSOCKET SERVER: {e}")
         st.error(f"Failed to start WebSocket server on port 8765: {e}. Port might be in use.")
         # Try to connect as client if server fails? Or just stop.
    except Exception as e:
         print(f"### UNEXPECTED ERROR IN WEBSOCKET SERVER: {e}")
         st.error(f"An unexpected error occurred: {e}")
    finally:
         print("WebSocket server task finished.")
         st.session_state['server_running_flag'] = False
         st.session_state['server_instance'] = None


def start_websocket_server_thread():
    """Starts the WebSocket server in a separate thread."""
    # Check if thread already running
    if st.session_state.get('server_task') and st.session_state.server_task.is_alive():
        print("Server thread already running.")
        return
    print("Creating and starting new server thread.")
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    st.session_state.server_task = threading.Thread(target=loop.run_until_complete, args=(run_websocket_server(),), daemon=True)
    st.session_state.server_task.start()

# --- PDF to Audio (Keep if desired, maybe in a separate tab?) ---
class AudioProcessor: # ... (keep class as is) ...
      def __init__(self): self.cache_dir=AUDIO_CACHE_DIR; os.makedirs(self.cache_dir,exist_ok=True); self.metadata=json.load(open(f"{self.cache_dir}/metadata.json")) if os.path.exists(f"{self.cache_dir}/metadata.json") else {}
      def _save_metadata(self): #... (save logic) ...
            with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f)
      async def create_audio(self, text, voice='en-US-AriaNeural'): # ... (audio creation logic) ...
            cache_key=hashlib.md5(f"{text}:{voice}".encode()).hexdigest(); cache_path=f"{self.cache_dir}/{cache_key}.mp3"
            if cache_key in self.metadata and os.path.exists(cache_path): return cache_path
            text=clean_text_for_tts(text); communicate=edge_tts.Communicate(text,voice); await communicate.save(cache_path)
            self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text), 'voice': voice}; self._save_metadata()
            return cache_path

def process_pdf(pdf_file, max_pages, voice, audio_processor): # ... (keep function as is) ...
      reader=PdfReader(pdf_file); total_pages=min(len(reader.pages),max_pages); texts,audios={}, {}
      async def process_page(i,text): audio_path=await audio_processor.create_audio(text,voice); audios[i]=audio_path
      for i in range(total_pages): text=reader.pages[i].extract_text(); texts[i]=text; threading.Thread(target=lambda: asyncio.run(process_page(i,text))).start()
      return texts,audios,total_pages

# --- ArXiv/AI Lookup (Commented out for focus) ---
# def parse_arxiv_refs(...): pass
# def generate_5min_feature_markdown(...): pass
# async def create_paper_audio_files(...): pass
# async def perform_ai_lookup(...): pass
# async def perform_claude_search(...): pass
# async def perform_arxiv_search(...): pass

# --- Voting (Removed for focus) ---
# def save_vote(...): pass
# def load_votes(...): pass

# --- Image Handling (Keep basic save, comment out Claude processing) ---
async def save_pasted_image(image, username): # Simplified
     img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]
     # Add check against existing hashes if needed: if img_hash in st.session_state.image_hashes: return None
     timestamp = format_timestamp_prefix(username)
     filename = f"{timestamp}_pasted_{img_hash}.png"
     filepath = os.path.join(MEDIA_DIR, filename) # Save in base dir
     try: image.save(filepath, "PNG"); return filepath
     except Exception as e: print(f"Failed image save: {e}"); return None

# --- Zip and Delete Files (Keep from New App) ---
def create_zip_of_files(files, prefix="Archive", query=""): # Simplified args
    if not files: return None
    timestamp = format_timestamp_prefix() # Generic timestamp
    zip_name = f"{prefix}_{timestamp}.zip"
    try:
        with zipfile.ZipFile(zip_name, 'w') as z:
            for f in files:
                 if os.path.exists(f): z.write(f, os.path.basename(f)) # Use basename in archive
        return zip_name
    except Exception as e: print(f"Zip creation failed: {e}"); return None

def delete_files(file_patterns, exclude_files=["README.md", STATE_FILE, WORLD_STATE_FILE]): # Takes list of patterns
    deleted_count = 0
    for pattern in file_patterns:
         # Be careful with glob patterns!
         files_to_delete = glob.glob(pattern)
         for f in files_to_delete:
             basename = os.path.basename(f)
             if basename not in exclude_files:
                  try: os.remove(f); deleted_count += 1
                  except Exception as e: print(f"Failed delete {f}: {e}")
    print(f"Deleted {deleted_count} files.")
    # Clear relevant caches?
    st.session_state['download_link_cache'] = {}


# --- Custom Paste Component (Keep from New App) ---
def paste_image_component(): # ... (Keep function as is) ...
      # Returns Image object, type string
      with st.form(key="paste_form"):
           paste_input = st.text_area("Paste Image Data Here (Ctrl+V)", key="paste_input_area", height=50)
           submit_button = st.form_submit_button("Paste Image ๐Ÿ“‹")
           if submit_button and paste_input and paste_input.startswith('data:image'):
               try:
                   mime_type = paste_input.split(';')[0].split(':')[1]; base64_str = paste_input.split(',')[1]
                   img_bytes = base64.b64decode(base64_str); img = Image.open(io.BytesIO(img_bytes))
                   st.image(img, caption=f"Pasted Image ({mime_type.split('/')[1].upper()})", width=150) # Smaller preview
                   return img, mime_type.split('/')[1]
               except Exception as e: st.error(f"Image decode error: {e}")
      return None, None


# --- Mapping Emojis to Primitive Types ---
# Ensure these types match the create[PrimitiveName] functions in index.html
PRIMITIVE_MAP = {
    "๐ŸŒณ": "Tree", "๐Ÿ—ฟ": "Rock", "๐Ÿ›๏ธ": "Simple House", "๐ŸŒฒ": "Pine Tree", "๐Ÿงฑ": "Brick Wall",
    "๐Ÿ”ต": "Sphere", "๐Ÿ“ฆ": "Cube", " cylinder ": "Cylinder", "๐Ÿฆ": "Cone", "๐Ÿฉ": "Torus",
    "๐Ÿ„": "Mushroom", "๐ŸŒต": "Cactus", "๐Ÿ”ฅ": "Campfire", "โญ": "Star", "๐Ÿ’Ž": "Gem",
    "๐Ÿ—ผ": "Tower", "๐Ÿšง": "Barrier", "โ›ฒ": "Fountain", "๐Ÿฎ": "Lantern", "ํ‘ฏ": "Sign Post"
    # Add more pairs up to ~20
}

# --- Main Streamlit Interface ---
def main_interface():
    init_session_state()

    # --- Load initial world state ONCE per session ---
    if not st.session_state.initial_world_state_loaded:
        load_world_state_from_disk()
        st.session_state.initial_world_state_loaded = True

    # --- Username Setup ---
    saved_username = load_username()
    if saved_username and saved_username in FUN_USERNAMES:
        st.session_state.username = saved_username
    if not st.session_state.username:
        st.session_state.username = random.choice(list(FUN_USERNAMES.keys()))
        st.session_state.tts_voice = FUN_USERNAMES[st.session_state.username]
        save_username(st.session_state.username)
        # Don't automatically announce join here, let WebSocket handler do it on connect

    st.title(f"{Site_Name} - User: {st.session_state.username}")

    # --- Main Content Area ---
    tab_world, tab_chat, tab_files = st.tabs(["๐Ÿ—๏ธ World Builder", "๐Ÿ—ฃ๏ธ Chat", "๐Ÿ“‚ Files & Settings"])

    with tab_world:
        st.header("Shared 3D World")
        st.caption("Place objects using the sidebar tools. Changes are shared live!")

        # --- Embed HTML Component for Three.js ---
        html_file_path = 'index.html'
        try:
            with open(html_file_path, 'r', encoding='utf-8') as f:
                html_template = f.read()

            # Inject necessary data for JS: Username, WebSocket URL, initial state?
            # Initial state now sent via WebSocket, maybe don't inject here?
            # Let's inject username and WS url.
            ws_url = "ws://localhost:8765" # Use localhost for local dev

            js_injection_script = f"""
<script>
    window.USERNAME = {json.dumps(st.session_state.username)};
    window.WEBSOCKET_URL = {json.dumps(ws_url)};
    window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)}; // Send current tool
    window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)}; // Send constants needed by JS
    window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};

    console.log("Streamlit State Injected:", {{
        username: window.USERNAME,
        websocketUrl: window.WEBSOCKET_URL,
        selectedObject: window.SELECTED_OBJECT_TYPE
    }});
</script>
"""
            html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)

            components.html(html_content_with_state, height=700, scrolling=False)

        except FileNotFoundError:
            st.error(f"CRITICAL ERROR: Could not find '{html_file_path}'.")
        except Exception as e:
            st.error(f"Error loading 3D component: {e}")

    with tab_chat:
        st.header(f"{START_ROOM} Chat")
        chat_history = asyncio.run(load_chat_history()) # Load history
        chat_container = st.container(height=500) # Scrollable chat area
        with chat_container:
             # Display chat history (most recent at bottom)
             for entry in reversed(chat_history[-50:]): # Show last 50 messages
                  st.markdown(entry) # Use markdown to render potential code blocks

        # Chat Input Area
        message = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
        if st.button("Send Chat ๐Ÿ’ฌ", key="send_chat_button") or (st.session_state.autosend and message):
             if message.strip() and message != st.session_state.last_message:
                 st.session_state.last_message = message
                 voice = FUN_USERNAMES.get(st.session_state.username, "en-US-AriaNeural")
                 # Send via WebSocket
                 ws_message = json.dumps({
                      "type": "chat_message",
                      "payload": {"username": st.session_state.username, "message": message, "voice": voice}
                 })
                 # Run broadcast in asyncio task to avoid blocking Streamlit
                 asyncio.run(broadcast_message(ws_message))
                 # Save locally (optional now, as broadcast handles real-time)
                 asyncio.run(save_chat_entry(st.session_state.username, message, voice))
                 # Clear input - needs rerun or JS callback
                 st.session_state.message_input = ""
                 st.rerun() # Force rerun to clear input and update display

    with tab_files:
        st.header("File Management & Settings")
        # Add options from the new app's sidebar here if desired
        # e.g., Zipping, Deleting, Marquee settings

        st.subheader("Server & World State")
        col_ws, col_save = st.columns(2)
        with col_ws:
             ws_status = "Running" if st.session_state.get('server_running_flag', False) else "Stopped"
             st.metric("WebSocket Server", ws_status)
             st.metric("Connected Clients", len(connected_clients))
        with col_save:
             if st.button("๐Ÿ’พ Save World State to Disk", key="save_world_disk"):
                  if save_world_state_to_disk():
                       st.success("World state saved!")
                  else:
                       st.error("Failed to save world state.")

        # Add file deletion buttons if needed
        st.subheader("Delete Files")
        col_del1, col_del2, col_del3 = st.columns(3)
        with col_del1:
             if st.button("๐Ÿ—‘๏ธ Delete Chats (.md)", key="del_chat_md"):
                  delete_files([os.path.join(CHAT_DIR, "*.md")])
                  st.session_state.chat_history = [] # Clear session history too
                  st.rerun()
        with col_del2:
            if st.button("๐Ÿ—‘๏ธ Delete Audio (.mp3)", 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()
        # Add more deletion options as needed


    # --- Sidebar Controls ---
    with st.sidebar:
        st.header("๐Ÿ—๏ธ Build Tools")
        st.caption("Select an object to place.")

        # --- Emoji Buttons for Primitives ---
        cols = st.columns(5) # Adjust grid width as needed
        col_idx = 0
        for emoji, name in PRIMITIVE_MAP.items():
            # Use button click to set selected_object
            button_key = f"primitive_{name}"
            # Highlight selected button? Could use custom CSS or just rely on state.
            button_type = "primary" if st.session_state.selected_object == name else "secondary"
            if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type):
                st.session_state.selected_object = name
                # Update JS selection without full rerun if possible
                try:
                    js_update_selection = f"updateSelectedObjectType({json.dumps(name)});"
                    streamlit_js_eval(js_code=js_update_selection, key="update_tool_js")
                except Exception as e:
                    print(f"Could not push tool update to JS: {e}")
                # Force a rerun to update button styles immediately
                st.rerun()
            col_idx += 1

        # Button to clear selection
        if st.button("๐Ÿšซ Clear Tool", key="clear_tool"):
             if st.session_state.selected_object != 'None':
                  st.session_state.selected_object = 'None'
                  try: # Update JS too
                      streamlit_js_eval(js_code=f"updateSelectedObjectType('None');", key="update_tool_js_none")
                  except Exception: pass
                  st.rerun() # Rerun to update UI

        st.markdown("---")
        st.header("๐Ÿ—ฃ๏ธ Voice & User")
        # Username/Voice Selection
        new_username = st.selectbox("Change Name/Voice", list(FUN_USERNAMES.keys()), index=list(FUN_USERNAMES.keys()).index(st.session_state.username), key="username_select")
        if new_username != st.session_state.username:
            # Announce name change via WebSocket?
            change_msg = json.dumps({
                 "type":"user_rename",
                 "payload": {"old_username": st.session_state.username, "new_username": new_username}
            })
            asyncio.run(broadcast_message(change_msg))
            st.session_state.username = new_username
            st.session_state.tts_voice = FUN_USERNAMES[new_username]
            save_username(st.session_state.username)
            st.rerun()

        # Enable/Disable Audio Toggle
        st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))

        st.markdown("---")
        st.info("Status and file management in 'Files & Settings' tab.")


# --- Main Execution ---
if __name__ == "__main__":
    init_session_state()
    # Start WebSocket server in a thread IF it's not already running
    if not st.session_state.get('server_task') or not st.session_state.server_task.is_alive():
         start_websocket_server_thread()
         time.sleep(1) # Give server a moment to start

    main_interface()