fix(demo): block Vue navigation in demo mode; fix wizard gate ui sync
- ui_switcher.py: add explicit guard that forces pref=streamlit when DEMO_MODE=true, before the tier-downgrade check. Demo Vue SPA (#46) is not yet implemented, so navigating there produced a blank screen. - app.py: call sync_ui_cookie inside wizard gate block before st.stop() so that cloud users with ui_preference=vue are redirected correctly even when the first-run wizard is still active. Previous behaviour called sync_ui_cookie after pg.run() which was never reached. - demo/config/user.yaml: reset ui_preference to streamlit (belt-and- suspenders alongside the code guard). Closes: demo blank-screen regression reported 2026-03-24.
This commit is contained in:
parent
e9c3c45612
commit
608e0fa922
3 changed files with 149 additions and 36 deletions
85
app/app.py
85
app/app.py
|
|
@ -22,7 +22,7 @@ IS_DEMO = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
from scripts.db import DEFAULT_DB, init_db, get_active_tasks
|
from scripts.db import DEFAULT_DB, init_db, get_active_tasks
|
||||||
from app.feedback import inject_feedback_button
|
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
|
import sqlite3
|
||||||
|
|
||||||
_LOGO_CIRCLE = Path(__file__).parent / "static" / "peregrine_logo_circle.png"
|
_LOGO_CIRCLE = Path(__file__).parent / "static" / "peregrine_logo_circle.png"
|
||||||
|
|
@ -99,6 +99,15 @@ _show_wizard = not IS_DEMO and (
|
||||||
if _show_wizard:
|
if _show_wizard:
|
||||||
_setup_page = st.Page("pages/0_Setup.py", title="Setup", icon="👋")
|
_setup_page = st.Page("pages/0_Setup.py", title="Setup", icon="👋")
|
||||||
st.navigation({"": [_setup_page]}).run()
|
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()
|
st.stop()
|
||||||
|
|
||||||
# ── Navigation ─────────────────────────────────────────────────────────────────
|
# ── Navigation ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -123,6 +132,21 @@ pg = st.navigation(pages)
|
||||||
# ── Background task sidebar indicator ─────────────────────────────────────────
|
# ── Background task sidebar indicator ─────────────────────────────────────────
|
||||||
# Fragment polls every 3s so stage labels update live without a full page reload.
|
# 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.
|
# 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)
|
@st.fragment(run_every=3)
|
||||||
def _task_indicator():
|
def _task_indicator():
|
||||||
tasks = get_active_tasks(get_db_path())
|
tasks = get_active_tasks(get_db_path())
|
||||||
|
|
@ -130,27 +154,30 @@ def _task_indicator():
|
||||||
return
|
return
|
||||||
st.divider()
|
st.divider()
|
||||||
st.markdown(f"**⏳ {len(tasks)} task(s) running**")
|
st.markdown(f"**⏳ {len(tasks)} task(s) running**")
|
||||||
for t in tasks:
|
|
||||||
|
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 "🕐"
|
icon = "⏳" if t["status"] == "running" else "🕐"
|
||||||
task_type = t["task_type"]
|
label = _TASK_LABELS.get(t["task_type"], t["task_type"].replace("_", " ").title())
|
||||||
if task_type == "cover_letter":
|
stage = t.get("stage") or ""
|
||||||
label = "Cover letter"
|
detail = f" · {stage}" if stage else ""
|
||||||
elif task_type == "company_research":
|
prefix = "" if i == 0 else "↳ "
|
||||||
label = "Research"
|
st.caption(f"{prefix}{icon} {label}{detail}")
|
||||||
elif task_type == "email_sync":
|
|
||||||
label = "Email sync"
|
# All other tasks (cover letter, email sync, etc.) as individual rows
|
||||||
elif task_type == "discovery":
|
for t in other_tasks:
|
||||||
label = "Discovery"
|
icon = "⏳" if t["status"] == "running" else "🕐"
|
||||||
elif task_type == "enrich_descriptions":
|
label = _TASK_LABELS.get(t["task_type"], t["task_type"].replace("_", " ").title())
|
||||||
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 ""
|
stage = t.get("stage") or ""
|
||||||
detail = f" · {stage}" if stage else (f" — {t.get('company')}" if t.get("company") else "")
|
detail = f" · {stage}" if stage else (f" — {t.get('company')}" if t.get("company") else "")
|
||||||
st.caption(f"{icon} {label}{detail}")
|
st.caption(f"{icon} {label}{detail}")
|
||||||
|
|
@ -166,6 +193,13 @@ def _get_version() -> str:
|
||||||
except Exception:
|
except Exception:
|
||||||
return "dev"
|
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:
|
with st.sidebar:
|
||||||
if IS_DEMO:
|
if IS_DEMO:
|
||||||
st.info(
|
st.info(
|
||||||
|
|
@ -195,6 +229,11 @@ with st.sidebar:
|
||||||
)
|
)
|
||||||
|
|
||||||
st.divider()
|
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()}")
|
st.caption(f"Peregrine {_get_version()}")
|
||||||
inject_feedback_button(page=pg.title)
|
inject_feedback_button(page=pg.title)
|
||||||
|
|
||||||
|
|
@ -206,8 +245,6 @@ if IS_DEMO:
|
||||||
# ── UI switcher banner (paid tier; or all visitors in demo mode) ─────────────
|
# ── UI switcher banner (paid tier; or all visitors in demo mode) ─────────────
|
||||||
try:
|
try:
|
||||||
from app.components.ui_switcher import render_banner
|
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)
|
render_banner(_USER_YAML, _ui_tier)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # never crash the app over the banner
|
pass # never crash the app over the banner
|
||||||
|
|
@ -217,8 +254,6 @@ pg.run()
|
||||||
# ── UI preference cookie sync (runs after page render) ──────────────────────
|
# ── UI preference cookie sync (runs after page render) ──────────────────────
|
||||||
try:
|
try:
|
||||||
from app.components.ui_switcher import sync_ui_cookie
|
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)
|
sync_ui_cookie(_USER_YAML, _ui_tier)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # never crash the app over cookie sync
|
pass # never crash the app over cookie sync
|
||||||
|
|
|
||||||
|
|
@ -26,17 +26,42 @@ from app.wizard.tiers import can_use
|
||||||
|
|
||||||
_DEMO_MODE = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
_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 = """
|
_COOKIE_JS = """
|
||||||
<script>
|
<script>
|
||||||
(function() {{
|
(function() {{
|
||||||
document.cookie = 'prgn_ui={value}; path=/; SameSite=Lax';
|
document.cookie = 'prgn_ui={value}; path=/; SameSite=Lax';
|
||||||
|
{navigate_js}
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</script>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _set_cookie_js(value: str) -> None:
|
def _set_cookie_js(value: str, navigate: bool = False) -> None:
|
||||||
components.html(_COOKIE_JS.format(value=value), height=0)
|
"""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:
|
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=<value> param (Vue SPA switch-back signal): overrides yaml,
|
- ?prgn_switch=<value> param (Vue SPA switch-back signal): overrides yaml,
|
||||||
writes yaml to match, clears the param.
|
writes yaml to match, clears the param.
|
||||||
- Tier downgrade: resets vue preference to streamlit for ineligible users.
|
- 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 ──────────────
|
# ── ?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"):
|
if st.query_params.get("ui_fallback"):
|
||||||
st.toast("⚠️ New UI temporarily unavailable — switched back to Classic", icon="⚠️")
|
st.toast("⚠️ New UI temporarily unavailable — switched back to Classic", icon="⚠️")
|
||||||
st.query_params.pop("ui_fallback", None)
|
st.query_params.pop("ui_fallback", None)
|
||||||
|
_set_cookie_js("streamlit")
|
||||||
|
return
|
||||||
|
|
||||||
# ── ?prgn_switch param — Vue SPA sent us here to switch back ──────────────
|
# ── ?prgn_switch param — Vue SPA sent us here to switch back ──────────────
|
||||||
switch_param = st.query_params.get("prgn_switch")
|
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
|
# UI components must not crash the app — silent fallback to default
|
||||||
pref = "streamlit"
|
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)
|
# 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 pref == "vue" and not _DEMO_MODE and not can_use(tier, "vue_ui_beta"):
|
||||||
if profile is not None:
|
if profile is not None:
|
||||||
|
|
@ -87,13 +130,23 @@ def sync_ui_cookie(yaml_path: Path, tier: str) -> None:
|
||||||
pass
|
pass
|
||||||
pref = "streamlit"
|
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:
|
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"
|
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"):
|
if to not in ("vue", "streamlit"):
|
||||||
return
|
return
|
||||||
|
|
@ -104,6 +157,10 @@ def switch_ui(yaml_path: Path, to: str, tier: str) -> None:
|
||||||
except Exception:
|
except Exception:
|
||||||
# UI components must not crash the app — silent fallback
|
# UI components must not crash the app — silent fallback
|
||||||
pass
|
pass
|
||||||
|
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)
|
sync_ui_cookie(yaml_path, tier=tier)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
|
@ -143,6 +200,26 @@ def render_banner(yaml_path: Path, tier: str) -> None:
|
||||||
st.rerun()
|
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:
|
def render_settings_toggle(yaml_path: Path, tier: str) -> None:
|
||||||
"""Toggle in Settings → System → Deployment expander."""
|
"""Toggle in Settings → System → Deployment expander."""
|
||||||
eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
|
eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ mission_preferences:
|
||||||
social_impact: Want my work to reach people who need it most.
|
social_impact: Want my work to reach people who need it most.
|
||||||
name: Demo User
|
name: Demo User
|
||||||
nda_companies: []
|
nda_companies: []
|
||||||
ollama_models_dir: ~/models/ollama
|
ollama_models_dir: /root/models/ollama
|
||||||
phone: ''
|
phone: ''
|
||||||
services:
|
services:
|
||||||
ollama_host: localhost
|
ollama_host: localhost
|
||||||
|
|
@ -39,6 +39,7 @@ services:
|
||||||
vllm_ssl: false
|
vllm_ssl: false
|
||||||
vllm_ssl_verify: true
|
vllm_ssl_verify: true
|
||||||
tier: free
|
tier: free
|
||||||
vllm_models_dir: ~/models/vllm
|
ui_preference: streamlit
|
||||||
|
vllm_models_dir: /root/models/vllm
|
||||||
wizard_complete: true
|
wizard_complete: true
|
||||||
wizard_step: 0
|
wizard_step: 0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue