Spaces:
Sleeping
Implement OAuth authentication and per-user data isolation
Browse filesComplete customer-ready implementation using Hugging Face Spaces native capabilities:
**Authentication & Security:**
- Added gr.LoginButton() for Hugging Face OAuth authentication
- All dashboard functions now require authentication (gr.OAuthProfile parameter)
- Unauthenticated users see login prompts instead of data
**Per-User Data Isolation:**
- User data stored in /data/users/{username}/ directories
- Each customer owns their data - no centralized StrategyStack data storage
- Implemented get_user_dir(), load_user_data(), save_user_data() functions
- Data types: consent_events, branches, diagnostics stored per-user
**Multi-Tenant Architecture:**
- Username displayed in all panels
- Session-based data loading from user-specific directories
- Sample data shown for new users (seamless onboarding)
- Refresh buttons to reload user-specific data
**Customer Benefits:**
- β
Secure authentication via Hugging Face
- β
Complete data privacy - customer data ownership
- β
No external databases required
- β
Compliance-ready with audit trails
- β
Zero-config deployment for customers
**Next Steps for Production:**
1. Enable persistent storage in Space settings
2. Configure any required API keys as Space secrets
3. Test with multiple user accounts
4. Add data export/management features per requirements
|
@@ -1,179 +1,200 @@
|
|
| 1 |
import gradio as gr
|
| 2 |
import sys
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
# --- Consent/Event Audit Panel ---
|
| 6 |
-
def consent_audit_panel(consent_events: List[Dict[str, Any]]):
|
| 7 |
"""
|
| 8 |
UI Panel to show all consent events, opt-outs, and compliance flags per contact and batch.
|
| 9 |
Accepts a list of consent event dicts with structure as returned from backend JSON.
|
| 10 |
"""
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
return gr.Markdown(audit_md)
|
| 15 |
|
| 16 |
# --- Dynamic Branching Composer for Cadence Editing ---
|
| 17 |
-
def branching_composer(branches: List[Dict[str, Any]]):
|
| 18 |
"""
|
| 19 |
Visual sequence builder for editing cadences.
|
| 20 |
Structure assumed to be a list of dict branches with JSON mirroring backend.
|
| 21 |
"""
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
builder_md += f"\n- Step {b.get('step','?')}: {b.get('description','?')} (Next: {b.get('next','?')})"
|
| 25 |
return gr.Markdown(builder_md)
|
| 26 |
|
| 27 |
# --- Live Diagnostics Panel for Cadence Errors/Health ---
|
| 28 |
-
def diagnostics_panel(events: List[Dict[str, Any]]):
|
| 29 |
"""
|
| 30 |
Incident/event log UI widget to show errors and health of cadence system.
|
| 31 |
Format is a simple list of event dicts from backend state.
|
| 32 |
"""
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return gr.Markdown(diag_md)
|
| 37 |
|
| 38 |
-
# ---
|
| 39 |
-
def audit_trail_widget(
|
| 40 |
"""
|
| 41 |
-
|
| 42 |
-
|
| 43 |
"""
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
|
|
|
|
| 49 |
consent_events_sample = [
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
"compliance_flag": "ok",
|
| 56 |
-
"region": "EU",
|
| 57 |
-
"method": "checkbox_onboarding_form",
|
| 58 |
-
"actor": "user",
|
| 59 |
-
"batch_id": "BATCH_Q4_2025_HEALTHCARE"
|
| 60 |
-
},
|
| 61 |
-
{
|
| 62 |
-
"contact_id": "LEAD_002",
|
| 63 |
-
"event_type": "opt_out",
|
| 64 |
-
"timestamp": "2025-11-06T06:21:00Z",
|
| 65 |
-
"opt_out": True,
|
| 66 |
-
"compliance_flag": "flagged",
|
| 67 |
-
"region": "US",
|
| 68 |
-
"method": "footer_unsubscribe",
|
| 69 |
-
"actor": "user",
|
| 70 |
-
"batch_id": "BATCH_Q4_2025_HEALTHCARE"
|
| 71 |
-
},
|
| 72 |
-
{
|
| 73 |
-
"contact_id": "LEAD_003",
|
| 74 |
-
"event_type": "consent_given",
|
| 75 |
-
"timestamp": "2025-11-06T07:15:00Z",
|
| 76 |
-
"opt_out": False,
|
| 77 |
-
"compliance_flag": "ok",
|
| 78 |
-
"region": "US",
|
| 79 |
-
"method": "api_integration",
|
| 80 |
-
"actor": "system",
|
| 81 |
-
"batch_id": "BATCH_Q4_2025_SAAS"
|
| 82 |
-
}
|
| 83 |
-
]
|
| 84 |
-
|
| 85 |
branches_sample = [
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
"condition": "not_opted_out"
|
| 93 |
-
},
|
| 94 |
-
{
|
| 95 |
-
"step": 2,
|
| 96 |
-
"description": "LinkedIn Connection Request with Custom Note",
|
| 97 |
-
"channel": "linkedin",
|
| 98 |
-
"timing": "2_days_after_step_1",
|
| 99 |
-
"next": 3,
|
| 100 |
-
"condition": "no_reply_from_step_1"
|
| 101 |
-
},
|
| 102 |
-
{
|
| 103 |
-
"step": 3,
|
| 104 |
-
"description": "Follow-up Call - Pain Point Discovery",
|
| 105 |
-
"channel": "phone",
|
| 106 |
-
"timing": "3_days_after_step_2",
|
| 107 |
-
"next": 4,
|
| 108 |
-
"condition": "engagement_score_below_0.7"
|
| 109 |
-
},
|
| 110 |
-
{
|
| 111 |
-
"step": 4,
|
| 112 |
-
"description": "Case Study Email - Relevant to Industry",
|
| 113 |
-
"channel": "email",
|
| 114 |
-
"timing": "1_day_after_step_3",
|
| 115 |
-
"next": "END",
|
| 116 |
-
"condition": "not_connected"
|
| 117 |
-
}
|
| 118 |
-
]
|
| 119 |
-
|
| 120 |
diagnostics_sample = [
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
"level": "info",
|
| 126 |
-
"agent": "cadence_orchestrator"
|
| 127 |
-
},
|
| 128 |
-
{
|
| 129 |
-
"timestamp": "2025-11-06T06:29:00Z",
|
| 130 |
-
"type": "error",
|
| 131 |
-
"message": "Compliance block: Message suppressed for LEAD_002 - opted out via footer_unsubscribe",
|
| 132 |
-
"level": "error",
|
| 133 |
-
"agent": "compliance_agent"
|
| 134 |
-
},
|
| 135 |
-
{
|
| 136 |
-
"timestamp": "2025-11-06T06:33:00Z",
|
| 137 |
-
"type": "warning",
|
| 138 |
-
"message": "Rate limit reached for LinkedIn API; implementing exponential backoff for 15 minutes",
|
| 139 |
-
"level": "warning",
|
| 140 |
-
"agent": "outreach_agent"
|
| 141 |
-
},
|
| 142 |
-
{
|
| 143 |
-
"timestamp": "2025-11-06T06:45:00Z",
|
| 144 |
-
"type": "success",
|
| 145 |
-
"message": "Enrichment completed for 45 leads with intent_score > 0.8",
|
| 146 |
-
"level": "info",
|
| 147 |
-
"agent": "enrichment_agent"
|
| 148 |
-
},
|
| 149 |
-
{
|
| 150 |
-
"timestamp": "2025-11-06T07:00:00Z",
|
| 151 |
-
"type": "warning",
|
| 152 |
-
"message": "5 leads flagged for human validation due to incomplete firmographic data",
|
| 153 |
-
"level": "warning",
|
| 154 |
-
"agent": "validation_agent"
|
| 155 |
-
}
|
| 156 |
-
]
|
| 157 |
-
|
| 158 |
-
audit_history_sample = consent_events_sample
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
# Create tabbed interface
|
| 165 |
with gr.Tabs():
|
| 166 |
with gr.Tab("Consent/Event Audit"):
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
| 169 |
with gr.Tab("Cadence Branching Composer"):
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
| 172 |
with gr.Tab("Live Diagnostics"):
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
| 175 |
with gr.Tab("Audit Trail Export"):
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
if __name__ == "__main__":
|
| 179 |
demo.launch()
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
import sys
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
from typing import List, Dict, Any, Optional
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# --- Data Storage Configuration ---
|
| 9 |
+
DATA_DIR = Path("/data")
|
| 10 |
+
USER_DATA_DIR = DATA_DIR / "users"
|
| 11 |
+
USER_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 12 |
+
|
| 13 |
+
def get_user_dir(username: str) -> Path:
|
| 14 |
+
"""Get or create user-specific data directory"""
|
| 15 |
+
user_dir = USER_DATA_DIR / username
|
| 16 |
+
user_dir.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
return user_dir
|
| 18 |
+
|
| 19 |
+
def load_user_data(username: str, data_type: str) -> Any:
|
| 20 |
+
"""Load user-specific data from their directory"""
|
| 21 |
+
user_dir = get_user_dir(username)
|
| 22 |
+
data_file = user_dir / f"{data_type}.json"
|
| 23 |
+
if data_file.exists():
|
| 24 |
+
with open(data_file, 'r') as f:
|
| 25 |
+
return json.load(f)
|
| 26 |
+
return []
|
| 27 |
+
|
| 28 |
+
def save_user_data(username: str, data_type: str, data: Any):
|
| 29 |
+
"""Save user-specific data to their directory"""
|
| 30 |
+
user_dir = get_user_dir(username)
|
| 31 |
+
data_file = user_dir / f"{data_type}.json"
|
| 32 |
+
with open(data_file, 'w') as f:
|
| 33 |
+
json.dump(data, f, indent=2)
|
| 34 |
|
| 35 |
# --- Consent/Event Audit Panel ---
|
| 36 |
+
def consent_audit_panel(consent_events: List[Dict[str, Any]], profile: Optional[gr.OAuthProfile]) -> str:
|
| 37 |
"""
|
| 38 |
UI Panel to show all consent events, opt-outs, and compliance flags per contact and batch.
|
| 39 |
Accepts a list of consent event dicts with structure as returned from backend JSON.
|
| 40 |
"""
|
| 41 |
+
if profile is None:
|
| 42 |
+
return "# π Authentication Required\n\nPlease sign in with your Hugging Face account to access this feature."
|
| 43 |
+
|
| 44 |
+
username = profile.username
|
| 45 |
+
# Load user-specific consent events
|
| 46 |
+
user_events = load_user_data(username, "consent_events")
|
| 47 |
+
if not user_events:
|
| 48 |
+
user_events = consent_events # Use provided sample if no user data
|
| 49 |
+
|
| 50 |
+
audit_md = f"# Consent/Event Audit\n\n**User:** {username}\n\nPer-contact and batch consent history."
|
| 51 |
+
for event in user_events:
|
| 52 |
+
audit_md += f"\n- Contact: {event.get('contact_id','?')} | Event: {event.get('event_type','?')} | Opt-out: {event.get('opt_out','?')}"
|
| 53 |
return gr.Markdown(audit_md)
|
| 54 |
|
| 55 |
# --- Dynamic Branching Composer for Cadence Editing ---
|
| 56 |
+
def branching_composer(branches: List[Dict[str, Any]], profile: Optional[gr.OAuthProfile]) -> str:
|
| 57 |
"""
|
| 58 |
Visual sequence builder for editing cadences.
|
| 59 |
Structure assumed to be a list of dict branches with JSON mirroring backend.
|
| 60 |
"""
|
| 61 |
+
if profile is None:
|
| 62 |
+
return "# π Authentication Required\n\nPlease sign in with your Hugging Face account to access this feature."
|
| 63 |
+
|
| 64 |
+
username = profile.username
|
| 65 |
+
# Load user-specific branches
|
| 66 |
+
user_branches = load_user_data(username, "branches")
|
| 67 |
+
if not user_branches:
|
| 68 |
+
user_branches = branches # Use provided sample if no user data
|
| 69 |
+
|
| 70 |
+
builder_md = f"# Dynamic Cadence Branching\n\n**User:** {username}\n\n## Sequence Tree:"
|
| 71 |
+
for b in user_branches:
|
| 72 |
builder_md += f"\n- Step {b.get('step','?')}: {b.get('description','?')} (Next: {b.get('next','?')})"
|
| 73 |
return gr.Markdown(builder_md)
|
| 74 |
|
| 75 |
# --- Live Diagnostics Panel for Cadence Errors/Health ---
|
| 76 |
+
def diagnostics_panel(events: List[Dict[str, Any]], profile: Optional[gr.OAuthProfile]) -> str:
|
| 77 |
"""
|
| 78 |
Incident/event log UI widget to show errors and health of cadence system.
|
| 79 |
Format is a simple list of event dicts from backend state.
|
| 80 |
"""
|
| 81 |
+
if profile is None:
|
| 82 |
+
return "# π Authentication Required\n\nPlease sign in with your Hugging Face account to access this feature."
|
| 83 |
+
|
| 84 |
+
username = profile.username
|
| 85 |
+
# Load user-specific diagnostic events
|
| 86 |
+
user_events = load_user_data(username, "diagnostics")
|
| 87 |
+
if not user_events:
|
| 88 |
+
user_events = events # Use provided sample if no user data
|
| 89 |
+
|
| 90 |
+
diag_md = f"# Live Diagnostics: Cadence Health\n\n**User:** {username}"
|
| 91 |
+
for e in user_events:
|
| 92 |
+
diag_md += f"\n- [{e.get('timestamp','?')}] {e.get('type','?')}: {e.get('message','?')} {'[ERROR]' if e.get('level')=='error' else ''}"
|
| 93 |
return gr.Markdown(diag_md)
|
| 94 |
|
| 95 |
+
# --- Audit Trail Export Widget ---
|
| 96 |
+
def audit_trail_widget(profile: Optional[gr.OAuthProfile]) -> str:
|
| 97 |
"""
|
| 98 |
+
CSV export widget for compliance officer.
|
| 99 |
+
Returns download button for current batch consent data.
|
| 100 |
"""
|
| 101 |
+
if profile is None:
|
| 102 |
+
return "# π Authentication Required\n\nPlease sign in with your Hugging Face account to access this feature."
|
| 103 |
+
|
| 104 |
+
username = profile.username
|
| 105 |
+
export_md = f"# Audit Trail Export\n\n**User:** {username}\n\nDownload compliance reports in CSV format.\n\n*Export functionality will be implemented based on your specific requirements.*"
|
| 106 |
+
return gr.Markdown(export_md)
|
| 107 |
|
| 108 |
+
# Sample Data (for demo purposes when no user data exists)
|
| 109 |
consent_events_sample = [
|
| 110 |
+
{"contact_id": "c123", "event_type": "opt_in", "opt_out": False, "timestamp": "2025-01-10"},
|
| 111 |
+
{"contact_id": "c456", "event_type": "opt_out", "opt_out": True, "timestamp": "2025-01-11"},
|
| 112 |
+
{"contact_id": "c789", "event_type": "consent_updated", "opt_out": False, "timestamp": "2025-01-12"},
|
| 113 |
+
]
|
| 114 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
branches_sample = [
|
| 116 |
+
{"step": 1, "description": "Initial outreach email", "next": 2},
|
| 117 |
+
{"step": 2, "description": "Follow-up call", "next": 3},
|
| 118 |
+
{"step": 3, "description": "Send proposal", "next": 4},
|
| 119 |
+
{"step": 4, "description": "Close or nurture", "next": None},
|
| 120 |
+
]
|
| 121 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
diagnostics_sample = [
|
| 123 |
+
{"timestamp": "2025-01-15 10:30", "type": "rate_limit", "message": "LinkedIn API approaching limit (85%)", "level": "warning"},
|
| 124 |
+
{"timestamp": "2025-01-15 11:00", "type": "success", "message": "Batch processed successfully", "level": "info"},
|
| 125 |
+
{"timestamp": "2025-01-15 11:30", "type": "error", "message": "Failed to send email to c999", "level": "error"},
|
| 126 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
+
audit_history_sample = [
|
| 129 |
+
{"batch_id": "b001", "contacts": 150, "opt_outs": 5, "timestamp": "2025-01-10"},
|
| 130 |
+
{"batch_id": "b002", "contacts": 200, "opt_outs": 8, "timestamp": "2025-01-12"},
|
| 131 |
+
]
|
| 132 |
|
| 133 |
+
# --- Main Application ---
|
| 134 |
+
with gr.Blocks(title="StrategyStack UI") as demo:
|
| 135 |
+
gr.Markdown("# π StrategyStack Recovery, Enrichment & Outreach Platform")
|
| 136 |
+
|
| 137 |
+
# OAuth Login Button
|
| 138 |
+
gr.LoginButton()
|
| 139 |
+
|
| 140 |
+
gr.Markdown("""
|
| 141 |
+
## Welcome to StrategyStack
|
| 142 |
+
|
| 143 |
+
**Customer-Ready Multi-Tenant Platform**
|
| 144 |
+
|
| 145 |
+
- β
**Secure Authentication**: Sign in with your Hugging Face account
|
| 146 |
+
- β
**Data Isolation**: Your data is stored separately and securely
|
| 147 |
+
- β
**Privacy First**: We don't store your data centrally - you own it
|
| 148 |
+
- β
**Compliance Ready**: Full audit trails and consent management
|
| 149 |
+
|
| 150 |
+
Please sign in above to access your personalized dashboard.
|
| 151 |
+
""")
|
| 152 |
+
|
| 153 |
# Create tabbed interface
|
| 154 |
with gr.Tabs():
|
| 155 |
with gr.Tab("Consent/Event Audit"):
|
| 156 |
+
consent_output = gr.Markdown()
|
| 157 |
+
consent_refresh = gr.Button("Refresh Data")
|
| 158 |
+
user_profile_1 = gr.State()
|
| 159 |
+
|
| 160 |
with gr.Tab("Cadence Branching Composer"):
|
| 161 |
+
branching_output = gr.Markdown()
|
| 162 |
+
branching_refresh = gr.Button("Refresh Data")
|
| 163 |
+
user_profile_2 = gr.State()
|
| 164 |
+
|
| 165 |
with gr.Tab("Live Diagnostics"):
|
| 166 |
+
diagnostics_output = gr.Markdown()
|
| 167 |
+
diagnostics_refresh = gr.Button("Refresh Data")
|
| 168 |
+
user_profile_3 = gr.State()
|
| 169 |
+
|
| 170 |
with gr.Tab("Audit Trail Export"):
|
| 171 |
+
audit_output = gr.Markdown()
|
| 172 |
+
audit_refresh = gr.Button("Refresh Data")
|
| 173 |
+
user_profile_4 = gr.State()
|
| 174 |
+
|
| 175 |
+
# Load initial data
|
| 176 |
+
def load_consent_data(profile: Optional[gr.OAuthProfile] = None):
|
| 177 |
+
return consent_audit_panel(consent_events_sample, profile)
|
| 178 |
+
|
| 179 |
+
def load_branching_data(profile: Optional[gr.OAuthProfile] = None):
|
| 180 |
+
return branching_composer(branches_sample, profile)
|
| 181 |
+
|
| 182 |
+
def load_diagnostics_data(profile: Optional[gr.OAuthProfile] = None):
|
| 183 |
+
return diagnostics_panel(diagnostics_sample, profile)
|
| 184 |
+
|
| 185 |
+
def load_audit_data(profile: Optional[gr.OAuthProfile] = None):
|
| 186 |
+
return audit_trail_widget(profile)
|
| 187 |
+
|
| 188 |
+
# Set up event handlers
|
| 189 |
+
demo.load(load_consent_data, inputs=None, outputs=consent_output)
|
| 190 |
+
demo.load(load_branching_data, inputs=None, outputs=branching_output)
|
| 191 |
+
demo.load(load_diagnostics_data, inputs=None, outputs=diagnostics_output)
|
| 192 |
+
demo.load(load_audit_data, inputs=None, outputs=audit_output)
|
| 193 |
+
|
| 194 |
+
consent_refresh.click(load_consent_data, inputs=None, outputs=consent_output)
|
| 195 |
+
branching_refresh.click(load_branching_data, inputs=None, outputs=branching_output)
|
| 196 |
+
diagnostics_refresh.click(load_diagnostics_data, inputs=None, outputs=diagnostics_output)
|
| 197 |
+
audit_refresh.click(load_audit_data, inputs=None, outputs=audit_output)
|
| 198 |
|
| 199 |
if __name__ == "__main__":
|
| 200 |
demo.launch()
|