milwright commited on
Commit
e6ff6e7
Β·
1 Parent(s): 50fad13

fix: remove unsupported clear_btn parameter from ChatInterface

Browse files
Files changed (1) hide show
  1. app.py +369 -347
app.py CHANGED
@@ -19,12 +19,12 @@ SPACE_DESCRIPTION = 'Interactive STEM adventure game guide'
19
  DEFAULT_CONFIG = {
20
  'name': SPACE_NAME,
21
  'description': SPACE_DESCRIPTION,
22
- 'system_prompt': "Transform into an interactive co-learning guide who creates Choose Your Own STEM Adventure games featuring historically significant scientific experiments, opening each session with an eye-catching unicode-styled arcade menu presenting 5-6 adventures drawn from Wikipedia's List of Experiments with brief enticing descriptions, then immersing players in vivid historical moments written in second person (e.g. 'You are Galileo Galilei') that establish the year, location, prevailing beliefs, and tensions between established wisdom and emerging observations, presenting 3-4 numbered decision points per stage that reflect authentic scientific choices ranging from experimental design and measurement approaches to strategic decisions about convincing skeptics or timing publications, with each choice meaningfully different and leading to distinct paths forward, then narrating results with sensory details, colleague reactions, and unexpected observations that teach concepts naturally through unfolding drama rather than lectures, always ending scenes with new branching choices that maintain narrative momentum while reinforcing science as an iterative process of hypothesis, testing, and refinement, offering backtracking options to emphasize how so-called failed experiments provide crucial insights, balancing historical accuracy with engaging gameplay that shows how systematic thinking and creative problem-solving combine in scientific breakthroughs.",
23
  'temperature': 0.6,
24
  'max_tokens': 1000,
25
  'model': 'google/gemini-2.0-flash-001',
26
  'api_key_var': 'API_KEY',
27
- 'theme': 'Default',
28
  'grounding_urls': ["https://en.wikipedia.org/wiki/List_of_experiments", "https://en.wikipedia.org/wiki/Scientific_method", "https://en.wikipedia.org/wiki/List_of_experiments#Biology", "https://en.wikipedia.org/wiki/List_of_experiments#Astronomy", "https://en.wikipedia.org/wiki/List_of_experiments#Chemistry", "https://en.wikipedia.org/wiki/List_of_experiments#Physics", "https://en.wikipedia.org/wiki/List_of_experiments#Geology"],
29
  'enable_dynamic_urls': True,
30
  'enable_file_upload': True,
@@ -92,49 +92,56 @@ class ConfigurationManager:
92
  backup_path = os.path.join(self.backup_dir, f"config_{timestamp}.json")
93
 
94
  with open(self.config_path, 'r') as source:
95
- content = source.read()
 
 
96
 
97
- with open(backup_path, 'w') as dest:
98
- dest.write(content)
 
 
 
 
 
 
 
 
 
99
 
100
- # Keep only last 10 backups
101
- backups = sorted([f for f in os.listdir(self.backup_dir) if f.endswith('.json')])
102
- if len(backups) > 10:
103
- for old_backup in backups[:-10]:
104
  os.remove(os.path.join(self.backup_dir, old_backup))
105
  except Exception as e:
106
- print(f"Warning: Could not create backup: {e}")
107
-
108
- def get(self, key: str, default: Any = None) -> Any:
109
- """Get configuration value"""
110
- if self._config is None:
111
- self.load()
112
- return self._config.get(key, default)
113
 
114
 
115
- # Initialize configuration manager
116
  config_manager = ConfigurationManager()
117
  config = config_manager.load()
118
 
119
- # Load configuration values
120
  SPACE_NAME = config.get('name', DEFAULT_CONFIG['name'])
121
  SPACE_DESCRIPTION = config.get('description', DEFAULT_CONFIG['description'])
122
  SYSTEM_PROMPT = config.get('system_prompt', DEFAULT_CONFIG['system_prompt'])
123
- temperature = config.get('temperature', DEFAULT_CONFIG['temperature'])
124
- max_tokens = config.get('max_tokens', DEFAULT_CONFIG['max_tokens'])
125
  MODEL = config.get('model', DEFAULT_CONFIG['model'])
 
126
  THEME = config.get('theme', DEFAULT_CONFIG['theme'])
127
  GROUNDING_URLS = config.get('grounding_urls', DEFAULT_CONFIG['grounding_urls'])
128
  ENABLE_DYNAMIC_URLS = config.get('enable_dynamic_urls', DEFAULT_CONFIG['enable_dynamic_urls'])
129
- ENABLE_FILE_UPLOAD = config.get('enable_file_upload', DEFAULT_CONFIG.get('enable_file_upload', True))
 
 
 
 
130
 
131
- # Environment variables
132
- ACCESS_CODE = os.environ.get("ACCESS_CODE")
133
- API_KEY_VAR = config.get('api_key_var', DEFAULT_CONFIG['api_key_var'])
134
- API_KEY = os.environ.get(API_KEY_VAR, "").strip() or None
 
 
135
 
136
 
137
- # Utility functions
138
  def validate_api_key() -> bool:
139
  """Validate API key configuration"""
140
  if not API_KEY:
@@ -158,104 +165,100 @@ def validate_url_domain(url: str) -> bool:
158
  try:
159
  from urllib.parse import urlparse
160
  parsed = urlparse(url)
161
- return bool(parsed.netloc and '.' in parsed.netloc)
162
  except:
163
  return False
164
 
165
 
166
- def fetch_url_content(url: str, timeout: int = 15, max_chars: int = 4000) -> str:
167
- """Fetch and clean URL content"""
168
- if not validate_url_domain(url):
169
- return f"Invalid URL format: {url}"
170
-
171
  try:
 
 
 
172
  headers = {
173
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
174
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
175
- 'Accept-Language': 'en-US,en;q=0.5',
176
- 'Accept-Encoding': 'gzip, deflate',
177
- 'Connection': 'keep-alive'
178
  }
179
 
180
- response = requests.get(url, timeout=timeout, headers=headers)
181
  response.raise_for_status()
182
- soup = BeautifulSoup(response.content, 'html.parser')
183
-
184
- # Remove non-content elements
185
- for element in soup(["script", "style", "nav", "header", "footer", "aside", "form"]):
186
- element.decompose()
187
-
188
- # Extract main content
189
- main_content = (
190
- soup.find('main') or
191
- soup.find('article') or
192
- soup.find('div', class_=lambda x: bool(x and 'content' in x.lower())) or
193
- soup
194
- )
195
- text = main_content.get_text()
196
 
197
- # Clean text
198
- lines = (line.strip() for line in text.splitlines())
199
- chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
200
- text = ' '.join(chunk for chunk in chunks if chunk and len(chunk) > 2)
201
 
202
- # Smart truncation
203
- if len(text) > max_chars:
204
- truncated = text[:max_chars]
205
- last_period = truncated.rfind('.')
206
- if last_period > max_chars * 0.8:
207
- text = truncated[:last_period + 1]
208
- else:
209
- text = truncated + "..."
 
 
 
 
 
 
 
 
 
 
210
 
211
- return text if text.strip() else "No readable content found at this URL"
 
 
 
 
212
 
 
 
 
213
  except requests.exceptions.Timeout:
214
- return f"Timeout error fetching {url} ({timeout}s limit exceeded)"
215
  except requests.exceptions.RequestException as e:
216
- return f"Error fetching {url}: {str(e)}"
217
  except Exception as e:
218
- return f"Error processing content from {url}: {str(e)}"
219
 
220
 
221
  def extract_urls_from_text(text: str) -> List[str]:
222
- """Extract valid URLs from text"""
223
- if not text:
224
- return []
225
-
226
- url_pattern = r'https?://[^\s<>"{}|\\^`\[\]"]+'
227
  urls = re.findall(url_pattern, text)
228
-
229
- validated_urls = []
230
- for url in urls:
231
- url = url.rstrip('.,!?;:')
232
- if validate_url_domain(url) and len(url) > 10:
233
- validated_urls.append(url)
234
-
235
- return validated_urls
236
 
237
 
238
- def process_uploaded_file(file_path: str, max_chars: int = 8000) -> str:
239
- """Process uploaded file with Gradio best practices"""
240
- if not file_path or not os.path.exists(file_path):
241
- return "❌ File not found"
242
-
243
  try:
 
 
 
244
  file_size = os.path.getsize(file_path)
245
  file_name = os.path.basename(file_path)
246
- _, ext = os.path.splitext(file_path.lower())
 
247
 
248
- # Text file extensions
249
- text_extensions = {
250
- '.txt', '.md', '.markdown', '.rst',
251
- '.py', '.js', '.jsx', '.ts', '.tsx', '.json', '.yaml', '.yml',
252
- '.html', '.htm', '.xml', '.css', '.scss',
253
- '.java', '.c', '.cpp', '.h', '.cs', '.go', '.rs',
254
- '.sh', '.bash', '.log', '.csv', '.sql'
255
- }
 
 
 
 
 
 
 
256
 
257
- if ext in text_extensions:
258
- with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
 
 
259
  content = f.read(max_chars)
260
  if len(content) == max_chars:
261
  content += "\n... [truncated]"
@@ -288,31 +291,26 @@ def get_grounding_context() -> str:
288
  try:
289
  urls = json.loads(urls)
290
  except:
291
- urls = []
292
 
293
  if not urls:
294
  return ""
295
 
296
- # Check cache
297
- cache_key = tuple(sorted([url for url in urls if url and url.strip()]))
298
- if cache_key in _url_content_cache:
299
- return _url_content_cache[cache_key]
300
 
301
- # Fetch content
302
- context_parts = []
303
- for i, url in enumerate(urls, 1):
304
- if url and url.strip():
305
- content = fetch_url_content(url.strip())
306
- priority = "PRIMARY" if i <= 2 else "SECONDARY"
307
- context_parts.append(f"[{priority}] Context from URL {i} ({url}):\n{content}")
308
-
309
- result = ""
310
- if context_parts:
311
- result = "\n\n" + "\n\n".join(context_parts) + "\n\n"
312
 
313
- # Cache result
314
- _url_content_cache[cache_key] = result
315
- return result
316
 
317
 
318
  def export_conversation_to_markdown(history: List[Dict[str, str]]) -> str:
@@ -330,23 +328,21 @@ Model: {MODEL}
330
  """
331
 
332
  message_count = 0
333
- for message in history:
334
- if isinstance(message, dict):
335
- role = message.get('role', 'unknown')
336
- content = message.get('content', '')
337
-
338
- if role == 'user':
339
- message_count += 1
340
- markdown_content += f"## User Message {message_count}\n\n{content}\n\n"
341
- elif role == 'assistant':
342
- markdown_content += f"## Assistant Response {message_count}\n\n{content}\n\n---\n\n"
343
 
344
  return markdown_content
345
 
346
 
347
- def generate_response(message: str, history: List[Dict[str, str]], files: Optional[List] = None) -> str:
348
- """Generate response using OpenRouter API with file support"""
349
-
350
  # API key validation
351
  if not API_KEY:
352
  return f"""πŸ”‘ **API Key Required**
@@ -367,15 +363,15 @@ Get your API key at: https://openrouter.ai/keys"""
367
  file_contents = []
368
  file_names = []
369
 
370
- # Handle both single file and list of files
371
- file_list = files if isinstance(files, list) else [files]
372
-
373
- for file_obj in file_list:
374
- if file_obj is not None:
 
 
375
  try:
376
- # Get file path
377
- file_path = file_obj.name if hasattr(file_obj, 'name') else str(file_obj)
378
- content = process_uploaded_file(file_path)
379
  file_contents.append(content)
380
  file_names.append(os.path.basename(file_path))
381
  print(f"πŸ“„ Processed file: {os.path.basename(file_path)}")
@@ -393,149 +389,127 @@ Get your API key at: https://openrouter.ai/keys"""
393
  if ENABLE_DYNAMIC_URLS:
394
  urls_in_message = extract_urls_from_text(message)
395
  if urls_in_message:
 
 
396
  for url in urls_in_message[:3]: # Limit to 3 URLs
397
  content = fetch_url_content(url)
398
- grounding_context += f"\n\n[DYNAMIC] Context from {url}:\n{content}"
399
-
400
- # Build system prompt
401
- enhanced_system_prompt = SYSTEM_PROMPT + grounding_context + file_context
402
 
403
  # Build messages
404
- messages = [{"role": "system", "content": enhanced_system_prompt}]
405
-
406
- # Add history (supports both dict and legacy tuple format)
407
- for chat in history:
408
- if isinstance(chat, dict):
409
- messages.append(chat)
410
- elif isinstance(chat, (list, tuple)) and len(chat) >= 2:
411
- messages.append({"role": "user", "content": chat[0]})
412
- messages.append({"role": "assistant", "content": chat[1]})
 
 
 
 
 
 
 
 
 
 
 
413
 
414
- # Add current message
415
- user_message = message + file_notification
416
- messages.append({"role": "user", "content": user_message})
417
-
418
- # Make API request
419
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  response = requests.post(
421
- url="https://openrouter.ai/api/v1/chat/completions",
422
- headers={
423
- "Authorization": f"Bearer {API_KEY}",
424
- "Content-Type": "application/json",
425
- "HTTP-Referer": "https://huggingface.co",
426
- "X-Title": "HuggingFace Space"
427
- },
428
- json={
429
- "model": MODEL,
430
- "messages": messages,
431
- "temperature": temperature,
432
- "max_tokens": max_tokens
433
- },
434
  timeout=30
435
  )
436
 
437
  if response.status_code == 200:
438
  result = response.json()
439
- content = result['choices'][0]['message']['content']
440
- return content
 
 
 
 
 
441
  else:
442
- return f"❌ API Error {response.status_code}: {response.text[:500]}"
 
 
443
 
444
  except requests.exceptions.Timeout:
445
- return "⏰ Request timeout (30s limit). Try a shorter message or different model."
446
- except requests.exceptions.ConnectionError:
447
- return "🌐 Connection error. Check your internet connection and try again."
448
  except Exception as e:
449
  return f"❌ Error: {str(e)}"
450
 
451
 
452
- # Access control state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  class AccessControl:
454
- """Manage access control state"""
455
  def __init__(self):
456
- self.granted = ACCESS_CODE is None
457
 
458
- def verify(self, code: str) -> Tuple[bool, str]:
459
  """Verify access code"""
460
- if ACCESS_CODE is None:
461
- self.granted = True
462
- return True, "No access code required"
463
-
464
- if code == ACCESS_CODE:
465
- self.granted = True
466
- return True, "βœ… Access granted!"
467
- else:
468
- self.granted = False
469
- return False, "❌ Invalid access code. Please try again."
470
 
471
  def is_granted(self) -> bool:
472
  """Check if access is granted"""
473
- return self.granted
474
 
475
 
476
- # Initialize access control
477
  access_control = AccessControl()
478
 
479
 
480
- def protected_generate_response(message: str, history: List[Dict[str, str]], files: Optional[List] = None) -> str:
481
- """Protected response function that checks access"""
482
- if not access_control.is_granted():
483
- return "Please enter the access code to continue."
484
- return generate_response(message, history, files)
485
-
486
-
487
- # Chat history for export
488
- chat_history_store = []
489
-
490
-
491
- def store_and_generate_response(message: str, history: List[Dict[str, str]], files: Optional[List] = None) -> str:
492
- """Generate response and store history for export"""
493
- global chat_history_store
494
-
495
- # Generate response
496
- response = protected_generate_response(message, history, files)
497
-
498
- # Update stored history
499
- chat_history_store = []
500
- for exchange in history:
501
- if isinstance(exchange, dict):
502
- chat_history_store.append(exchange)
503
- elif isinstance(exchange, (list, tuple)) and len(exchange) >= 2:
504
- chat_history_store.append({"role": "user", "content": exchange[0]})
505
- chat_history_store.append({"role": "assistant", "content": exchange[1]})
506
-
507
- # Add current exchange
508
- chat_history_store.append({"role": "user", "content": message})
509
- chat_history_store.append({"role": "assistant", "content": response})
510
-
511
- return response
512
-
513
-
514
- def verify_hf_token_access() -> Tuple[bool, str]:
515
- """Verify HuggingFace token access"""
516
- hf_token = os.environ.get("HF_TOKEN")
517
- space_id = os.environ.get("SPACE_ID")
518
-
519
- if not hf_token or not space_id:
520
- return False, "Missing HF_TOKEN or SPACE_ID"
521
-
522
- try:
523
- from huggingface_hub import HfApi
524
- api = HfApi(token=hf_token)
525
- api.space_info(space_id)
526
- return True, "Authenticated successfully"
527
- except Exception as e:
528
- return False, f"Authentication failed: {str(e)}"
529
-
530
-
531
- # Create main interface using modern Gradio patterns
532
- def create_interface():
533
- """Create the Gradio interface with modern patterns"""
534
-
535
- # Get theme
536
- theme = AVAILABLE_THEMES.get(THEME, gr.themes.Default())
537
-
538
- # Validate API key on startup
539
  API_KEY_VALID = validate_api_key()
540
 
541
  # Check HuggingFace access
@@ -555,40 +529,39 @@ def create_interface():
555
  if ACCESS_CODE and not access_control.is_granted():
556
  # Show access code input
557
  gr.Markdown("### πŸ” Access Required")
558
- gr.Markdown("Please enter the access code:")
559
 
560
  with gr.Row():
561
  access_input = gr.Textbox(
562
  label="Access Code",
563
- placeholder="Enter access code...",
564
  type="password",
 
565
  scale=3
566
  )
567
  access_btn = gr.Button("Submit", variant="primary", scale=1)
568
 
569
- access_status = gr.Markdown()
570
 
571
- def verify_and_refresh(code):
572
- granted, message = access_control.verify(code)
573
- if granted:
574
- # Force refresh to show chat interface
575
- return {access_status: message}, gr.Tabs(selected=0)
576
  else:
577
- return {access_status: message}, gr.update()
578
 
579
  access_btn.click(
580
- verify_and_refresh,
581
  inputs=[access_input],
582
- outputs=[access_status, main_tabs]
583
  )
584
 
585
  access_input.submit(
586
- verify_and_refresh,
587
  inputs=[access_input],
588
- outputs=[access_status, main_tabs]
589
  )
590
  else:
591
- # Show chat interface
 
592
  examples = config.get('examples', [])
593
  if isinstance(examples, str):
594
  try:
@@ -603,17 +576,17 @@ def create_interface():
603
  elif examples:
604
  formatted_examples = examples
605
 
606
- # Create additional inputs list
607
- additional_inputs = []
608
  if ENABLE_FILE_UPLOAD:
609
- additional_inputs.append(
610
  gr.File(
611
  label="πŸ“Ž Upload Files",
612
- file_types=None,
613
  file_count="multiple",
 
614
  visible=True
615
  )
616
- )
617
 
618
  # Create chat interface
619
  chat_interface = gr.ChatInterface(
@@ -624,7 +597,6 @@ def create_interface():
624
  type="messages",
625
  additional_inputs=additional_inputs if additional_inputs else None,
626
  submit_btn="Send",
627
- clear_btn="Clear",
628
  undo_btn=None,
629
  retry_btn=None
630
  )
@@ -637,23 +609,50 @@ def create_interface():
637
  size="sm"
638
  )
639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  # Export handler
641
- def prepare_export():
642
- if not chat_history_store:
 
643
  return None
644
 
645
- content = export_conversation_to_markdown(chat_history_store)
646
-
647
- # Create filename
648
- space_name_safe = re.sub(r'[^a-zA-Z0-9]+', '_', SPACE_NAME).lower()
649
  timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
650
- filename = f"{space_name_safe}_conversation_{timestamp}.md"
651
 
652
- # Save to temp file
653
- temp_path = Path(tempfile.gettempdir()) / filename
654
- temp_path.write_text(content, encoding='utf-8')
655
-
656
- return str(temp_path)
657
 
658
  export_btn.click(
659
  prepare_export,
@@ -692,25 +691,43 @@ def create_interface():
692
  show_label=True
693
  )
694
 
695
- # Simplified configuration form
696
  with gr.Group():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  edit_system_prompt = gr.Textbox(
698
  label="System Prompt",
699
  value=config.get('system_prompt', ''),
700
  lines=5
701
  )
702
 
703
- edit_model = gr.Dropdown(
704
- label="Model",
705
- choices=[
706
- "google/gemini-2.0-flash-001",
707
- "anthropic/claude-3.5-sonnet",
708
- "openai/gpt-4o-mini",
709
- "meta-llama/llama-3.1-70b-instruct"
710
- ],
711
- value=config.get('model', MODEL)
712
- )
713
-
714
  with gr.Row():
715
  edit_temperature = gr.Slider(
716
  label="Temperature",
@@ -732,88 +749,93 @@ def create_interface():
732
  value='\n'.join(config.get('examples', [])),
733
  lines=3
734
  )
 
 
 
 
 
 
 
735
 
736
- # Configuration actions
737
- with gr.Row():
738
- save_btn = gr.Button("πŸ’Ύ Save Configuration", variant="primary")
739
- reset_btn = gr.Button("↩️ Reset to Defaults", variant="secondary")
740
-
741
- config_status = gr.Markdown()
742
-
743
- # Save handler
744
  def save_configuration(system_prompt, model, temp, tokens, examples):
 
745
  try:
746
- # Update configuration
747
- new_config = config.copy()
748
- new_config.update({
749
  'system_prompt': system_prompt,
750
  'model': model,
751
  'temperature': temp,
752
  'max_tokens': int(tokens),
753
  'examples': [ex.strip() for ex in examples.split('\n') if ex.strip()],
754
- 'last_modified': datetime.now().isoformat()
755
  })
756
 
757
- # Save configuration
758
- if config_manager.save(new_config):
759
- # Try to commit to HuggingFace
760
- try:
761
- from huggingface_hub import HfApi, CommitOperationAdd
762
- hf_token = os.environ.get("HF_TOKEN")
763
- space_id = os.environ.get("SPACE_ID")
764
-
765
- if hf_token and space_id:
766
- api = HfApi(token=hf_token)
767
- operations = [
768
- CommitOperationAdd(
769
- path_or_fileobj="config.json",
770
- path_in_repo="config.json"
771
- )
772
- ]
773
-
774
- api.create_commit(
775
- repo_id=space_id,
776
- operations=operations,
777
- commit_message="Update configuration",
778
- repo_type="space"
779
  )
780
-
781
- return "βœ… Configuration saved and committed. Space will restart automatically."
782
- except Exception as e:
783
- print(f"Could not commit to HF: {e}")
784
- return "βœ… Configuration saved locally. Manual restart required."
785
  else:
786
- return "❌ Error saving configuration"
787
-
788
  except Exception as e:
789
  return f"❌ Error: {str(e)}"
790
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  save_btn.click(
792
  save_configuration,
793
  inputs=[edit_system_prompt, edit_model, edit_temperature, edit_max_tokens, edit_examples],
794
  outputs=[config_status]
795
  )
796
 
797
- # Reset handler
798
- def reset_configuration():
799
- return [
800
- gr.update(value=DEFAULT_CONFIG['system_prompt']),
801
- gr.update(value=DEFAULT_CONFIG['model']),
802
- gr.update(value=DEFAULT_CONFIG['temperature']),
803
- gr.update(value=DEFAULT_CONFIG['max_tokens']),
804
- gr.update(value='\n'.join(DEFAULT_CONFIG['examples'])),
805
- "Reset to default values"
806
- ]
807
-
808
  reset_btn.click(
809
  reset_configuration,
810
  outputs=[edit_system_prompt, edit_model, edit_temperature, edit_max_tokens, edit_examples, config_status]
811
  )
 
 
 
 
812
 
813
  return demo
814
 
815
 
816
- # Create and launch the interface
817
  if __name__ == "__main__":
 
818
  demo = create_interface()
819
- demo.launch()
 
 
 
 
 
 
19
  DEFAULT_CONFIG = {
20
  'name': SPACE_NAME,
21
  'description': SPACE_DESCRIPTION,
22
+ 'system_prompt': 'Transform into an interactive co-learning guide who creates Choose Your Own STEM Adventure games featuring historically significant scientific experiments, opening each session with an eye-catching unicode-styled arcade menu presenting 5-6 adventures drawn from Wikipedia\'s List of Experiments with brief enticing descriptions, then immersing players in vivid historical moments written in second person (e.g. \'You are Galileo Galilei\') that establish the year, location, prevailing beliefs, and tensions between established wisdom and emerging observations, presenting 3-4 numbered decision points per stage that reflect authentic scientific choices ranging from experimental design and measurement approaches to strategic decisions about convincing skeptics or timing publications, with each choice meaningfully different and leading to distinct paths forward, then narrating results with sensory details, colleague reactions, and unexpected observations that teach concepts naturally through unfolding drama rather than lectures, always ending scenes with new branching choices that maintain narrative momentum while reinforcing science as an iterative process of hypothesis, testing, and refinement, offering backtracking options to emphasize how so-called failed experiments provide crucial insights, balancing historical accuracy with engaging gameplay that shows how systematic thinking and creative problem-solving combine in scientific breakthroughs.',
23
  'temperature': 0.6,
24
  'max_tokens': 1000,
25
  'model': 'google/gemini-2.0-flash-001',
26
  'api_key_var': 'API_KEY',
27
+ 'theme': 'Soft',
28
  'grounding_urls': ["https://en.wikipedia.org/wiki/List_of_experiments", "https://en.wikipedia.org/wiki/Scientific_method", "https://en.wikipedia.org/wiki/List_of_experiments#Biology", "https://en.wikipedia.org/wiki/List_of_experiments#Astronomy", "https://en.wikipedia.org/wiki/List_of_experiments#Chemistry", "https://en.wikipedia.org/wiki/List_of_experiments#Physics", "https://en.wikipedia.org/wiki/List_of_experiments#Geology"],
29
  'enable_dynamic_urls': True,
30
  'enable_file_upload': True,
 
92
  backup_path = os.path.join(self.backup_dir, f"config_{timestamp}.json")
93
 
94
  with open(self.config_path, 'r') as source:
95
+ config_data = json.load(source)
96
+ with open(backup_path, 'w') as backup:
97
+ json.dump(config_data, backup, indent=2)
98
 
99
+ self._cleanup_old_backups()
100
+ except Exception as e:
101
+ print(f"⚠️ Error creating backup: {e}")
102
+
103
+ def _cleanup_old_backups(self, keep=10):
104
+ """Keep only the most recent backups"""
105
+ try:
106
+ backups = sorted([
107
+ f for f in os.listdir(self.backup_dir)
108
+ if f.startswith('config_') and f.endswith('.json')
109
+ ])
110
 
111
+ if len(backups) > keep:
112
+ for old_backup in backups[:-keep]:
 
 
113
  os.remove(os.path.join(self.backup_dir, old_backup))
114
  except Exception as e:
115
+ print(f"⚠️ Error cleaning up backups: {e}")
 
 
 
 
 
 
116
 
117
 
118
+ # Global configuration instance
119
  config_manager = ConfigurationManager()
120
  config = config_manager.load()
121
 
122
+ # Extract configuration values
123
  SPACE_NAME = config.get('name', DEFAULT_CONFIG['name'])
124
  SPACE_DESCRIPTION = config.get('description', DEFAULT_CONFIG['description'])
125
  SYSTEM_PROMPT = config.get('system_prompt', DEFAULT_CONFIG['system_prompt'])
 
 
126
  MODEL = config.get('model', DEFAULT_CONFIG['model'])
127
+ API_KEY_VAR = config.get('api_key_var', DEFAULT_CONFIG['api_key_var'])
128
  THEME = config.get('theme', DEFAULT_CONFIG['theme'])
129
  GROUNDING_URLS = config.get('grounding_urls', DEFAULT_CONFIG['grounding_urls'])
130
  ENABLE_DYNAMIC_URLS = config.get('enable_dynamic_urls', DEFAULT_CONFIG['enable_dynamic_urls'])
131
+ ENABLE_FILE_UPLOAD = config.get('enable_file_upload', DEFAULT_CONFIG['enable_file_upload'])
132
+ ACCESS_CODE = os.environ.get('ACCESS_CODE', '')
133
+ API_KEY = os.environ.get(API_KEY_VAR, '')
134
+ HF_TOKEN = os.environ.get('HF_TOKEN', '')
135
+ SPACE_ID = os.environ.get('SPACE_ID', '')
136
 
137
+ # Get theme instance
138
+ theme = AVAILABLE_THEMES.get(THEME, gr.themes.Default())
139
+
140
+ # Configuration defaults from loaded config
141
+ temperature = config.get('temperature', DEFAULT_CONFIG['temperature'])
142
+ max_tokens = config.get('max_tokens', DEFAULT_CONFIG['max_tokens'])
143
 
144
 
 
145
  def validate_api_key() -> bool:
146
  """Validate API key configuration"""
147
  if not API_KEY:
 
165
  try:
166
  from urllib.parse import urlparse
167
  parsed = urlparse(url)
168
+ return bool(parsed.netloc and parsed.scheme in ['http', 'https'])
169
  except:
170
  return False
171
 
172
 
173
+ def fetch_url_content(url: str) -> str:
174
+ """Fetch and convert URL content to text"""
 
 
 
175
  try:
176
+ if not validate_url_domain(url):
177
+ return f"❌ Invalid URL format: {url}"
178
+
179
  headers = {
180
+ 'User-Agent': 'Mozilla/5.0 (compatible; HuggingFace-Space/1.0)'
 
 
 
 
181
  }
182
 
183
+ response = requests.get(url, headers=headers, timeout=5)
184
  response.raise_for_status()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
+ content_type = response.headers.get('content-type', '').lower()
 
 
 
187
 
188
+ if 'text/html' in content_type:
189
+ soup = BeautifulSoup(response.text, 'html.parser')
190
+
191
+ # Remove script and style elements
192
+ for script in soup(["script", "style"]):
193
+ script.extract()
194
+
195
+ # Get text content
196
+ text = soup.get_text(separator=' ', strip=True)
197
+
198
+ # Clean up whitespace
199
+ text = ' '.join(text.split())
200
+
201
+ # Limit content length
202
+ if len(text) > 3000:
203
+ text = text[:3000] + "... [truncated]"
204
+
205
+ return f"πŸ“„ Content from {url}:\n{text}\n"
206
 
207
+ elif any(ct in content_type for ct in ['text/plain', 'application/json']):
208
+ text = response.text
209
+ if len(text) > 3000:
210
+ text = text[:3000] + "... [truncated]"
211
+ return f"πŸ“„ Content from {url}:\n{text}\n"
212
 
213
+ else:
214
+ return f"⚠️ Unsupported content type at {url}: {content_type}"
215
+
216
  except requests.exceptions.Timeout:
217
+ return f"⏱️ Timeout accessing {url}"
218
  except requests.exceptions.RequestException as e:
219
+ return f"❌ Error accessing {url}: {str(e)}"
220
  except Exception as e:
221
+ return f"❌ Unexpected error with {url}: {str(e)}"
222
 
223
 
224
  def extract_urls_from_text(text: str) -> List[str]:
225
+ """Extract URLs from message text"""
226
+ url_pattern = r'https?://[^\s<>"{}|\\^`\[\]]+(?:\.[^\s<>"{}|\\^`\[\]])*'
 
 
 
227
  urls = re.findall(url_pattern, text)
228
+ return [url.rstrip('.,;:)?!') for url in urls]
 
 
 
 
 
 
 
229
 
230
 
231
+ def process_file_upload(file_path: str) -> str:
232
+ """Process uploaded file and extract text content"""
 
 
 
233
  try:
234
+ if not os.path.exists(file_path):
235
+ return "❌ File not found"
236
+
237
  file_size = os.path.getsize(file_path)
238
  file_name = os.path.basename(file_path)
239
+ _, ext = os.path.splitext(file_name)
240
+ ext = ext.lower()
241
 
242
+ # Text files
243
+ if ext in {'.txt', '.md', '.py', '.js', '.html', '.css', '.json', '.csv', '.xml', '.yaml', '.yml'}:
244
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
245
+ content = f.read(8000) # Limit to 8KB
246
+ if len(content) == 8000:
247
+ content += "\n... [truncated]"
248
+ return f"πŸ“„ **{file_name}** ({file_size:,} bytes)\n```{ext[1:]}\n{content}\n```"
249
+
250
+ # Code files
251
+ elif ext in {'.java', '.cpp', '.c', '.h', '.hpp', '.cs', '.rb', '.go', '.rs', '.swift', '.kt'}:
252
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
253
+ content = f.read(8000)
254
+ if len(content) == 8000:
255
+ content += "\n... [truncated]"
256
+ return f"πŸ“„ **{file_name}** ({file_size:,} bytes)\n```{ext[1:]}\n{content}\n```"
257
 
258
+ # Config files
259
+ elif ext in {'.ini', '.conf', '.toml', '.env'}:
260
+ max_chars = 5000
261
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
262
  content = f.read(max_chars)
263
  if len(content) == max_chars:
264
  content += "\n... [truncated]"
 
291
  try:
292
  urls = json.loads(urls)
293
  except:
294
+ return ""
295
 
296
  if not urls:
297
  return ""
298
 
299
+ context_parts = ["πŸ“š **Reference Context:**\n"]
 
 
 
300
 
301
+ for i, url in enumerate(urls[:2], 1): # Primary URLs only
302
+ if url in _url_content_cache:
303
+ content = _url_content_cache[url]
304
+ else:
305
+ content = fetch_url_content(url)
306
+ _url_content_cache[url] = content
307
+
308
+ if not content.startswith("❌") and not content.startswith("⏱️"):
309
+ context_parts.append(f"\n**Source {i}:** {content}")
 
 
310
 
311
+ if len(context_parts) > 1:
312
+ return "\n".join(context_parts)
313
+ return ""
314
 
315
 
316
  def export_conversation_to_markdown(history: List[Dict[str, str]]) -> str:
 
328
  """
329
 
330
  message_count = 0
331
+ for msg in history:
332
+ role = msg.get('role', 'unknown')
333
+ content = msg.get('content', '')
334
+ message_count += 1
335
+
336
+ if role == 'user':
337
+ markdown_content += f"### πŸ§‘ User Message {message_count}\n{content}\n\n"
338
+ elif role == 'assistant':
339
+ markdown_content += f"### πŸ€– Assistant Response {message_count}\n{content}\n\n"
 
340
 
341
  return markdown_content
342
 
343
 
344
+ def generate_response(message: str, history: List[Dict[str, str]], files: Optional[List[str]] = None) -> str:
345
+ """Generate AI response"""
 
346
  # API key validation
347
  if not API_KEY:
348
  return f"""πŸ”‘ **API Key Required**
 
363
  file_contents = []
364
  file_names = []
365
 
366
+ for file_info in files:
367
+ if isinstance(file_info, dict):
368
+ file_path = file_info.get('path', file_info.get('name', ''))
369
+ else:
370
+ file_path = str(file_info)
371
+
372
+ if file_path and os.path.exists(file_path):
373
  try:
374
+ content = process_file_upload(file_path)
 
 
375
  file_contents.append(content)
376
  file_names.append(os.path.basename(file_path))
377
  print(f"πŸ“„ Processed file: {os.path.basename(file_path)}")
 
389
  if ENABLE_DYNAMIC_URLS:
390
  urls_in_message = extract_urls_from_text(message)
391
  if urls_in_message:
392
+ print(f"πŸ”— Found {len(urls_in_message)} URLs in message")
393
+ dynamic_context = "\nπŸ“Ž **Dynamic Context:**\n"
394
  for url in urls_in_message[:3]: # Limit to 3 URLs
395
  content = fetch_url_content(url)
396
+ if not content.startswith("❌"):
397
+ dynamic_context += f"\n{content}"
398
+ grounding_context += dynamic_context
 
399
 
400
  # Build messages
401
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
402
+
403
+ # Add conversation history
404
+ for msg in history:
405
+ messages.append({
406
+ "role": msg['role'],
407
+ "content": msg['content']
408
+ })
409
+
410
+ # Add current message with context
411
+ full_message = message
412
+ if grounding_context:
413
+ full_message = f"{grounding_context}\n\n{message}"
414
+ if file_context:
415
+ full_message = f"{file_context}\n\n{full_message}"
416
+
417
+ messages.append({
418
+ "role": "user",
419
+ "content": full_message
420
+ })
421
 
 
 
 
 
 
422
  try:
423
+ # Make API request
424
+ headers = {
425
+ "Authorization": f"Bearer {API_KEY}",
426
+ "Content-Type": "application/json",
427
+ "HTTP-Referer": f"https://huggingface.co/spaces/{SPACE_ID}" if SPACE_ID else "https://huggingface.co",
428
+ "X-Title": SPACE_NAME
429
+ }
430
+
431
+ data = {
432
+ "model": MODEL,
433
+ "messages": messages,
434
+ "temperature": temperature,
435
+ "max_tokens": max_tokens,
436
+ "stream": False
437
+ }
438
+
439
  response = requests.post(
440
+ "https://openrouter.ai/api/v1/chat/completions",
441
+ headers=headers,
442
+ json=data,
 
 
 
 
 
 
 
 
 
 
443
  timeout=30
444
  )
445
 
446
  if response.status_code == 200:
447
  result = response.json()
448
+ ai_response = result['choices'][0]['message']['content']
449
+
450
+ # Add file notification if files were uploaded
451
+ if file_notification:
452
+ ai_response += file_notification
453
+
454
+ return ai_response
455
  else:
456
+ error_data = response.json()
457
+ error_message = error_data.get('error', {}).get('message', 'Unknown error')
458
+ return f"❌ API Error ({response.status_code}): {error_message}"
459
 
460
  except requests.exceptions.Timeout:
461
+ return "⏱️ Request timed out. Please try again."
 
 
462
  except Exception as e:
463
  return f"❌ Error: {str(e)}"
464
 
465
 
466
+ def verify_hf_token_access() -> Tuple[bool, str]:
467
+ """Verify HuggingFace token and access"""
468
+ if not HF_TOKEN:
469
+ return False, "No HF_TOKEN found"
470
+
471
+ if not SPACE_ID:
472
+ return False, "No SPACE_ID found - running locally?"
473
+
474
+ try:
475
+ headers = {"Authorization": f"Bearer {HF_TOKEN}"}
476
+ response = requests.get(
477
+ f"https://huggingface.co/api/spaces/{SPACE_ID}",
478
+ headers=headers,
479
+ timeout=5
480
+ )
481
+ if response.status_code == 200:
482
+ return True, f"HF Token valid for {SPACE_ID}"
483
+ else:
484
+ return False, f"HF Token invalid or no access to {SPACE_ID}"
485
+ except Exception as e:
486
+ return False, f"Error verifying HF token: {str(e)}"
487
+
488
+
489
+ # Access control
490
  class AccessControl:
491
+ """Handle access code verification"""
492
  def __init__(self):
493
+ self._granted = not bool(ACCESS_CODE)
494
 
495
+ def verify(self, code: str) -> bool:
496
  """Verify access code"""
497
+ if not ACCESS_CODE:
498
+ return True
499
+ self._granted = (code == ACCESS_CODE)
500
+ return self._granted
 
 
 
 
 
 
501
 
502
  def is_granted(self) -> bool:
503
  """Check if access is granted"""
504
+ return self._granted
505
 
506
 
 
507
  access_control = AccessControl()
508
 
509
 
510
+ def create_interface() -> gr.Blocks:
511
+ """Create the main Gradio interface"""
512
+ # API key validation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  API_KEY_VALID = validate_api_key()
514
 
515
  # Check HuggingFace access
 
529
  if ACCESS_CODE and not access_control.is_granted():
530
  # Show access code input
531
  gr.Markdown("### πŸ” Access Required")
532
+ gr.Markdown("This space requires an access code.")
533
 
534
  with gr.Row():
535
  access_input = gr.Textbox(
536
  label="Access Code",
 
537
  type="password",
538
+ placeholder="Enter access code...",
539
  scale=3
540
  )
541
  access_btn = gr.Button("Submit", variant="primary", scale=1)
542
 
543
+ access_status = gr.Markdown("")
544
 
545
+ def check_access(code):
546
+ if access_control.verify(code):
547
+ return "βœ… Access granted! Please refresh the page."
 
 
548
  else:
549
+ return "❌ Invalid access code."
550
 
551
  access_btn.click(
552
+ check_access,
553
  inputs=[access_input],
554
+ outputs=[access_status]
555
  )
556
 
557
  access_input.submit(
558
+ check_access,
559
  inputs=[access_input],
560
+ outputs=[access_status]
561
  )
562
  else:
563
+ # Show main chat interface
564
+ # Process examples
565
  examples = config.get('examples', [])
566
  if isinstance(examples, str):
567
  try:
 
576
  elif examples:
577
  formatted_examples = examples
578
 
579
+ # Create additional inputs if file upload is enabled
580
+ additional_inputs = None
581
  if ENABLE_FILE_UPLOAD:
582
+ additional_inputs = [
583
  gr.File(
584
  label="πŸ“Ž Upload Files",
 
585
  file_count="multiple",
586
+ file_types=["text", "image", "pdf"],
587
  visible=True
588
  )
589
+ ]
590
 
591
  # Create chat interface
592
  chat_interface = gr.ChatInterface(
 
597
  type="messages",
598
  additional_inputs=additional_inputs if additional_inputs else None,
599
  submit_btn="Send",
 
600
  undo_btn=None,
601
  retry_btn=None
602
  )
 
609
  size="sm"
610
  )
611
 
612
+ # Track current history for export
613
+ current_history = gr.State([])
614
+
615
+ def store_and_generate_response(message, history, *args):
616
+ """Store history and generate response"""
617
+ # Convert history to expected format
618
+ formatted_history = []
619
+ for h in history:
620
+ if isinstance(h, dict):
621
+ formatted_history.append(h)
622
+ elif isinstance(h, (list, tuple)) and len(h) == 2:
623
+ formatted_history.append({"role": "user", "content": h[0]})
624
+ if h[1]:
625
+ formatted_history.append({"role": "assistant", "content": h[1]})
626
+
627
+ # Handle file uploads if enabled
628
+ files = None
629
+ if ENABLE_FILE_UPLOAD and args:
630
+ files = args[0]
631
+
632
+ response = generate_response(message, formatted_history, files)
633
+
634
+ # Update stored history
635
+ new_history = formatted_history + [
636
+ {"role": "user", "content": message},
637
+ {"role": "assistant", "content": response}
638
+ ]
639
+ current_history.value = new_history
640
+
641
+ return response
642
+
643
  # Export handler
644
+ def prepare_export(history):
645
+ """Prepare conversation for export"""
646
+ if not history:
647
  return None
648
 
649
+ content = export_conversation_to_markdown(current_history.value or history)
 
 
 
650
  timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
651
+ filename = f"conversation_{timestamp}.md"
652
 
653
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.md') as f:
654
+ f.write(content)
655
+ return f.name
 
 
656
 
657
  export_btn.click(
658
  prepare_export,
 
691
  show_label=True
692
  )
693
 
694
+ # Configuration editor
695
  with gr.Group():
696
+ gr.Markdown("### βš™οΈ Configuration Editor")
697
+
698
+ # Basic settings
699
+ with gr.Row():
700
+ edit_name = gr.Textbox(
701
+ label="Space Name",
702
+ value=config.get('name', ''),
703
+ max_lines=1
704
+ )
705
+ edit_model = gr.Dropdown(
706
+ label="Model",
707
+ choices=[
708
+ "google/gemini-2.0-flash-001",
709
+ "anthropic/claude-3.5-sonnet",
710
+ "anthropic/claude-3.5-haiku",
711
+ "openai/gpt-4o-mini",
712
+ "meta-llama/llama-3.1-70b-instruct",
713
+ "custom"
714
+ ],
715
+ value=config.get('model', '')
716
+ )
717
+
718
+ edit_description = gr.Textbox(
719
+ label="Description",
720
+ value=config.get('description', ''),
721
+ max_lines=2
722
+ )
723
+
724
  edit_system_prompt = gr.Textbox(
725
  label="System Prompt",
726
  value=config.get('system_prompt', ''),
727
  lines=5
728
  )
729
 
730
+ # Advanced settings
 
 
 
 
 
 
 
 
 
 
731
  with gr.Row():
732
  edit_temperature = gr.Slider(
733
  label="Temperature",
 
749
  value='\n'.join(config.get('examples', [])),
750
  lines=3
751
  )
752
+
753
+ # Action buttons
754
+ with gr.Row():
755
+ save_btn = gr.Button("πŸ’Ύ Save Configuration", variant="primary")
756
+ reset_btn = gr.Button("↩️ Reset to Defaults", variant="secondary")
757
+
758
+ config_status = gr.Markdown("")
759
 
 
 
 
 
 
 
 
 
760
  def save_configuration(system_prompt, model, temp, tokens, examples):
761
+ """Save updated configuration"""
762
  try:
763
+ updated_config = config.copy()
764
+ updated_config.update({
 
765
  'system_prompt': system_prompt,
766
  'model': model,
767
  'temperature': temp,
768
  'max_tokens': int(tokens),
769
  'examples': [ex.strip() for ex in examples.split('\n') if ex.strip()],
770
+ 'locked': config.get('locked', False)
771
  })
772
 
773
+ if config_manager.save(updated_config):
774
+ # Auto-commit if HF token is available
775
+ if HF_TOKEN and SPACE_ID:
776
+ try:
777
+ from huggingface_hub import HfApi
778
+ api = HfApi()
779
+ api.upload_file(
780
+ path_or_fileobj=config_manager.config_path,
781
+ path_in_repo="config.json",
782
+ repo_id=SPACE_ID,
783
+ repo_type="space",
784
+ token=HF_TOKEN,
785
+ commit_message="Update configuration via web UI"
 
 
 
 
 
 
 
 
 
786
  )
787
+ return "βœ… Configuration saved and committed to repository!"
788
+ except Exception as e:
789
+ return f"βœ… Configuration saved locally. ⚠️ Auto-commit failed: {str(e)}"
790
+ else:
791
+ return "βœ… Configuration saved locally (no HF token for auto-commit)"
792
  else:
793
+ return "❌ Failed to save configuration"
 
794
  except Exception as e:
795
  return f"❌ Error: {str(e)}"
796
 
797
+ def reset_configuration():
798
+ """Reset to default configuration"""
799
+ try:
800
+ if config_manager.save(DEFAULT_CONFIG):
801
+ return (
802
+ DEFAULT_CONFIG['system_prompt'],
803
+ DEFAULT_CONFIG['model'],
804
+ DEFAULT_CONFIG['temperature'],
805
+ DEFAULT_CONFIG['max_tokens'],
806
+ '\n'.join(DEFAULT_CONFIG['examples']),
807
+ "βœ… Reset to default configuration"
808
+ )
809
+ else:
810
+ return (*[gr.update() for _ in range(5)], "❌ Failed to reset")
811
+ except Exception as e:
812
+ return (*[gr.update() for _ in range(5)], f"❌ Error: {str(e)}")
813
+
814
  save_btn.click(
815
  save_configuration,
816
  inputs=[edit_system_prompt, edit_model, edit_temperature, edit_max_tokens, edit_examples],
817
  outputs=[config_status]
818
  )
819
 
 
 
 
 
 
 
 
 
 
 
 
820
  reset_btn.click(
821
  reset_configuration,
822
  outputs=[edit_system_prompt, edit_model, edit_temperature, edit_max_tokens, edit_examples, config_status]
823
  )
824
+
825
+ # Show config lock status
826
+ if config.get('locked', False):
827
+ gr.Markdown("πŸ”’ **Configuration is locked**")
828
 
829
  return demo
830
 
831
 
832
+ # Main execution
833
  if __name__ == "__main__":
834
+ print("===== Application Startup at", datetime.now().strftime('%Y-%m-%d %H:%M:%S'), "=====")
835
  demo = create_interface()
836
+ demo.launch(
837
+ share=False,
838
+ server_name="0.0.0.0",
839
+ server_port=7860,
840
+ show_error=True
841
+ )