diff --git a/app/app.py b/app/app.py index 8a49a76..64d0393 100644 --- a/app/app.py +++ b/app/app.py @@ -22,7 +22,7 @@ IS_DEMO = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes") import streamlit as st from scripts.db import DEFAULT_DB, init_db, get_active_tasks from app.feedback import inject_feedback_button -from app.cloud_session import resolve_session, get_db_path, get_config_dir +from app.cloud_session import resolve_session, get_db_path, get_config_dir, get_cloud_tier import sqlite3 _LOGO_CIRCLE = Path(__file__).parent / "static" / "peregrine_logo_circle.png" @@ -99,6 +99,15 @@ _show_wizard = not IS_DEMO and ( if _show_wizard: _setup_page = st.Page("pages/0_Setup.py", title="Setup", icon="๐Ÿ‘‹") st.navigation({"": [_setup_page]}).run() + # Sync UI cookie even during wizard so vue preference redirects correctly. + # Tier not yet computed here โ€” use cloud tier (or "free" fallback). + try: + from app.components.ui_switcher import sync_ui_cookie as _sync_wizard_cookie + from app.cloud_session import get_cloud_tier as _gctr + _wizard_tier = _gctr() if _gctr() != "local" else "free" + _sync_wizard_cookie(_USER_YAML, _wizard_tier) + except Exception: + pass st.stop() # โ”€โ”€ Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -123,6 +132,21 @@ pg = st.navigation(pages) # โ”€โ”€ Background task sidebar indicator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Fragment polls every 3s so stage labels update live without a full page reload. # The sidebar context WRAPS the fragment call โ€” do not write to st.sidebar inside it. +_TASK_LABELS = { + "cover_letter": "Cover letter", + "company_research": "Research", + "email_sync": "Email sync", + "discovery": "Discovery", + "enrich_descriptions": "Enriching descriptions", + "score": "Scoring matches", + "scrape_url": "Scraping listing", + "enrich_craigslist": "Enriching listing", + "wizard_generate": "Wizard generation", + "prepare_training": "Training data", +} +_DISCOVERY_PIPELINE = ["discovery", "enrich_descriptions", "score"] + + @st.fragment(run_every=3) def _task_indicator(): tasks = get_active_tasks(get_db_path()) @@ -130,28 +154,31 @@ def _task_indicator(): return st.divider() st.markdown(f"**โณ {len(tasks)} task(s) running**") - for t in tasks: - icon = "โณ" if t["status"] == "running" else "๐Ÿ•" - task_type = t["task_type"] - if task_type == "cover_letter": - label = "Cover letter" - elif task_type == "company_research": - label = "Research" - elif task_type == "email_sync": - label = "Email sync" - elif task_type == "discovery": - label = "Discovery" - elif task_type == "enrich_descriptions": - label = "Enriching" - elif task_type == "scrape_url": - label = "Scraping URL" - elif task_type == "wizard_generate": - label = "Wizard generation" - elif task_type == "enrich_craigslist": - label = "Enriching listing" - else: - label = task_type.replace("_", " ").title() - stage = t.get("stage") or "" + + pipeline_set = set(_DISCOVERY_PIPELINE) + pipeline_tasks = [t for t in tasks if t["task_type"] in pipeline_set] + other_tasks = [t for t in tasks if t["task_type"] not in pipeline_set] + + # Discovery pipeline: render as ordered sub-queue with indented steps + if pipeline_tasks: + ordered = [ + next((t for t in pipeline_tasks if t["task_type"] == typ), None) + for typ in _DISCOVERY_PIPELINE + ] + ordered = [t for t in ordered if t is not None] + for i, t in enumerate(ordered): + icon = "โณ" if t["status"] == "running" else "๐Ÿ•" + label = _TASK_LABELS.get(t["task_type"], t["task_type"].replace("_", " ").title()) + stage = t.get("stage") or "" + detail = f" ยท {stage}" if stage else "" + prefix = "" if i == 0 else "โ†ณ " + st.caption(f"{prefix}{icon} {label}{detail}") + + # All other tasks (cover letter, email sync, etc.) as individual rows + for t in other_tasks: + icon = "โณ" if t["status"] == "running" else "๐Ÿ•" + label = _TASK_LABELS.get(t["task_type"], t["task_type"].replace("_", " ").title()) + stage = t.get("stage") or "" detail = f" ยท {stage}" if stage else (f" โ€” {t.get('company')}" if t.get("company") else "") st.caption(f"{icon} {label}{detail}") @@ -166,6 +193,13 @@ def _get_version() -> str: except Exception: return "dev" +# โ”€โ”€ Effective tier (resolved before sidebar so switcher can use it) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# get_cloud_tier() returns "local" in dev/self-hosted mode, real tier in cloud. +_ui_profile = _UserProfile(_USER_YAML) if _UserProfile.exists(_USER_YAML) else None +_ui_yaml_tier = _ui_profile.effective_tier if _ui_profile else "free" +_ui_cloud_tier = get_cloud_tier() +_ui_tier = _ui_cloud_tier if _ui_cloud_tier != "local" else _ui_yaml_tier + with st.sidebar: if IS_DEMO: st.info( @@ -195,6 +229,11 @@ with st.sidebar: ) st.divider() + try: + from app.components.ui_switcher import render_sidebar_switcher + render_sidebar_switcher(_USER_YAML, _ui_tier) + except Exception: + pass # never crash the app over the sidebar switcher st.caption(f"Peregrine {_get_version()}") inject_feedback_button(page=pg.title) @@ -206,8 +245,6 @@ if IS_DEMO: # โ”€โ”€ UI switcher banner (paid tier; or all visitors in demo mode) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ try: from app.components.ui_switcher import render_banner - _ui_profile = _UserProfile(_USER_YAML) if _UserProfile.exists(_USER_YAML) else None - _ui_tier = _ui_profile.tier if _ui_profile else "free" render_banner(_USER_YAML, _ui_tier) except Exception: pass # never crash the app over the banner @@ -217,8 +254,6 @@ pg.run() # โ”€โ”€ UI preference cookie sync (runs after page render) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ try: from app.components.ui_switcher import sync_ui_cookie - _ui_profile = _UserProfile(_USER_YAML) if _UserProfile.exists(_USER_YAML) else None - _ui_tier = _ui_profile.tier if _ui_profile else "free" sync_ui_cookie(_USER_YAML, _ui_tier) except Exception: pass # never crash the app over cookie sync diff --git a/app/components/ui_switcher.py b/app/components/ui_switcher.py index 28b71b2..2fb4a02 100644 --- a/app/components/ui_switcher.py +++ b/app/components/ui_switcher.py @@ -26,17 +26,42 @@ from app.wizard.tiers import can_use _DEMO_MODE = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes") +# When set, the app is running without a Caddy reverse proxy in front +# (local dev, direct port exposure). Switch to Vue by navigating directly +# to this URL instead of relying on cookie-based Caddy routing. +# Example: PEREGRINE_VUE_URL=http://localhost:8506 +_VUE_URL = os.environ.get("PEREGRINE_VUE_URL", "").strip().rstrip("/") + _COOKIE_JS = """ """ -def _set_cookie_js(value: str) -> None: - components.html(_COOKIE_JS.format(value=value), height=0) +def _set_cookie_js(value: str, navigate: bool = False) -> None: + """Inject JS to set the prgn_ui cookie. + + When PEREGRINE_VUE_URL is set (local dev, no Caddy): navigating to Vue + uses window.parent.location.href to jump directly to the Vue container + port. Without this, reload() just sends the request back to the same + Streamlit port with no router in between to inspect the cookie. + + When PEREGRINE_VUE_URL is absent (Caddy deployment): navigate=True + triggers window.location.reload() so Caddy sees the updated cookie on + the next HTTP request and routes accordingly. + """ + # components.html() renders in an iframe โ€” window.parent navigates the host page + if navigate and value == "vue" and _VUE_URL: + nav_js = f"window.parent.location.href = '{_VUE_URL}';" + elif navigate: + nav_js = "window.parent.location.reload();" + else: + nav_js = "" + components.html(_COOKIE_JS.format(value=value, navigate_js=nav_js), height=0) def sync_ui_cookie(yaml_path: Path, tier: str) -> None: @@ -46,12 +71,24 @@ def sync_ui_cookie(yaml_path: Path, tier: str) -> None: - ?prgn_switch= param (Vue SPA switch-back signal): overrides yaml, writes yaml to match, clears the param. - Tier downgrade: resets vue preference to streamlit for ineligible users. - - ?ui_fallback=1 param: shows a toast (Vue SPA was unreachable). + - ?ui_fallback=1 param: Vue SPA was down โ€” reinforce streamlit cookie and + return early to avoid immediately navigating back to a broken Vue SPA. + + When the resolved preference is "vue", this function navigates (full page + reload) rather than silently setting the cookie. Without navigate=True, + Streamlit would set prgn_ui=vue mid-page-load; subsequent HTTP requests + made by Streamlit's own frontend (lazy JS chunks, WebSocket upgrade) would + carry the new cookie and Caddy would misroute them to the Vue nginx + container, causing TypeError: error loading dynamically imported module. """ # โ”€โ”€ ?ui_fallback=1 โ€” Vue SPA was down, Caddy bounced us back โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Return early: reinforce the streamlit cookie so we don't immediately + # navigate back to a Vue SPA that may still be down. if st.query_params.get("ui_fallback"): st.toast("โš ๏ธ New UI temporarily unavailable โ€” switched back to Classic", icon="โš ๏ธ") st.query_params.pop("ui_fallback", None) + _set_cookie_js("streamlit") + return # โ”€โ”€ ?prgn_switch param โ€” Vue SPA sent us here to switch back โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ switch_param = st.query_params.get("prgn_switch") @@ -76,6 +113,12 @@ def sync_ui_cookie(yaml_path: Path, tier: str) -> None: # UI components must not crash the app โ€” silent fallback to default pref = "streamlit" + # Demo mode: Vue SPA has no demo data wiring โ€” always serve Streamlit. + # (The tier downgrade check below is skipped in demo mode, but we must + # also block the Vue navigation itself so Caddy doesn't route to a blank SPA.) + if pref == "vue" and _DEMO_MODE: + pref = "streamlit" + # Tier downgrade protection (skip in demo โ€” demo bypasses tier gate) if pref == "vue" and not _DEMO_MODE and not can_use(tier, "vue_ui_beta"): if profile is not None: @@ -87,13 +130,23 @@ def sync_ui_cookie(yaml_path: Path, tier: str) -> None: pass pref = "streamlit" - _set_cookie_js(pref) + # Navigate (full reload) when switching to Vue so Caddy re-routes on the + # next HTTP request before Streamlit serves any more content. Silent + # cookie-only set is safe for streamlit since we're already on that origin. + _set_cookie_js(pref, navigate=(pref == "vue")) def switch_ui(yaml_path: Path, to: str, tier: str) -> None: - """Write user.yaml, sync cookie, rerun. + """Write user.yaml, set cookie, and navigate. to: "vue" | "streamlit" + + Switching to Vue triggers window.location.reload() so Caddy sees the + updated prgn_ui cookie and routes to the Vue SPA. st.rerun() alone is + not sufficient โ€” it operates over WebSocket and produces no HTTP request. + + Switching back to streamlit uses st.rerun() (no full reload needed since + we're already on the Streamlit origin and no Caddy re-routing is required). """ if to not in ("vue", "streamlit"): return @@ -104,8 +157,12 @@ def switch_ui(yaml_path: Path, to: str, tier: str) -> None: except Exception: # UI components must not crash the app โ€” silent fallback pass - sync_ui_cookie(yaml_path, tier=tier) - st.rerun() + if to == "vue": + # navigate=True triggers window.location.reload() after setting cookie + _set_cookie_js("vue", navigate=True) + else: + sync_ui_cookie(yaml_path, tier=tier) + st.rerun() def render_banner(yaml_path: Path, tier: str) -> None: @@ -143,6 +200,26 @@ def render_banner(yaml_path: Path, tier: str) -> None: st.rerun() +def render_sidebar_switcher(yaml_path: Path, tier: str) -> None: + """Persistent sidebar button to switch to the Vue UI. + + Shown when the user is eligible (paid+ or demo) and currently on Streamlit. + This is always visible โ€” unlike the banner which can be dismissed. + """ + eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta") + if not eligible: + return + try: + profile = UserProfile(yaml_path) + if profile.ui_preference == "vue": + return + except Exception: + pass + + if st.button("โœจ Switch to New UI", key="_sidebar_switch_vue", use_container_width=True): + switch_ui(yaml_path, to="vue", tier=tier) + + def render_settings_toggle(yaml_path: Path, tier: str) -> None: """Toggle in Settings โ†’ System โ†’ Deployment expander.""" eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta") diff --git a/demo/config/user.yaml b/demo/config/user.yaml index a4f1ec2..3dd8c02 100644 --- a/demo/config/user.yaml +++ b/demo/config/user.yaml @@ -22,7 +22,7 @@ mission_preferences: social_impact: Want my work to reach people who need it most. name: Demo User nda_companies: [] -ollama_models_dir: ~/models/ollama +ollama_models_dir: /root/models/ollama phone: '' services: ollama_host: localhost @@ -39,6 +39,7 @@ services: vllm_ssl: false vllm_ssl_verify: true tier: free -vllm_models_dir: ~/models/vllm +ui_preference: streamlit +vllm_models_dir: /root/models/vllm wizard_complete: true wizard_step: 0