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