awacke1 commited on
Commit
836475d
·
verified ·
1 Parent(s): 26cbb1c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +762 -364
app.py CHANGED
@@ -1,49 +1,180 @@
1
- # app.py
2
  import streamlit as st
3
- import streamlit.components.v1 as components
 
 
 
4
  import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import json
 
 
 
 
 
6
  import pandas as pd
7
- import uuid
8
- import math
9
- from streamlit_js_eval import streamlit_js_eval
10
- import time # For debugging or potential delays
11
-
12
- # --- Constants ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  SAVE_DIR = "saved_worlds"
14
  PLOT_WIDTH = 50.0
15
  PLOT_DEPTH = 50.0
16
  CSV_COLUMNS = ['obj_id', 'type', 'pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z', 'rot_order']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- # --- Ensure Save Directory Exists ---
19
- os.makedirs(SAVE_DIR, exist_ok=True)
20
-
21
- # --- Helper Functions ---
22
-
23
- # --- Caching Plot Metadata ---
24
- @st.cache_data(ttl=3600) # Cache plot list for an hour
25
  def load_plot_metadata():
26
  """Scans save dir for plot_X*_Z*.csv, sorts, calculates metadata."""
 
27
  print(f"[{time.time():.2f}] Loading plot metadata...")
28
  plot_files = []
29
  try:
30
  plot_files = [f for f in os.listdir(SAVE_DIR) if f.endswith(".csv") and f.startswith("plot_X")]
31
- except FileNotFoundError:
32
- st.error(f"Save directory '{SAVE_DIR}' not found.")
33
- return []
34
- except Exception as e:
35
- st.error(f"Error listing save directory '{SAVE_DIR}': {e}")
36
- return []
37
 
38
  parsed_plots = []
39
  for filename in plot_files:
40
  try:
41
- # Check if file is empty or just header before parsing filename
42
  file_path = os.path.join(SAVE_DIR, filename)
43
- if os.path.getsize(file_path) <= len(",".join(CSV_COLUMNS)) + 2: # Check if smaller/equal to header size + newline
44
- print(f"Skipping empty or header-only file: {filename}")
45
- # Optionally delete empty files? os.remove(file_path)
46
- continue
47
 
48
  parts = filename[:-4].split('_')
49
  grid_x = int(parts[1][1:])
@@ -54,385 +185,652 @@ def load_plot_metadata():
54
  'grid_x': grid_x, 'grid_z': grid_z, 'name': plot_name,
55
  'x_offset': grid_x * PLOT_WIDTH, 'z_offset': grid_z * PLOT_DEPTH
56
  })
57
- except FileNotFoundError:
58
- # Should not happen if listed above, but safety check
59
- st.warning(f"File {filename} not found during metadata parsing.")
60
- continue
61
- except (IndexError, ValueError, OSError) as e:
62
  st.warning(f"Error parsing metadata from filename '{filename}': {e}. Skipping.")
63
  continue
64
-
65
  parsed_plots.sort(key=lambda p: (p['grid_x'], p['grid_z']))
66
- print(f"[{time.time():.2f}] Found {len(parsed_plots)} valid plots.")
67
  return parsed_plots
68
 
69
- # --- Loading Objects (No Cache on individual plots, handled by get_all_world_objects) ---
70
  def load_single_plot_objects_relative(filename):
71
  """Loads objects from a specific CSV file, keeping coordinates relative."""
 
72
  file_path = os.path.join(SAVE_DIR, filename)
73
  try:
74
- # Check for empty file BEFORE reading CSV to avoid pandas error
75
- if not os.path.exists(file_path) or os.path.getsize(file_path) == 0:
76
- # print(f"Plot file is empty or missing (normal): {filename}")
77
- return [] # Return empty list for non-existent or empty files
78
-
79
  df = pd.read_csv(file_path)
80
- if df.empty:
81
- # print(f"Plot file read but DataFrame is empty: {filename}")
82
- return []
83
-
84
- # --- Data Cleaning & Defaulting ---
85
- # Ensure obj_id exists and is unique enough for merging
86
- if 'obj_id' not in df.columns:
87
- df['obj_id'] = [str(uuid.uuid4()) for _ in range(len(df))]
88
- else:
89
- # Fill potential NaNs in obj_id and ensure string type
90
- df['obj_id'] = df['obj_id'].fillna(pd.Series([str(uuid.uuid4()) for _ in range(len(df))])).astype(str)
91
-
92
- # Ensure essential columns exist
93
  for col in ['type', 'pos_x', 'pos_y', 'pos_z']:
94
- if col not in df.columns:
95
- st.warning(f"CSV '{filename}' missing essential column '{col}'. Skipping file.")
96
- return [] # Skip file if essential geometry is missing
97
-
98
- # Add defaults for optional columns if they don't exist
99
  for col, default in [('rot_x', 0.0), ('rot_y', 0.0), ('rot_z', 0.0), ('rot_order', 'XYZ')]:
100
  if col not in df.columns: df[col] = default
101
- # Fill NaNs in optional columns with defaults
102
  df.fillna({'rot_x': 0.0, 'rot_y': 0.0, 'rot_z': 0.0, 'rot_order': 'XYZ'}, inplace=True)
103
-
104
- # Basic type validation (optional but good)
105
  for col in ['pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z']:
106
- df[col] = pd.to_numeric(df[col], errors='coerce') # Convert to number, turn errors into NaN
107
- df.dropna(subset=['pos_x', 'pos_y', 'pos_z'], inplace=True) # Drop rows where position failed conversion
108
-
109
- # Ensure 'type' is string
110
  df['type'] = df['type'].astype(str)
111
-
112
- # Convert to list of dicts using the final columns
113
  return df[CSV_COLUMNS].to_dict('records')
114
-
115
- except pd.errors.EmptyDataError:
116
- # print(f"Plot file is empty (caught by pandas): {filename}") # Normal case
117
- return []
118
- except FileNotFoundError:
119
- # This case should be handled by the os.path check above, but good fallback.
120
- # print(f"Plot file not found: {filename}")
121
- return []
122
- except Exception as e:
123
- st.error(f"Error loading objects from {filename}: {e}")
124
- st.exception(e) # Show full traceback in logs/console
125
- return []
126
 
127
 
128
- # --- Cache the combined world state ---
129
- @st.cache_data(show_spinner="Loading world objects...")
130
- def get_all_world_objects():
131
- """Loads ALL objects from ALL known plots into world coordinates."""
132
- print(f"[{time.time():.2f}] Reloading ALL world objects from files...")
133
- all_objects = {} # Use dict keyed by obj_id for auto-deduplication during load
134
- plots_meta = load_plot_metadata() # Get the list of valid plots
135
-
136
  for plot in plots_meta:
137
  relative_objects = load_single_plot_objects_relative(plot['filename'])
138
  for obj in relative_objects:
139
  obj_id = obj.get('obj_id')
140
- if not obj_id: continue # Should have ID from load_single_plot now
141
-
142
- # Convert to world coordinates
143
  world_obj = {
144
- 'obj_id': obj_id,
145
- 'type': obj.get('type', 'Unknown'),
146
- 'position': {
147
- 'x': obj.get('pos_x', 0.0) + plot['x_offset'],
148
- 'y': obj.get('pos_y', 0.0),
149
- 'z': obj.get('pos_z', 0.0) + plot['z_offset']
150
- },
151
- 'rotation': {
152
- '_x': obj.get('rot_x', 0.0),
153
- '_y': obj.get('rot_y', 0.0),
154
- '_z': obj.get('rot_z', 0.0),
155
- '_order': obj.get('rot_order', 'XYZ')
156
- }
157
  }
158
- # If obj_id already exists, this will overwrite. Assumes later plot file wins?
159
- # Or maybe first loaded wins? Dict behavior. Let's assume last wins.
160
  all_objects[obj_id] = world_obj
161
-
162
- world_list = list(all_objects.values())
163
- print(f"[{time.time():.2f}] Loaded {len(world_list)} total objects.")
164
- return world_list
165
-
166
-
167
- def save_plot_data_merged(filename, new_objects_world_coords, plot_x_offset, plot_z_offset):
168
- """
169
- Loads existing data, merges new objects (world coords), saves back relative.
170
- Handles de-duplication based on obj_id (new objects overwrite).
171
- Returns True on success, False otherwise.
172
- """
173
- file_path = os.path.join(SAVE_DIR, filename)
174
- print(f"[{time.time():.2f}] Merging and saving plot: {filename}")
175
-
176
- # 1. Load existing objects (relative coordinates)
177
- existing_relative_objects = load_single_plot_objects_relative(filename)
178
- existing_objects_dict = {obj['obj_id']: obj for obj in existing_relative_objects if obj.get('obj_id')}
179
- print(f"Found {len(existing_objects_dict)} existing objects in {filename}.")
180
-
181
- # 2. Convert new objects to relative coordinates and add/overwrite in dict
182
- new_object_count = 0
183
- for obj_world in new_objects_world_coords:
184
- obj_id = obj_world.get('obj_id')
185
- pos = obj_world.get('position')
186
- rot = obj_world.get('rotation')
187
- obj_type = obj_world.get('type')
188
-
189
- if not all([obj_id, pos, rot, obj_type]):
190
- st.warning(f"Skipping malformed new object during merge: {obj_world}")
191
- continue
192
-
193
- relative_obj = {
194
- 'obj_id': obj_id,
195
- 'type': obj_type,
196
- 'pos_x': pos.get('x', 0.0) - plot_x_offset,
197
- 'pos_y': pos.get('y', 0.0),
198
- 'pos_z': pos.get('z', 0.0) - plot_z_offset,
199
- 'rot_x': rot.get('_x', 0.0),
200
- 'rot_y': rot.get('_y', 0.0),
201
- 'rot_z': rot.get('_z', 0.0),
202
- 'rot_order': rot.get('_order', 'XYZ')
203
- }
204
- existing_objects_dict[obj_id] = relative_obj # Add or overwrite based on ID
205
- new_object_count += 1
206
-
207
- print(f"Added/updated {new_object_count} objects for {filename}.")
208
-
209
- # 3. Convert final dictionary back to list and save
210
- final_relative_list = list(existing_objects_dict.values())
 
 
 
 
 
 
 
211
  try:
212
- if not final_relative_list:
213
- # If the merge results in an empty list, maybe delete the file?
214
- # Or save an empty file (or just header?)
215
- if os.path.exists(file_path):
216
- print(f"Resulting object list for {filename} is empty. Deleting file.")
217
- os.remove(file_path)
218
- else:
219
- print(f"Resulting object list for {filename} is empty. No file to save/delete.")
220
- # Ensure metadata cache gets cleared even if file deleted
221
- load_plot_metadata.clear()
222
- # Clear main object cache too
223
- get_all_world_objects.clear()
224
- return True # Considered success?
225
-
226
- df = pd.DataFrame(final_relative_list, columns=CSV_COLUMNS)
227
- # Ensure required columns aren't accidentally empty after merge/conversion
228
- df.dropna(subset=['obj_id','type', 'pos_x', 'pos_y', 'pos_z'], inplace=True)
229
- df.to_csv(file_path, index=False)
230
- st.success(f"Saved {len(df)} total objects to {filename}")
231
- # --- CRITICAL: Clear caches after successful save ---
232
- load_plot_metadata.clear()
233
- get_all_world_objects.clear()
234
- print(f"[{time.time():.2f}] Caches cleared after saving {filename}.")
235
- return True
236
  except Exception as e:
237
- st.error(f"Failed to save merged plot data to {filename}: {e}")
238
- st.exception(e)
239
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
- # --- Page Config ---
242
- st.set_page_config(page_title="Shared World Builder (v2)", layout="wide")
243
-
244
- # --- Initialize Session State ---
245
- if 'selected_object' not in st.session_state: st.session_state.selected_object = 'None'
246
- if 'js_save_payload' not in st.session_state: st.session_state.js_save_payload = None # Renamed from js_save_data_result for clarity
247
-
248
-
249
- # --- Load Initial Data for Page Load / Rerun ---
250
- # Use the cached function to get all objects
251
- initial_world_state_list = get_all_world_objects()
252
- # Metadata also uses its cache
253
- plots_metadata = load_plot_metadata()
254
-
255
- # --- Sidebar ---
256
- with st.sidebar:
257
- st.title("🏗️ World Controls")
258
-
259
- # --- Refresh Button ---
260
- st.header("World View")
261
- if st.button("🔄 Refresh World View", key="refresh_button"):
262
- st.info("Reloading world data...")
263
- load_plot_metadata.clear()
264
- get_all_world_objects.clear()
265
- # Clear potential JS payload trigger if refresh clicked mid-save?
266
- st.session_state.js_save_payload = None
267
- st.rerun()
268
-
269
- st.markdown("---")
270
-
271
- # Navigation (using cached metadata)
272
- st.header("Navigation (Plots)")
273
- st.caption("Click to teleport player to a plot.")
274
- max_cols = 2
275
- cols = st.columns(max_cols)
276
- col_idx = 0
277
- # Use the potentially updated plots_metadata
278
- sorted_plots_for_nav = sorted(plots_metadata, key=lambda p: (p['grid_x'], p['grid_z']))
279
- for plot in sorted_plots_for_nav:
280
- button_label = f"➡️ {plot.get('name', plot['id'])} ({plot['grid_x']},{plot['grid_z']})"
281
- if cols[col_idx].button(button_label, key=f"nav_{plot['id']}"):
282
- target_x = plot['x_offset'] + PLOT_WIDTH / 2
283
- target_z = plot['z_offset'] + PLOT_DEPTH / 2
284
- try:
285
- js_code = f"teleportPlayer({target_x}, {target_z});"
286
- streamlit_js_eval(js_code=js_code, key=f"teleport_{plot['id']}")
287
- except Exception as e:
288
- st.error(f"Failed to send teleport command: {e}")
289
- col_idx = (col_idx + 1) % max_cols
290
-
291
- st.markdown("---")
292
-
293
- # Object Placement
294
- st.header("Place Objects")
295
- object_types = ["None", "Simple House", "Tree", "Rock", "Fence Post"]
296
- current_selection = st.session_state.selected_object
297
- if current_selection not in object_types: current_selection = "None"
298
- current_object_index = object_types.index(current_selection)
299
-
300
- selected_object_type_widget = st.selectbox(
301
- "Select Object:", options=object_types, index=current_object_index, key="selected_object_widget"
302
- )
303
- if selected_object_type_widget != st.session_state.selected_object:
304
- st.session_state.selected_object = selected_object_type_widget
305
- # Update JS immediately without needing full rerun if possible
306
  try:
307
- js_update_selection = f"updateSelectedObjectType({json.dumps(st.session_state.selected_object)});"
308
- streamlit_js_eval(js_code=js_update_selection, key="update_selection_js")
309
- except Exception as e:
310
- st.warning(f"Could not push selection update to JS: {e}")
311
- # Optional: Trigger a rerun if immediate JS update isn't enough
312
- # st.rerun()
313
-
314
-
315
- st.markdown("---")
316
-
317
- # --- Saving ---
318
- st.header("Save Work")
319
- st.caption("Merges your newly placed objects into the shared world plot file.")
320
- if st.button("💾 Save My New Objects", key="save_button"):
321
- # Trigger JS to get data AND player position (needed to determine target plot)
322
- js_get_data_code = "getSaveDataAndPosition();"
323
- # Store the result in session state, process below
324
- st.session_state.js_save_payload = streamlit_js_eval(js_code=js_get_data_code, key="js_save_processor", want_result=True)
325
- # No automatic rerun here, processing happens below
326
-
327
- # --- Process Save Data ---
328
- save_data_from_js = st.session_state.pop("js_save_payload", None) # Use pop to consume
329
-
330
- if save_data_from_js is not None:
331
- st.info("Received save data from client...")
332
- save_processed_successfully = False
333
  try:
334
- # Expecting { playerPosition: {x,y,z}, objectsToSave: [...] }
335
- payload = json.loads(save_data_from_js)
336
-
337
- if isinstance(payload, dict) and 'playerPosition' in payload and 'objectsToSave' in payload:
338
- player_pos = payload['playerPosition']
339
- # These are the NEW objects placed by the user, with WORLD coordinates
340
- objects_to_save_world_coords = payload['objectsToSave']
341
-
342
- if not isinstance(objects_to_save_world_coords, list):
343
- st.error("Invalid 'objectsToSave' format received (expected list).")
344
- elif not objects_to_save_world_coords:
345
- st.warning("Save clicked, but there were no new objects reported by the client.")
346
- else:
347
- # Determine target plot based on player position
348
- target_grid_x = math.floor(player_pos.get('x', 0.0) / PLOT_WIDTH)
349
- target_grid_z = math.floor(player_pos.get('z', 0.0) / PLOT_DEPTH)
350
-
351
- target_filename = f"plot_X{target_grid_x}_Z{target_grid_z}.csv"
352
- target_plot_x_offset = target_grid_x * PLOT_WIDTH
353
- target_plot_z_offset = target_grid_z * PLOT_DEPTH
354
-
355
- st.write(f"Saving {len(objects_to_save_world_coords)} new object(s) to plot: {target_filename} (Player at: x={player_pos.get('x', 0):.1f}, z={player_pos.get('z', 0):.1f})")
356
-
357
- # --- Call the MERGE save function ---
358
- save_ok = save_plot_data_merged(
359
- target_filename,
360
- objects_to_save_world_coords,
361
- target_plot_x_offset,
362
- target_plot_z_offset
363
- )
364
-
365
- if save_ok:
366
- save_processed_successfully = True
367
- # Caches are cleared inside save_plot_data_merged now
368
- # Tell JS to clear its local unsaved state (newlyPlacedObjects + sessionStorage)
369
- try:
370
- streamlit_js_eval(js_code="resetNewlyPlacedObjects();", key="reset_js_state_after_save")
371
- st.success("Changes saved and merged successfully. Client state reset.")
372
- except Exception as js_e:
373
- st.warning(f"Save successful, but could not reset JS state: {js_e}")
374
- else:
375
- st.error(f"Failed to save merged plot data to file: {target_filename}")
376
- else:
377
- st.error("Invalid save payload structure received from client.")
378
- print("Received payload structure:", type(payload), "Keys:", payload.keys() if isinstance(payload, dict) else "N/A")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
- except json.JSONDecodeError:
381
- st.error("Failed to decode save data from client (invalid JSON).")
382
- print("Received raw data:", save_data_from_js)
383
  except Exception as e:
384
- st.error(f"Error processing save: {e}")
385
- st.exception(e)
386
 
387
- # Rerun after processing save attempt to reflect changes / clear messages / reload data
388
- st.rerun()
389
-
390
-
391
- # --- Main Area ---
392
- st.header("Infinite Shared 3D World")
393
- st.caption("Place objects, then 'Save My New Objects'. Use 'Refresh World View' to see others' saved changes.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
- # --- Load and Prepare HTML ---
396
- html_file_path = 'index.html'
397
- html_content_with_state = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
 
399
- try:
400
- with open(html_file_path, 'r', encoding='utf-8') as f:
401
- html_template = f.read()
 
402
 
403
- # --- Inject Python state into JavaScript ---
404
- # Use the data loaded (potentially from cache) at the start of the script run
405
- js_injection_script = f"""
406
  <script>
407
- // Use the global state loaded at the start of this Streamlit script run
408
- window.ALL_INITIAL_OBJECTS = {json.dumps(initial_world_state_list)};
409
- window.PLOTS_METADATA = {json.dumps(plots_metadata)}; // Plot info for ground generation etc.
410
- window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)}; // Current user's tool
411
- window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
412
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
413
 
414
  console.log("Streamlit State Injected:", {{
415
- selectedObject: window.SELECTED_OBJECT_TYPE,
416
- initialObjectsCount: window.ALL_INITIAL_OBJECTS ? window.ALL_INITIAL_OBJECTS.length : 0,
417
- plotCount: window.PLOTS_METADATA ? window.PLOTS_METADATA.length : 0,
418
- plotWidth: window.PLOT_WIDTH,
419
- plotDepth: window.PLOT_DEPTH
420
  }});
421
  </script>
422
  """
423
- # Find the closing </head> tag and insert the script before it
424
- html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
425
-
426
- # --- Embed HTML Component ---
427
- components.html(
428
- html_content_with_state,
429
- height=750, # Adjust as needed
430
- scrolling=False
431
- )
432
-
433
- except FileNotFoundError:
434
- st.error(f"CRITICAL ERROR: Could not find the file '{html_file_path}'.")
435
- st.warning(f"Make sure `{html_file_path}` is in the same directory as `app.py` and `{SAVE_DIR}` exists.")
436
- except Exception as e:
437
- st.error(f"An critical error occurred during HTML preparation or component rendering: {e}")
438
- st.exception(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py (Merged Version)
2
  import streamlit as st
3
+ import asyncio
4
+ import websockets
5
+ import uuid
6
+ from datetime import datetime
7
  import os
8
+ import random
9
+ import time
10
+ import hashlib
11
+ # from PIL import Image # Keep commented unless needed for image pasting->3D texture?
12
+ import glob
13
+ import base64
14
+ import io
15
+ import streamlit.components.v1 as components
16
+ import edge_tts
17
+ # from audio_recorder_streamlit import audio_recorder # Keep commented unless re-adding audio input
18
+ import nest_asyncio
19
+ import re
20
+ import pytz
21
+ import shutil
22
+ # import anthropic # Keep commented unless integrating Claude
23
+ # import openai # Keep commented unless integrating OpenAI
24
+ from PyPDF2 import PdfReader
25
+ import threading
26
  import json
27
+ import zipfile
28
+ # from gradio_client import Client # Keep commented unless integrating ArXiv/Gradio
29
+ from dotenv import load_dotenv
30
+ from streamlit_marquee import streamlit_marquee
31
+ from collections import defaultdict, Counter
32
  import pandas as pd
33
+ from streamlit_js_eval import streamlit_js_eval # Still needed for some UI interactions
34
+
35
+ # 🛠️ Patch asyncio for nesting
36
+ nest_asyncio.apply()
37
+
38
+ # 🎨 Page Config (From New App)
39
+ st.set_page_config(
40
+ page_title="🤖🏗️ Shared World Builder 🏆",
41
+ page_icon="🏗️",
42
+ layout="wide",
43
+ initial_sidebar_state="expanded" # Keep sidebar open initially
44
+ )
45
+
46
+ # --- Constants (Combined & 3D Added) ---
47
+ # Chat/User Constants
48
+ icons = '🤖🏗️🗣️' # Updated icons
49
+ Site_Name = '🤖🏗️ Shared World Builder 🗣️'
50
+ START_ROOM = "World Lobby 🌍"
51
+ FUN_USERNAMES = { # Simplified for clarity, can expand later
52
+ "BuilderBot 🤖": "en-US-AriaNeural", "WorldWeaver 🕸️": "en-US-JennyNeural",
53
+ "Terraformer 🌱": "en-GB-SoniaNeural", "SkyArchitect ☁️": "en-AU-NatashaNeural",
54
+ "PixelPainter 🎨": "en-CA-ClaraNeural", "VoxelVortex 🌪️": "en-US-GuyNeural",
55
+ "CosmicCrafter ✨": "en-GB-RyanNeural", "GeoGuru 🗺️": "en-AU-WilliamNeural",
56
+ "BlockBard 🧱": "en-CA-LiamNeural", "SoundSculptor 🔊": "en-US-AnaNeural",
57
+ }
58
+ EDGE_TTS_VOICES = list(set(FUN_USERNAMES.values()))
59
+ FILE_EMOJIS = {"md": "📝", "mp3": "🎵", "png": "🖼️", "mp4": "🎥", "zip": "📦", "csv":"📄"}
60
+
61
+ # 3D World Constants
62
  SAVE_DIR = "saved_worlds"
63
  PLOT_WIDTH = 50.0
64
  PLOT_DEPTH = 50.0
65
  CSV_COLUMNS = ['obj_id', 'type', 'pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z', 'rot_order']
66
+ WORLD_STATE_FILE = "world_state.json" # Using JSON for simpler in-memory<->disk state
67
+
68
+ # --- Directories (Combined) ---
69
+ for d in ["chat_logs", "audio_logs", "audio_cache", SAVE_DIR]: # Added SAVE_DIR
70
+ os.makedirs(d, exist_ok=True)
71
+
72
+ CHAT_DIR = "chat_logs"
73
+ MEDIA_DIR = "." # Where general files are saved/served from
74
+ AUDIO_CACHE_DIR = "audio_cache"
75
+ AUDIO_DIR = "audio_logs"
76
+ STATE_FILE = "user_state.txt" # For remembering username
77
+
78
+ CHAT_FILE = os.path.join(CHAT_DIR, "global_chat.md")
79
+ # Removed vote files for simplicity
80
+
81
+ # --- API Keys (Keep placeholder logic) ---
82
+ load_dotenv()
83
+ # anthropic_key = os.getenv('ANTHROPIC_API_KEY', st.secrets.get('ANTHROPIC_API_KEY', ""))
84
+ # openai_api_key = os.getenv('OPENAI_API_KEY', st.secrets.get('OPENAI_API_KEY', ""))
85
+ # openai_client = openai.OpenAI(api_key=openai_api_key)
86
+
87
+ # --- Helper Functions (Combined & Adapted) ---
88
+
89
+ def format_timestamp_prefix(username=""):
90
+ # Using UTC for consistency in logs/filenames across timezones potentially
91
+ now = datetime.now(pytz.utc)
92
+ # Simplified format
93
+ return f"{now.strftime('%Y%m%d_%H%M%S')}_{username}"
94
+
95
+ # --- Performance Timer (Optional, Keep if desired) ---
96
+ class PerformanceTimer:
97
+ # ... (keep class as is from new app.py if needed) ...
98
+ pass
99
+
100
+ # --- 3D World State Management (Adapted from original + WebSocket focus) ---
101
+
102
+ # Global structure to hold the current state of the world IN MEMORY
103
+ # Use defaultdict for easier adding
104
+ # Needs thread safety if accessed by multiple websocket handlers simultaneously.
105
+ # For now, relying on Streamlit's single-thread-per-session execution
106
+ # and assuming broadcast updates are okay without strict locking for this scale.
107
+ # A lock would be needed for production robustness.
108
+ # world_objects_lock = threading.Lock() # Import threading if using lock
109
+ world_objects = defaultdict(dict) # Holds {obj_id: object_data}
110
+
111
+ def load_world_state_from_disk():
112
+ """Loads world state from the JSON file or fallback to CSVs."""
113
+ global world_objects
114
+ loaded_count = 0
115
+ if os.path.exists(WORLD_STATE_FILE):
116
+ try:
117
+ with open(WORLD_STATE_FILE, 'r') as f:
118
+ data = json.load(f)
119
+ # Ensure keys are strings if they got saved as ints somehow
120
+ world_objects = defaultdict(dict, {str(k): v for k, v in data.items()})
121
+ loaded_count = len(world_objects)
122
+ print(f"Loaded {loaded_count} objects from {WORLD_STATE_FILE}")
123
+ except json.JSONDecodeError:
124
+ print(f"Error reading {WORLD_STATE_FILE}. Falling back to CSVs.")
125
+ world_objects = defaultdict(dict) # Reset before loading from CSV
126
+ except Exception as e:
127
+ print(f"Error loading from {WORLD_STATE_FILE}: {e}. Falling back to CSVs.")
128
+ world_objects = defaultdict(dict) # Reset
129
+
130
+ # Fallback or initial load from CSVs if JSON fails or doesn't exist
131
+ if not world_objects:
132
+ print("Loading world state from CSV files...")
133
+ # Use the cached CSV loading logic, but populate the global dict
134
+ loaded_from_csv = get_all_world_objects_from_csv() # Gets list
135
+ for obj in loaded_from_csv:
136
+ world_objects[obj['obj_id']] = obj
137
+ loaded_count = len(world_objects)
138
+ print(f"Loaded {loaded_count} objects from CSVs.")
139
+ # Save immediately to JSON for next time
140
+ save_world_state_to_disk()
141
+
142
+ return loaded_count
143
+
144
+ def save_world_state_to_disk():
145
+ """Saves the current in-memory world state to a JSON file."""
146
+ global world_objects
147
+ print(f"Saving {len(world_objects)} objects to {WORLD_STATE_FILE}...")
148
+ try:
149
+ # with world_objects_lock: # Use lock if implementing thread safety
150
+ with open(WORLD_STATE_FILE, 'w') as f:
151
+ # Convert defaultdict back to regular dict for saving
152
+ json.dump(dict(world_objects), f, indent=2)
153
+ print("World state saved successfully.")
154
+ return True
155
+ except Exception as e:
156
+ print(f"Error saving world state to {WORLD_STATE_FILE}: {e}")
157
+ st.error(f"Failed to save world state: {e}")
158
+ return False
159
 
160
+ # --- Functions to load from CSVs (kept for initial load/fallback) ---
161
+ @st.cache_data(ttl=3600)
 
 
 
 
 
162
  def load_plot_metadata():
163
  """Scans save dir for plot_X*_Z*.csv, sorts, calculates metadata."""
164
+ # ... (Keep function as is from original app.py) ...
165
  print(f"[{time.time():.2f}] Loading plot metadata...")
166
  plot_files = []
167
  try:
168
  plot_files = [f for f in os.listdir(SAVE_DIR) if f.endswith(".csv") and f.startswith("plot_X")]
169
+ except FileNotFoundError: return []
170
+ except Exception as e: return []
 
 
 
 
171
 
172
  parsed_plots = []
173
  for filename in plot_files:
174
  try:
 
175
  file_path = os.path.join(SAVE_DIR, filename)
176
+ # Basic check for empty file before parsing name
177
+ if not os.path.exists(file_path) or os.path.getsize(file_path) <= 2: continue
 
 
178
 
179
  parts = filename[:-4].split('_')
180
  grid_x = int(parts[1][1:])
 
185
  'grid_x': grid_x, 'grid_z': grid_z, 'name': plot_name,
186
  'x_offset': grid_x * PLOT_WIDTH, 'z_offset': grid_z * PLOT_DEPTH
187
  })
188
+ except Exception as e:
 
 
 
 
189
  st.warning(f"Error parsing metadata from filename '{filename}': {e}. Skipping.")
190
  continue
 
191
  parsed_plots.sort(key=lambda p: (p['grid_x'], p['grid_z']))
 
192
  return parsed_plots
193
 
 
194
  def load_single_plot_objects_relative(filename):
195
  """Loads objects from a specific CSV file, keeping coordinates relative."""
196
+ # ... (Keep function as is from previous merged version, including validation) ...
197
  file_path = os.path.join(SAVE_DIR, filename)
198
  try:
199
+ if not os.path.exists(file_path) or os.path.getsize(file_path) == 0: return []
 
 
 
 
200
  df = pd.read_csv(file_path)
201
+ if df.empty: return []
202
+ if 'obj_id' not in df.columns: df['obj_id'] = [str(uuid.uuid4()) for _ in range(len(df))]
203
+ else: df['obj_id'] = df['obj_id'].fillna(pd.Series([str(uuid.uuid4()) for _ in range(len(df))])).astype(str)
 
 
 
 
 
 
 
 
 
 
204
  for col in ['type', 'pos_x', 'pos_y', 'pos_z']:
205
+ if col not in df.columns: return []
 
 
 
 
206
  for col, default in [('rot_x', 0.0), ('rot_y', 0.0), ('rot_z', 0.0), ('rot_order', 'XYZ')]:
207
  if col not in df.columns: df[col] = default
 
208
  df.fillna({'rot_x': 0.0, 'rot_y': 0.0, 'rot_z': 0.0, 'rot_order': 'XYZ'}, inplace=True)
 
 
209
  for col in ['pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z']:
210
+ df[col] = pd.to_numeric(df[col], errors='coerce')
211
+ df.dropna(subset=['pos_x', 'pos_y', 'pos_z'], inplace=True)
 
 
212
  df['type'] = df['type'].astype(str)
 
 
213
  return df[CSV_COLUMNS].to_dict('records')
214
+ except Exception as e: return []
 
 
 
 
 
 
 
 
 
 
 
215
 
216
 
217
+ @st.cache_data(show_spinner="Loading initial world objects from CSVs...")
218
+ def get_all_world_objects_from_csv():
219
+ """Loads ALL objects from ALL known plots into world coordinates FROM CSVs."""
220
+ # ... (Keep function as is from previous merged version) ...
221
+ print(f"[{time.time():.2f}] Reloading ALL world objects from CSV files...")
222
+ all_objects = {}
223
+ plots_meta = load_plot_metadata()
 
224
  for plot in plots_meta:
225
  relative_objects = load_single_plot_objects_relative(plot['filename'])
226
  for obj in relative_objects:
227
  obj_id = obj.get('obj_id')
228
+ if not obj_id: continue
 
 
229
  world_obj = {
230
+ 'obj_id': obj_id, 'type': obj.get('type', 'Unknown'),
231
+ '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']},
232
+ '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')}
 
 
 
 
 
 
 
 
 
 
233
  }
 
 
234
  all_objects[obj_id] = world_obj
235
+ return list(all_objects.values())
236
+
237
+ # --- Session State Init (Combined & Expanded) ---
238
+ def init_session_state():
239
+ defaults = {
240
+ # From Chat App
241
+ 'server_running': False, 'server_task': None, 'active_connections': defaultdict(dict), # Use defaultdict
242
+ 'last_chat_update': 0, 'message_text': "", 'audio_cache': {},
243
+ 'tts_voice': "en-US-AriaNeural", 'chat_history': [], 'marquee_settings': {
244
+ "background": "#1E1E1E", "color": "#FFFFFF", "font-size": "14px",
245
+ "animationDuration": "20s", "width": "100%", "lineHeight": "35px"
246
+ },
247
+ 'enable_audio': True, 'download_link_cache': {}, 'username': None,
248
+ 'autosend': True, 'last_message': "", 'timer_start': time.time(),
249
+ 'last_sent_transcript': "", 'last_refresh': time.time(),
250
+ 'auto_refresh': False, # Default auto-refresh off for world builder?
251
+ 'refresh_rate': 30, # Default refresh rate
252
+
253
+ # From 3D World App (or adapted)
254
+ 'selected_object': 'None', # Current building tool
255
+ # 'world_objects': defaultdict(dict), # In-memory state now global 'world_objects'
256
+ 'initial_world_state_loaded': False, # Flag to load state only once
257
+
258
+ # Keep others if needed, removed some for clarity
259
+ 'operation_timings': {}, 'performance_metrics': defaultdict(list),
260
+ }
261
+ for k, v in defaults.items():
262
+ if k not in st.session_state:
263
+ st.session_state[k] = v
264
+ # Ensure nested dicts are present
265
+ if 'marquee_settings' not in st.session_state: st.session_state.marquee_settings = defaults['marquee_settings']
266
+ if 'active_connections' not in st.session_state: st.session_state.active_connections = defaultdict(dict)
267
+
268
+ # --- Marquee Helpers (Keep from New App) ---
269
+ def update_marquee_settings_ui(): # ... (keep function as is) ...
270
+ pass # Placeholder if not immediately needed
271
+ def display_marquee(text, settings, key_suffix=""): # ... (keep function as is) ...
272
+ pass # Placeholder
273
+
274
+ # --- Text & File Helpers (Keep & Adapt from New App) ---
275
+ def clean_text_for_tts(text): # ... (keep function as is) ...
276
+ return re.sub(r'[#*!\[\]]+', '', ' '.join(text.split()))[:200] or "No text"
277
+
278
+ def generate_filename(prompt, username, file_type="md", title=None): # ... (keep function as is) ...
279
+ timestamp = format_timestamp_prefix(username)
280
+ # Simplified filename generation
281
+ base = clean_text_for_filename(title if title else prompt[:30])
282
+ hash_val = hashlib.md5(prompt.encode()).hexdigest()[:6]
283
+ return f"{timestamp}_{base}_{hash_val}.{file_type}"
284
+
285
+ def clean_text_for_filename(text): # ... (keep function as is) ...
286
+ return '_'.join(re.sub(r'[^\w\s-]', '', text.lower()).split())[:50]
287
+
288
+ def create_file(content, username, file_type="md", title=None): # ... (keep function as is) ...
289
+ filename = generate_filename(content, username, file_type, title)
290
+ # Ensure saving to correct directory based on type? Assume current dir for now
291
+ save_path = filename # os.path.join(MEDIA_DIR, filename)?
292
  try:
293
+ with open(save_path, 'w', encoding='utf-8') as f:
294
+ f.write(content)
295
+ return save_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  except Exception as e:
297
+ print(f"Error creating file {save_path}: {e}")
298
+ return None
299
+
300
+ def get_download_link(file, file_type="mp3"): # ... (keep function as is, ensure FILE_EMOJIS updated) ...
301
+ cache_key = f"dl_{file}_{os.path.getmtime(file) if os.path.exists(file) else 0}"
302
+ if cache_key not in st.session_state['download_link_cache']:
303
+ if not os.path.exists(file): return "File not found"
304
+ with open(file, "rb") as f: b64 = base64.b64encode(f.read()).decode()
305
+ mime_types = {"mp3": "audio/mpeg", "png": "image/png", "mp4": "video/mp4", "md": "text/markdown", "zip": "application/zip", "csv": "text/csv", "json": "application/json"}
306
+ 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>'
307
+ return st.session_state['download_link_cache'][cache_key]
308
+
309
+ def save_username(username): # ... (keep function as is) ...
310
+ try:
311
+ with open(STATE_FILE, 'w') as f: f.write(username)
312
+ except Exception as e: print(f"Failed to save username: {e}")
313
 
314
+ def load_username(): # ... (keep function as is) ...
315
+ if os.path.exists(STATE_FILE):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  try:
317
+ with open(STATE_FILE, 'r') as f: return f.read().strip()
318
+ except Exception as e: print(f"Failed to load username: {e}")
319
+ return None
320
+
321
+ # --- Audio Processing (Keep from New App) ---
322
+ async def async_edge_tts_generate(text, voice, username): # Simplified args
323
+ # ... (keep core logic, maybe save to AUDIO_DIR) ...
324
+ cache_key = f"{text[:100]}_{voice}"
325
+ if cache_key in st.session_state['audio_cache']: return st.session_state['audio_cache'][cache_key]
326
+ text = clean_text_for_tts(text)
327
+ if not text or text == "No text": return None
328
+ filename_base = generate_filename(text, username, "mp3")
329
+ save_path = os.path.join(AUDIO_DIR, filename_base)
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  try:
331
+ communicate = edge_tts.Communicate(text, voice)
332
+ await communicate.save(save_path)
333
+ if os.path.exists(save_path) and os.path.getsize(save_path) > 0:
334
+ st.session_state['audio_cache'][cache_key] = save_path
335
+ return save_path
336
+ else: return None
337
+ except Exception as e: return None
338
+
339
+ def play_and_download_audio(file_path): # ... (keep function as is) ...
340
+ if file_path and os.path.exists(file_path):
341
+ st.audio(file_path)
342
+ file_type = file_path.split('.')[-1]
343
+ st.markdown(get_download_link(file_path, file_type), unsafe_allow_html=True)
344
+
345
+ # --- Chat Saving/Loading (Keep & Adapt from New App) ---
346
+ async def save_chat_entry(username, message, voice, is_markdown=False):
347
+ # ... (keep core logic, save to CHAT_DIR) ...
348
+ if not message.strip(): return None, None
349
+ central = pytz.timezone('US/Central') # Or use UTC
350
+ timestamp = datetime.now(central).strftime("%Y-%m-%d %H:%M:%S")
351
+ entry = f"[{timestamp}] {username} ({voice}): {message}" if not is_markdown else f"[{timestamp}] {username} ({voice}):\n```markdown\n{message}\n```"
352
+ md_filename_base = generate_filename(message, username, "md")
353
+ md_file = create_file(entry, username, "md", os.path.join(CHAT_DIR, md_filename_base)) # Save to chat_logs
354
+ # Simplified - don't write to global CHAT_FILE on every message, maybe periodically?
355
+ # Append to session state history for immediate display
356
+ st.session_state.chat_history.append(entry)
357
+ # Generate audio
358
+ audio_file = None
359
+ if st.session_state.get('enable_audio', True): # Check if enabled
360
+ audio_file = await async_edge_tts_generate(message, voice, username)
361
+ return md_file, audio_file
362
+
363
+ async def load_chat_history():
364
+ # Load from individual files in CHAT_DIR for resilience? Or just session state?
365
+ # For now, rely on session state + initial load from files if needed.
366
+ if not st.session_state.chat_history:
367
+ chat_files = sorted(glob.glob(os.path.join(CHAT_DIR, "*.md")), key=os.path.getmtime)
368
+ for f in chat_files:
369
+ try:
370
+ with open(f, 'r', encoding='utf-8') as file:
371
+ st.session_state.chat_history.append(file.read().strip())
372
+ except Exception: pass # Ignore read errors
373
+ return st.session_state.chat_history
374
+
375
+
376
+ # --- WebSocket Handling (Adapted for 3D State) ---
377
+ # Global set to track connected client IDs for efficient broadcast checks
378
+ connected_clients = set()
379
+
380
+ async def websocket_handler(websocket, path):
381
+ client_id = str(websocket.id) # Use websocket's built-in ID
382
+ connected_clients.add(client_id)
383
+ username = st.session_state.get('username', f"User_{client_id[:4]}")
384
+ print(f"Client connected: {client_id} ({username})")
385
+
386
+ # Send initial world state to the new client
387
+ try:
388
+ # with world_objects_lock: # Lock if using threads
389
+ initial_state_msg = json.dumps({
390
+ "type": "initial_state",
391
+ "payload": dict(world_objects) # Send current world state
392
+ })
393
+ await websocket.send(initial_state_msg)
394
+ print(f"Sent initial state ({len(world_objects)} objects) to {client_id}")
395
+
396
+ # Announce join (optional)
397
+ await broadcast_message(json.dumps({
398
+ "type": "user_join",
399
+ "payload": {"username": username, "id": client_id}
400
+ }), exclude_id=client_id) # Don't send to self
401
 
 
 
 
402
  except Exception as e:
403
+ print(f"Error sending initial state to {client_id}: {e}")
 
404
 
405
+ # Main message loop
406
+ try:
407
+ async for message in websocket:
408
+ try:
409
+ data = json.loads(message)
410
+ msg_type = data.get("type")
411
+ payload = data.get("payload")
412
+ sender_username = payload.get("username", username) # Get username from payload or default
413
+
414
+ if msg_type == "chat_message":
415
+ print(f"Received chat from {sender_username}: {payload.get('message')}")
416
+ voice = FUN_USERNAMES.get(sender_username, "en-US-AriaNeural")
417
+ # Save chat locally (optional async call)
418
+ asyncio.create_task(save_chat_entry(sender_username, payload.get('message', ''), voice))
419
+ # Broadcast chat to others
420
+ await broadcast_message(message, exclude_id=client_id)
421
+
422
+ elif msg_type == "place_object":
423
+ obj_data = payload.get("object_data")
424
+ if obj_data and 'obj_id' in obj_data:
425
+ print(f"Received place_object from {sender_username}: {obj_data.get('type')} ({obj_data['obj_id']})")
426
+ # with world_objects_lock: # Lock if needed
427
+ world_objects[obj_data['obj_id']] = obj_data # Add/update in memory
428
+ # Broadcast placement to others
429
+ broadcast_payload = json.dumps({
430
+ "type": "object_placed",
431
+ "payload": {"object_data": obj_data, "username": sender_username}
432
+ })
433
+ await broadcast_message(broadcast_payload, exclude_id=client_id)
434
+ # Maybe trigger periodic save here? Or rely on manual save.
435
+ else:
436
+ print(f"Invalid place_object payload from {client_id}")
437
+
438
+ elif msg_type == "delete_object":
439
+ obj_id = payload.get("obj_id")
440
+ if obj_id:
441
+ print(f"Received delete_object from {sender_username}: {obj_id}")
442
+ # with world_objects_lock: # Lock if needed
443
+ if obj_id in world_objects:
444
+ del world_objects[obj_id]
445
+ # Broadcast deletion
446
+ broadcast_payload = json.dumps({
447
+ "type": "object_deleted",
448
+ "payload": {"obj_id": obj_id, "username": sender_username}
449
+ })
450
+ await broadcast_message(broadcast_payload, exclude_id=client_id)
451
+
452
+ # Add handlers for other types (player_move, request_save, etc.)
453
+
454
+ except json.JSONDecodeError:
455
+ print(f"Received invalid JSON from {client_id}: {message}")
456
+ except Exception as e:
457
+ print(f"Error processing message from {client_id}: {e}")
458
+
459
+ except websockets.ConnectionClosedOK:
460
+ print(f"Client disconnected normally: {client_id} ({username})")
461
+ except websockets.ConnectionClosedError as e:
462
+ print(f"Client connection closed with error: {client_id} ({username}) - {e}")
463
+ finally:
464
+ connected_clients.discard(client_id)
465
+ # Announce leave (optional)
466
+ await broadcast_message(json.dumps({
467
+ "type": "user_leave",
468
+ "payload": {"username": username, "id": client_id}
469
+ }))
470
+ print(f"Client disconnected: {client_id} ({username}). Remaining: {len(connected_clients)}")
471
+
472
+
473
+ # Modified broadcast to use the global set and skip sender
474
+ async def broadcast_message(message, exclude_id=None):
475
+ tasks = []
476
+ disconnected_clients_this_call = set()
477
+
478
+ # Iterate over a copy of the client set in case it changes during iteration
479
+ current_client_ids = list(connected_clients)
480
+
481
+ for client_id in current_client_ids:
482
+ if client_id == exclude_id:
483
+ continue
484
 
485
+ websocket = st.session_state.active_connections.get(client_id) # Get WS object
486
+ if websocket:
487
+ try:
488
+ # Create task for sending; allows concurrent sends
489
+ tasks.append(asyncio.create_task(websocket.send(message)))
490
+ except websockets.ConnectionClosed:
491
+ print(f"Found disconnected client during broadcast prep: {client_id}")
492
+ disconnected_clients_this_call.add(client_id)
493
+ except RuntimeError as e: # Handles 'Event loop is closed' during shutdown
494
+ print(f"RuntimeError during broadcast prep for {client_id}: {e}")
495
+ disconnected_clients_this_call.add(client_id)
496
+ except Exception as e:
497
+ print(f"Unexpected error during broadcast prep for {client_id}: {e}")
498
+ disconnected_clients_this_call.add(client_id)
499
+ else:
500
+ # Websocket object not found in session state, likely already disconnected
501
+ disconnected_clients_this_call.add(client_id)
502
+
503
+ # Wait for all send tasks to complete
504
+ if tasks:
505
+ await asyncio.gather(*tasks, return_exceptions=True) # Handle exceptions during send
506
+
507
+ # Clean up disconnected clients found during this specific broadcast attempt
508
+ if disconnected_clients_this_call:
509
+ print(f"Cleaning up {len(disconnected_clients_this_call)} disconnected clients after broadcast.")
510
+ for client_id in disconnected_clients_this_call:
511
+ connected_clients.discard(client_id)
512
+ st.session_state.active_connections.pop(client_id, None)
513
+
514
+
515
+ async def run_websocket_server():
516
+ # Check if already running - basic flag protection
517
+ if st.session_state.get('server_running_flag', False):
518
+ print("Server already seems to be running or starting.")
519
+ return
520
+ st.session_state['server_running_flag'] = True
521
+ print("Starting WebSocket server on 0.0.0.0:8765...")
522
+ try:
523
+ # Use localhost for testing, 0.0.0.0 for broader access (requires firewall config)
524
+ server = await websockets.serve(websocket_handler, 'localhost', 8765)
525
+ print("WebSocket server started successfully.")
526
+ st.session_state['server_instance'] = server # Store server instance if needed for graceful shutdown
527
+ await server.wait_closed() # Keep server running
528
+ except OSError as e:
529
+ print(f"### FAILED TO START WEBSOCKET SERVER: {e}")
530
+ st.error(f"Failed to start WebSocket server on port 8765: {e}. Port might be in use.")
531
+ # Try to connect as client if server fails? Or just stop.
532
+ except Exception as e:
533
+ print(f"### UNEXPECTED ERROR IN WEBSOCKET SERVER: {e}")
534
+ st.error(f"An unexpected error occurred: {e}")
535
+ finally:
536
+ print("WebSocket server task finished.")
537
+ st.session_state['server_running_flag'] = False
538
+ st.session_state['server_instance'] = None
539
+
540
+
541
+ def start_websocket_server_thread():
542
+ """Starts the WebSocket server in a separate thread."""
543
+ # Check if thread already running
544
+ if st.session_state.get('server_task') and st.session_state.server_task.is_alive():
545
+ print("Server thread already running.")
546
+ return
547
+ print("Creating and starting new server thread.")
548
+ loop = asyncio.new_event_loop()
549
+ asyncio.set_event_loop(loop)
550
+ st.session_state.server_task = threading.Thread(target=loop.run_until_complete, args=(run_websocket_server(),), daemon=True)
551
+ st.session_state.server_task.start()
552
+
553
+ # --- PDF to Audio (Keep if desired, maybe in a separate tab?) ---
554
+ class AudioProcessor: # ... (keep class as is) ...
555
+ 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 {}
556
+ def _save_metadata(self): #... (save logic) ...
557
+ with open(f"{self.cache_dir}/metadata.json", 'w') as f: json.dump(self.metadata, f)
558
+ async def create_audio(self, text, voice='en-US-AriaNeural'): # ... (audio creation logic) ...
559
+ cache_key=hashlib.md5(f"{text}:{voice}".encode()).hexdigest(); cache_path=f"{self.cache_dir}/{cache_key}.mp3"
560
+ if cache_key in self.metadata and os.path.exists(cache_path): return cache_path
561
+ text=clean_text_for_tts(text); communicate=edge_tts.Communicate(text,voice); await communicate.save(cache_path)
562
+ self.metadata[cache_key]={'timestamp': datetime.now().isoformat(), 'text_length': len(text), 'voice': voice}; self._save_metadata()
563
+ return cache_path
564
+
565
+ def process_pdf(pdf_file, max_pages, voice, audio_processor): # ... (keep function as is) ...
566
+ reader=PdfReader(pdf_file); total_pages=min(len(reader.pages),max_pages); texts,audios={}, {}
567
+ async def process_page(i,text): audio_path=await audio_processor.create_audio(text,voice); audios[i]=audio_path
568
+ 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()
569
+ return texts,audios,total_pages
570
+
571
+ # --- ArXiv/AI Lookup (Commented out for focus) ---
572
+ # def parse_arxiv_refs(...): pass
573
+ # def generate_5min_feature_markdown(...): pass
574
+ # async def create_paper_audio_files(...): pass
575
+ # async def perform_ai_lookup(...): pass
576
+ # async def perform_claude_search(...): pass
577
+ # async def perform_arxiv_search(...): pass
578
+
579
+ # --- Voting (Removed for focus) ---
580
+ # def save_vote(...): pass
581
+ # def load_votes(...): pass
582
+
583
+ # --- Image Handling (Keep basic save, comment out Claude processing) ---
584
+ async def save_pasted_image(image, username): # Simplified
585
+ img_hash = hashlib.md5(image.tobytes()).hexdigest()[:8]
586
+ # Add check against existing hashes if needed: if img_hash in st.session_state.image_hashes: return None
587
+ timestamp = format_timestamp_prefix(username)
588
+ filename = f"{timestamp}_pasted_{img_hash}.png"
589
+ filepath = os.path.join(MEDIA_DIR, filename) # Save in base dir
590
+ try: image.save(filepath, "PNG"); return filepath
591
+ except Exception as e: print(f"Failed image save: {e}"); return None
592
+
593
+ # --- Zip and Delete Files (Keep from New App) ---
594
+ def create_zip_of_files(files, prefix="Archive", query=""): # Simplified args
595
+ if not files: return None
596
+ timestamp = format_timestamp_prefix() # Generic timestamp
597
+ zip_name = f"{prefix}_{timestamp}.zip"
598
+ try:
599
+ with zipfile.ZipFile(zip_name, 'w') as z:
600
+ for f in files:
601
+ if os.path.exists(f): z.write(f, os.path.basename(f)) # Use basename in archive
602
+ return zip_name
603
+ except Exception as e: print(f"Zip creation failed: {e}"); return None
604
+
605
+ def delete_files(file_patterns, exclude_files=["README.md", STATE_FILE, WORLD_STATE_FILE]): # Takes list of patterns
606
+ deleted_count = 0
607
+ for pattern in file_patterns:
608
+ # Be careful with glob patterns!
609
+ files_to_delete = glob.glob(pattern)
610
+ for f in files_to_delete:
611
+ basename = os.path.basename(f)
612
+ if basename not in exclude_files:
613
+ try: os.remove(f); deleted_count += 1
614
+ except Exception as e: print(f"Failed delete {f}: {e}")
615
+ print(f"Deleted {deleted_count} files.")
616
+ # Clear relevant caches?
617
+ st.session_state['download_link_cache'] = {}
618
+
619
+
620
+ # --- Custom Paste Component (Keep from New App) ---
621
+ def paste_image_component(): # ... (Keep function as is) ...
622
+ # Returns Image object, type string
623
+ with st.form(key="paste_form"):
624
+ paste_input = st.text_area("Paste Image Data Here (Ctrl+V)", key="paste_input_area", height=50)
625
+ submit_button = st.form_submit_button("Paste Image 📋")
626
+ if submit_button and paste_input and paste_input.startswith('data:image'):
627
+ try:
628
+ mime_type = paste_input.split(';')[0].split(':')[1]; base64_str = paste_input.split(',')[1]
629
+ img_bytes = base64.b64decode(base64_str); img = Image.open(io.BytesIO(img_bytes))
630
+ st.image(img, caption=f"Pasted Image ({mime_type.split('/')[1].upper()})", width=150) # Smaller preview
631
+ return img, mime_type.split('/')[1]
632
+ except Exception as e: st.error(f"Image decode error: {e}")
633
+ return None, None
634
+
635
+
636
+ # --- Mapping Emojis to Primitive Types ---
637
+ # Ensure these types match the create[PrimitiveName] functions in index.html
638
+ PRIMITIVE_MAP = {
639
+ "🌳": "Tree", "🗿": "Rock", "🏛️": "Simple House", "🌲": "Pine Tree", "🧱": "Brick Wall",
640
+ "🔵": "Sphere", "📦": "Cube", " cylinder ": "Cylinder", "🍦": "Cone", "🍩": "Torus",
641
+ "🍄": "Mushroom", "🌵": "Cactus", "🔥": "Campfire", "⭐": "Star", "💎": "Gem",
642
+ "🗼": "Tower", "🚧": "Barrier", "⛲": "Fountain", "🏮": "Lantern", "푯": "Sign Post"
643
+ # Add more pairs up to ~20
644
+ }
645
+
646
+ # --- Main Streamlit Interface ---
647
+ def main_interface():
648
+ init_session_state()
649
+
650
+ # --- Load initial world state ONCE per session ---
651
+ if not st.session_state.initial_world_state_loaded:
652
+ load_world_state_from_disk()
653
+ st.session_state.initial_world_state_loaded = True
654
+
655
+ # --- Username Setup ---
656
+ saved_username = load_username()
657
+ if saved_username and saved_username in FUN_USERNAMES:
658
+ st.session_state.username = saved_username
659
+ if not st.session_state.username:
660
+ st.session_state.username = random.choice(list(FUN_USERNAMES.keys()))
661
+ st.session_state.tts_voice = FUN_USERNAMES[st.session_state.username]
662
+ save_username(st.session_state.username)
663
+ # Don't automatically announce join here, let WebSocket handler do it on connect
664
+
665
+ st.title(f"{Site_Name} - User: {st.session_state.username}")
666
+
667
+ # --- Main Content Area ---
668
+ tab_world, tab_chat, tab_files = st.tabs(["🏗️ World Builder", "🗣️ Chat", "📂 Files & Settings"])
669
+
670
+ with tab_world:
671
+ st.header("Shared 3D World")
672
+ st.caption("Place objects using the sidebar tools. Changes are shared live!")
673
+
674
+ # --- Embed HTML Component for Three.js ---
675
+ html_file_path = 'index.html'
676
+ try:
677
+ with open(html_file_path, 'r', encoding='utf-8') as f:
678
+ html_template = f.read()
679
 
680
+ # Inject necessary data for JS: Username, WebSocket URL, initial state?
681
+ # Initial state now sent via WebSocket, maybe don't inject here?
682
+ # Let's inject username and WS url.
683
+ ws_url = "ws://localhost:8765" # Use localhost for local dev
684
 
685
+ js_injection_script = f"""
 
 
686
  <script>
687
+ window.USERNAME = {json.dumps(st.session_state.username)};
688
+ window.WEBSOCKET_URL = {json.dumps(ws_url)};
689
+ window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)}; // Send current tool
690
+ window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)}; // Send constants needed by JS
 
691
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
692
 
693
  console.log("Streamlit State Injected:", {{
694
+ username: window.USERNAME,
695
+ websocketUrl: window.WEBSOCKET_URL,
696
+ selectedObject: window.SELECTED_OBJECT_TYPE
 
 
697
  }});
698
  </script>
699
  """
700
+ html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
701
+
702
+ components.html(html_content_with_state, height=700, scrolling=False)
703
+
704
+ except FileNotFoundError:
705
+ st.error(f"CRITICAL ERROR: Could not find '{html_file_path}'.")
706
+ except Exception as e:
707
+ st.error(f"Error loading 3D component: {e}")
708
+
709
+ with tab_chat:
710
+ st.header(f"{START_ROOM} Chat")
711
+ chat_history = asyncio.run(load_chat_history()) # Load history
712
+ chat_container = st.container(height=500) # Scrollable chat area
713
+ with chat_container:
714
+ # Display chat history (most recent at bottom)
715
+ for entry in reversed(chat_history[-50:]): # Show last 50 messages
716
+ st.markdown(entry) # Use markdown to render potential code blocks
717
+
718
+ # Chat Input Area
719
+ message = st.text_input("Your Message:", key="message_input", label_visibility="collapsed")
720
+ if st.button("Send Chat 💬", key="send_chat_button") or (st.session_state.autosend and message):
721
+ if message.strip() and message != st.session_state.last_message:
722
+ st.session_state.last_message = message
723
+ voice = FUN_USERNAMES.get(st.session_state.username, "en-US-AriaNeural")
724
+ # Send via WebSocket
725
+ ws_message = json.dumps({
726
+ "type": "chat_message",
727
+ "payload": {"username": st.session_state.username, "message": message, "voice": voice}
728
+ })
729
+ # Run broadcast in asyncio task to avoid blocking Streamlit
730
+ asyncio.run(broadcast_message(ws_message))
731
+ # Save locally (optional now, as broadcast handles real-time)
732
+ asyncio.run(save_chat_entry(st.session_state.username, message, voice))
733
+ # Clear input - needs rerun or JS callback
734
+ st.session_state.message_input = ""
735
+ st.rerun() # Force rerun to clear input and update display
736
+
737
+ with tab_files:
738
+ st.header("File Management & Settings")
739
+ # Add options from the new app's sidebar here if desired
740
+ # e.g., Zipping, Deleting, Marquee settings
741
+
742
+ st.subheader("Server & World State")
743
+ col_ws, col_save = st.columns(2)
744
+ with col_ws:
745
+ ws_status = "Running" if st.session_state.get('server_running_flag', False) else "Stopped"
746
+ st.metric("WebSocket Server", ws_status)
747
+ st.metric("Connected Clients", len(connected_clients))
748
+ with col_save:
749
+ if st.button("💾 Save World State to Disk", key="save_world_disk"):
750
+ if save_world_state_to_disk():
751
+ st.success("World state saved!")
752
+ else:
753
+ st.error("Failed to save world state.")
754
+
755
+ # Add file deletion buttons if needed
756
+ st.subheader("Delete Files")
757
+ col_del1, col_del2, col_del3 = st.columns(3)
758
+ with col_del1:
759
+ if st.button("🗑️ Delete Chats (.md)", key="del_chat_md"):
760
+ delete_files([os.path.join(CHAT_DIR, "*.md")])
761
+ st.session_state.chat_history = [] # Clear session history too
762
+ st.rerun()
763
+ with col_del2:
764
+ if st.button("🗑️ Delete Audio (.mp3)", key="del_audio_mp3"):
765
+ delete_files([os.path.join(AUDIO_DIR, "*.mp3"), os.path.join(AUDIO_CACHE_DIR, "*.mp3")])
766
+ st.session_state.audio_cache = {}
767
+ st.rerun()
768
+ # Add more deletion options as needed
769
+
770
+
771
+ # --- Sidebar Controls ---
772
+ with st.sidebar:
773
+ st.header("🏗️ Build Tools")
774
+ st.caption("Select an object to place.")
775
+
776
+ # --- Emoji Buttons for Primitives ---
777
+ cols = st.columns(5) # Adjust grid width as needed
778
+ col_idx = 0
779
+ for emoji, name in PRIMITIVE_MAP.items():
780
+ # Use button click to set selected_object
781
+ button_key = f"primitive_{name}"
782
+ # Highlight selected button? Could use custom CSS or just rely on state.
783
+ button_type = "primary" if st.session_state.selected_object == name else "secondary"
784
+ if cols[col_idx % 5].button(emoji, key=button_key, help=name, type=button_type):
785
+ st.session_state.selected_object = name
786
+ # Update JS selection without full rerun if possible
787
+ try:
788
+ js_update_selection = f"updateSelectedObjectType({json.dumps(name)});"
789
+ streamlit_js_eval(js_code=js_update_selection, key="update_tool_js")
790
+ except Exception as e:
791
+ print(f"Could not push tool update to JS: {e}")
792
+ # Force a rerun to update button styles immediately
793
+ st.rerun()
794
+ col_idx += 1
795
+
796
+ # Button to clear selection
797
+ if st.button("🚫 Clear Tool", key="clear_tool"):
798
+ if st.session_state.selected_object != 'None':
799
+ st.session_state.selected_object = 'None'
800
+ try: # Update JS too
801
+ streamlit_js_eval(js_code=f"updateSelectedObjectType('None');", key="update_tool_js_none")
802
+ except Exception: pass
803
+ st.rerun() # Rerun to update UI
804
+
805
+ st.markdown("---")
806
+ st.header("🗣️ Voice & User")
807
+ # Username/Voice Selection
808
+ new_username = st.selectbox("Change Name/Voice", list(FUN_USERNAMES.keys()), index=list(FUN_USERNAMES.keys()).index(st.session_state.username), key="username_select")
809
+ if new_username != st.session_state.username:
810
+ # Announce name change via WebSocket?
811
+ change_msg = json.dumps({
812
+ "type":"user_rename",
813
+ "payload": {"old_username": st.session_state.username, "new_username": new_username}
814
+ })
815
+ asyncio.run(broadcast_message(change_msg))
816
+ st.session_state.username = new_username
817
+ st.session_state.tts_voice = FUN_USERNAMES[new_username]
818
+ save_username(st.session_state.username)
819
+ st.rerun()
820
+
821
+ # Enable/Disable Audio Toggle
822
+ st.session_state['enable_audio'] = st.toggle("Enable TTS Audio", value=st.session_state.get('enable_audio', True))
823
+
824
+ st.markdown("---")
825
+ st.info("Status and file management in 'Files & Settings' tab.")
826
+
827
+
828
+ # --- Main Execution ---
829
+ if __name__ == "__main__":
830
+ init_session_state()
831
+ # Start WebSocket server in a thread IF it's not already running
832
+ if not st.session_state.get('server_task') or not st.session_state.server_task.is_alive():
833
+ start_websocket_server_thread()
834
+ time.sleep(1) # Give server a moment to start
835
+
836
+ main_interface()