diff --git a/CHANGELOG.md b/CHANGELOG.md
index d2fa234..e96cbda 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,50 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
+## [0.7.0] — 2026-03-22
+
+### Added
+- **Vue 3 SPA — beta access for paid tier** — The new Vue 3 frontend (built with
+ Vite + UnoCSS) is now merged into `main` and available to paid-tier subscribers
+ as an opt-in beta. The Streamlit UI remains the default and will continue to
+ receive full support.
+ - `web/` — full Vue 3 SPA source (components, stores, router, composables,
+ views) from `feature/vue-spa`
+ - `web/src/components/ClassicUIButton.vue` — one-click switch back to the
+ Classic (Streamlit) UI; sets `prgn_ui=streamlit` cookie and appends
+ `?prgn_switch=streamlit` so `user.yaml` stays in sync
+ - `web/src/composables/useFeatureFlag.ts` — reads `prgn_demo_tier` cookie for
+ demo toolbar visual consistency (display-only, not an authoritative gate)
+
+- **UI switcher** — Reddit-style opt-in to the Vue SPA with durable preference
+ persistence and graceful fallback.
+ - `app/components/ui_switcher.py` — `sync_ui_cookie()`, `switch_ui()`,
+ `render_banner()`, `render_settings_toggle()`
+ - `scripts/user_profile.py` — `ui_preference` field (`streamlit` | `vue`,
+ default: `streamlit`) with round-trip `save()`
+ - `app/wizard/tiers.py` — `vue_ui_beta: "paid"` feature key; `demo_tier`
+ keyword arg on `can_use()` for thread-safe demo mode simulation
+ - Banner (dismissible, paid tier only) + Settings → System → Deployment toggle
+ - Caddy cookie routing: `prgn_ui=vue` → nginx Vue SPA; absent/`streamlit` →
+ Streamlit. 502 fallback clears cookie and redirects with `?ui_fallback=1`
+
+- **Demo toolbar** — slim full-width tier-simulation bar for `DEMO_MODE`
+ instances. Free / Paid / Premium pills let demo visitors explore all feature
+ tiers without an account. Persists via `prgn_demo_tier` cookie. Default: Paid
+ (most compelling first impression). `app/components/demo_toolbar.py`
+
+- **Docker `web` service** — multi-stage nginx container serving the Vue SPA
+ `dist/` build. Added to `compose.yml` (port 8506), `compose.demo.yml`
+ (port 8507), `compose.cloud.yml` (port 8508). `manage.sh build` now includes
+ the `web` service alongside `app`.
+
+### Changed
+- **Caddy routing** — `menagerie.circuitforge.tech` and
+ `demo.circuitforge.tech` peregrine blocks now inspect the `prgn_ui` cookie
+ and fan-out to the Vue SPA service or Streamlit accordingly.
+
+---
+
## [0.6.2] — 2026-03-18
### Added
diff --git a/app/app.py b/app/app.py
index fcd04df..64d0393 100644
--- a/app/app.py
+++ b/app/app.py
@@ -22,18 +22,28 @@ 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"
+_LOGO_FULL = Path(__file__).parent / "static" / "peregrine_logo.png"
+
st.set_page_config(
page_title="Peregrine",
- page_icon="💼",
+ page_icon=str(_LOGO_CIRCLE) if _LOGO_CIRCLE.exists() else "💼",
layout="wide",
)
resolve_session("peregrine")
init_db(get_db_path())
+# Demo tier — initialize once per session (cookie persistence handled client-side)
+if IS_DEMO and "simulated_tier" not in st.session_state:
+ st.session_state["simulated_tier"] = "paid"
+
+if _LOGO_CIRCLE.exists():
+ st.logo(str(_LOGO_CIRCLE), icon_image=str(_LOGO_CIRCLE))
+
# ── Startup cleanup — runs once per server process via cache_resource ──────────
@st.cache_resource
def _startup() -> None:
@@ -89,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 ─────────────────────────────────────────────────────────────────
@@ -113,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())
@@ -120,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}")
@@ -156,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(
@@ -185,7 +229,31 @@ 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)
+# ── Demo toolbar (DEMO_MODE only) ───────────────────────────────────────────
+if IS_DEMO:
+ from app.components.demo_toolbar import render_demo_toolbar
+ render_demo_toolbar()
+
+# ── UI switcher banner (paid tier; or all visitors in demo mode) ─────────────
+try:
+ from app.components.ui_switcher import render_banner
+ render_banner(_USER_YAML, _ui_tier)
+except Exception:
+ pass # never crash the app over the banner
+
pg.run()
+
+# ── UI preference cookie sync (runs after page render) ──────────────────────
+try:
+ from app.components.ui_switcher import sync_ui_cookie
+ sync_ui_cookie(_USER_YAML, _ui_tier)
+except Exception:
+ pass # never crash the app over cookie sync
diff --git a/app/components/demo_toolbar.py b/app/components/demo_toolbar.py
new file mode 100644
index 0000000..2c30c56
--- /dev/null
+++ b/app/components/demo_toolbar.py
@@ -0,0 +1,72 @@
+"""Demo toolbar — tier simulation for DEMO_MODE instances.
+
+Renders a slim full-width bar above the Streamlit nav showing
+Free / Paid / Premium pills. Clicking a pill sets a prgn_demo_tier
+cookie (for persistence across reloads) and st.session_state.simulated_tier
+(for immediate use within the current render pass).
+
+Only ever rendered when DEMO_MODE=true.
+"""
+from __future__ import annotations
+
+import os
+
+import streamlit as st
+import streamlit.components.v1 as components
+
+_VALID_TIERS = ("free", "paid", "premium")
+_DEFAULT_TIER = "paid" # most compelling first impression
+
+_DEMO_MODE = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
+
+_COOKIE_JS = """
+
+"""
+
+
+def get_simulated_tier() -> str:
+ """Return the current simulated tier, defaulting to 'paid'."""
+ return st.session_state.get("simulated_tier", _DEFAULT_TIER)
+
+
+def set_simulated_tier(tier: str) -> None:
+ """Set simulated tier in session state + cookie. Reruns the page."""
+ if tier not in _VALID_TIERS:
+ return
+ st.session_state["simulated_tier"] = tier
+ components.html(_COOKIE_JS.format(tier=tier), height=0)
+ st.rerun()
+
+
+def render_demo_toolbar() -> None:
+ """Render the demo mode toolbar.
+
+ Shows a dismissible info bar with tier-selection pills.
+ Call this at the TOP of app.py's render pass, before pg.run().
+ """
+ current = get_simulated_tier()
+
+ labels = {t: t.capitalize() + (" ✓" if t == current else "") for t in _VALID_TIERS}
+
+ with st.container():
+ cols = st.columns([3, 1, 1, 1, 2])
+ with cols[0]:
+ st.caption("🎭 **Demo mode** — exploring as:")
+ for i, tier in enumerate(_VALID_TIERS):
+ with cols[i + 1]:
+ is_active = tier == current
+ if st.button(
+ labels[tier],
+ key=f"_demo_tier_{tier}",
+ type="primary" if is_active else "secondary",
+ use_container_width=True,
+ ):
+ if not is_active:
+ set_simulated_tier(tier)
+ with cols[4]:
+ st.caption("[Get your own →](https://circuitforge.tech/software/peregrine)")
+ st.divider()
diff --git a/app/components/ui_switcher.py b/app/components/ui_switcher.py
new file mode 100644
index 0000000..2fb4a02
--- /dev/null
+++ b/app/components/ui_switcher.py
@@ -0,0 +1,251 @@
+"""UI switcher component for Peregrine.
+
+Manages the prgn_ui cookie (Caddy routing signal) and user.yaml
+ui_preference (durability across browser clears).
+
+Cookie mechanics
+----------------
+Streamlit cannot read HTTP cookies server-side. Instead:
+- sync_ui_cookie() injects a JS snippet that sets document.cookie.
+- Vue SPA switch-back appends ?prgn_switch=streamlit to the redirect URL.
+ sync_ui_cookie() reads this param via st.query_params and uses it as
+ an override signal, then writes user.yaml to match.
+
+Call sync_ui_cookie() in the app.py render pass (after pg.run()).
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import streamlit as st
+import streamlit.components.v1 as components
+
+from scripts.user_profile import UserProfile
+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, 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:
+ """Sync the prgn_ui cookie to match user.yaml ui_preference.
+
+ Also handles:
+ - ?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: 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")
+ if switch_param in ("streamlit", "vue"):
+ try:
+ profile = UserProfile(yaml_path)
+ profile.ui_preference = switch_param
+ profile.save()
+ except Exception:
+ # UI components must not crash the app — silent fallback
+ pass
+ st.query_params.pop("prgn_switch", None)
+ _set_cookie_js(switch_param)
+ return
+
+ # ── Normal path: read yaml, enforce tier, inject cookie ───────────────────
+ profile = None
+ try:
+ profile = UserProfile(yaml_path)
+ pref = profile.ui_preference
+ except Exception:
+ # 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:
+ try:
+ profile.ui_preference = "streamlit"
+ profile.save()
+ except Exception:
+ # UI components must not crash the app — silent fallback
+ pass
+ pref = "streamlit"
+
+ # 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, 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
+ try:
+ profile = UserProfile(yaml_path)
+ profile.ui_preference = to
+ profile.save()
+ except Exception:
+ # UI components must not crash the app — silent fallback
+ 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)
+ st.rerun()
+
+
+def render_banner(yaml_path: Path, tier: str) -> None:
+ """Show the 'Try the new UI' banner once per session.
+
+ Dismissed flag stored in user.yaml dismissed_banners list so it
+ persists across sessions (uses the existing dismissed_banners pattern).
+ Eligible: paid+ tier, OR demo mode. Not shown if already on vue.
+ """
+ eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
+ if not eligible:
+ return
+
+ try:
+ profile = UserProfile(yaml_path)
+ except Exception:
+ # UI components must not crash the app — silent fallback
+ return
+
+ if profile.ui_preference == "vue":
+ return
+ if "ui_switcher_beta" in (profile.dismissed_banners or []):
+ return
+
+ col1, col2, col3 = st.columns([8, 1, 1])
+ with col1:
+ st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta, Paid tier)")
+ with col2:
+ if st.button("Try it", key="_ui_banner_try"):
+ switch_ui(yaml_path, to="vue", tier=tier)
+ with col3:
+ if st.button("Dismiss", key="_ui_banner_dismiss"):
+ profile.dismissed_banners = list(profile.dismissed_banners or []) + ["ui_switcher_beta"]
+ profile.save()
+ 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")
+ if not eligible:
+ return
+
+ try:
+ profile = UserProfile(yaml_path)
+ current = profile.ui_preference
+ except Exception:
+ # UI components must not crash the app — silent fallback to default
+ current = "streamlit"
+
+ options = ["streamlit", "vue"]
+ labels = ["Classic (Streamlit)", "✨ New UI (Vue, Beta)"]
+ current_idx = options.index(current) if current in options else 0
+
+ st.markdown("**UI Version**")
+ chosen = st.radio(
+ "UI Version",
+ options=labels,
+ index=current_idx,
+ key="_ui_toggle_radio",
+ label_visibility="collapsed",
+ )
+ chosen_val = options[labels.index(chosen)]
+
+ if chosen_val != current:
+ switch_ui(yaml_path, to=chosen_val, tier=tier)
diff --git a/app/pages/2_Settings.py b/app/pages/2_Settings.py
index 937e336..08b37cb 100644
--- a/app/pages/2_Settings.py
+++ b/app/pages/2_Settings.py
@@ -323,6 +323,26 @@ with tab_search:
_run_suggest = st.button("✨ Suggest", key="sp_suggest_btn",
help="Ask the LLM to suggest additional titles and smarter exclude keywords — using your blocklist, mission values, and career background.")
+ _title_sugg_count = len((st.session_state.get("_sp_suggestions") or {}).get("suggested_titles", []))
+ if _title_sugg_count:
+ st.markdown(f"""""", unsafe_allow_html=True)
+
st.multiselect(
"Job titles",
options=st.session_state.get("_sp_title_options", p.get("titles", [])),
@@ -330,6 +350,14 @@ with tab_search:
help="Select from known titles. Suggestions from ✨ Suggest appear here — pick the ones you want.",
label_visibility="collapsed",
)
+
+ if _title_sugg_count:
+ st.markdown(
+ f'
'
+ f' ↑ {_title_sugg_count} new suggestion{"s" if _title_sugg_count != 1 else ""} '
+ f'added — open the dropdown to browse
',
+ unsafe_allow_html=True,
+ )
_add_t_col, _add_t_btn = st.columns([5, 1])
with _add_t_col:
st.text_input("Add a title", key="_sp_new_title", label_visibility="collapsed",
@@ -813,6 +841,13 @@ with tab_resume:
kw_current: list[str] = kw_data.get(kw_category, [])
kw_suggestions = _load_sugg(kw_category)
+ # If a custom tag was added last render, clear the multiselect's session
+ # state key NOW (before the widget is created) so Streamlit uses `default`
+ # instead of the stale session state that lacks the new tag.
+ _reset_key = f"_kw_reset_{kw_category}"
+ if st.session_state.pop(_reset_key, False):
+ st.session_state.pop(f"kw_ms_{kw_category}", None)
+
# Merge: suggestions first, then any custom tags not in suggestions
kw_custom = [t for t in kw_current if t not in kw_suggestions]
kw_options = kw_suggestions + kw_custom
@@ -833,6 +868,7 @@ with tab_resume:
label_visibility="collapsed",
placeholder=f"Custom: {kw_placeholder}",
)
+ _tag_just_added = False
if kw_btn_col.button("+", key=f"kw_add_{kw_category}", help="Add custom tag"):
cleaned = _filter_tag(kw_raw)
if cleaned is None:
@@ -840,13 +876,19 @@ with tab_resume:
elif cleaned in kw_options:
st.info(f"'{cleaned}' is already in the list — select it above.")
else:
- # Persist custom tag: add to YAML and session state so it appears in options
+ # Save to YAML and set a reset flag so the multiselect session
+ # state is cleared before the widget renders on the next rerun,
+ # allowing `default` (which includes the new tag) to take effect.
kw_new_list = kw_selected + [cleaned]
+ st.session_state[_reset_key] = True
kw_data[kw_category] = kw_new_list
kw_changed = True
+ _tag_just_added = True
- # Detect multiselect changes
- if sorted(kw_selected) != sorted(kw_current):
+ # Detect multiselect changes. Skip when a tag was just added — the change
+ # detection would otherwise overwrite kw_data with the old kw_selected
+ # (which doesn't include the new tag) in the same render.
+ if not _tag_just_added and sorted(kw_selected) != sorted(kw_current):
kw_data[kw_category] = kw_selected
kw_changed = True
@@ -999,6 +1041,11 @@ with tab_system:
_env_path.write_text("\n".join(_env_lines) + "\n")
st.success("Deployment settings saved. Run `./manage.sh restart` to apply.")
+ st.divider()
+ from app.components.ui_switcher import render_settings_toggle as _render_ui_toggle
+ _ui_tier = _profile.tier if _profile else "free"
+ _render_ui_toggle(yaml_path=_USER_YAML, tier=_ui_tier)
+
st.divider()
# ── LLM Backends ─────────────────────────────────────────────────────────
diff --git a/app/wizard/tiers.py b/app/wizard/tiers.py
index 9679843..4a97707 100644
--- a/app/wizard/tiers.py
+++ b/app/wizard/tiers.py
@@ -1,7 +1,7 @@
"""
Tier definitions and feature gates for Peregrine.
-Tiers: free < paid < premium
+Tiers: free < paid < premium < ultra (ultra reserved; no Peregrine features use it yet)
FEATURES maps feature key → minimum tier required.
Features not in FEATURES are available to all tiers (free).
@@ -22,9 +22,14 @@ Features that stay gated even with BYOK:
"""
from __future__ import annotations
+import os as _os
from pathlib import Path
-TIERS = ["free", "paid", "premium"]
+from circuitforge_core.tiers import (
+ can_use as _core_can_use,
+ TIERS,
+ tier_label as _core_tier_label,
+)
# Maps feature key → minimum tier string required.
# Features absent from this dict are free (available to all).
@@ -58,6 +63,9 @@ FEATURES: dict[str, str] = {
"google_calendar_sync": "paid",
"apple_calendar_sync": "paid",
"slack_notifications": "paid",
+
+ # Beta UI access — stays gated (access management, not compute)
+ "vue_ui_beta": "paid",
}
# Features that unlock when the user supplies any LLM backend (local or BYOK).
@@ -75,6 +83,13 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"survey_assistant",
})
+# Demo mode flag — read from environment at module load time.
+# Allows demo toolbar to override tier without accessing st.session_state (thread-safe).
+# _DEMO_MODE is immutable after import for the process lifetime.
+# DEMO_MODE must be set in the environment before the process starts (e.g., via
+# Docker Compose environment:). Runtime toggling is not supported.
+_DEMO_MODE = _os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
+
# Free integrations (not in FEATURES):
# google_drive_sync, dropbox_sync, onedrive_sync, mega_sync,
# nextcloud_sync, discord_notifications, home_assistant
@@ -101,34 +116,40 @@ def has_configured_llm(config_path: Path | None = None) -> bool:
return False
-def can_use(tier: str, feature: str, has_byok: bool = False) -> bool:
+def can_use(
+ tier: str,
+ feature: str,
+ has_byok: bool = False,
+ *,
+ demo_tier: str | None = None,
+) -> bool:
"""Return True if the given tier has access to the feature.
has_byok: pass has_configured_llm() to unlock BYOK_UNLOCKABLE features
for users who supply their own LLM backend regardless of tier.
+ demo_tier: when set AND _DEMO_MODE is True, substitutes for `tier`.
+ Read from st.session_state by the *caller*, not here — keeps
+ this function thread-safe for background tasks and tests.
+
Returns True for unknown features (not gated).
Returns False for unknown/invalid tier strings.
"""
- required = FEATURES.get(feature)
- if required is None:
- return True # not gated — available to all
+ effective_tier = demo_tier if (demo_tier is not None and _DEMO_MODE) else tier
+ # Pass Peregrine's BYOK_UNLOCKABLE via has_byok collapse — core's frozenset is empty
if has_byok and feature in BYOK_UNLOCKABLE:
return True
- try:
- return TIERS.index(tier) >= TIERS.index(required)
- except ValueError:
- return False # invalid tier string
+ return _core_can_use(feature, effective_tier, _features=FEATURES)
def tier_label(feature: str, has_byok: bool = False) -> str:
"""Return a display label for a locked feature, or '' if free/unlocked."""
if has_byok and feature in BYOK_UNLOCKABLE:
return ""
- required = FEATURES.get(feature)
- if required is None:
+ raw = _core_tier_label(feature, _features=FEATURES)
+ if not raw or raw == "free":
return ""
- return "🔒 Paid" if required == "paid" else "⭐ Premium"
+ return "🔒 Paid" if raw == "paid" else "⭐ Premium"
def effective_tier(
diff --git a/compose.cloud.yml b/compose.cloud.yml
index 180b168..16c442e 100644
--- a/compose.cloud.yml
+++ b/compose.cloud.yml
@@ -39,6 +39,14 @@ services:
- "host.docker.internal:host-gateway"
restart: unless-stopped
+ web:
+ build:
+ context: .
+ dockerfile: docker/web/Dockerfile
+ ports:
+ - "8508:80"
+ restart: unless-stopped
+
searxng:
image: searxng/searxng:latest
volumes:
diff --git a/compose.demo.yml b/compose.demo.yml
index 3678321..db1b9f6 100644
--- a/compose.demo.yml
+++ b/compose.demo.yml
@@ -38,6 +38,14 @@ services:
- "host.docker.internal:host-gateway"
restart: unless-stopped
+ web:
+ build:
+ context: .
+ dockerfile: docker/web/Dockerfile
+ ports:
+ - "8507:80"
+ restart: unless-stopped
+
searxng:
image: searxng/searxng:latest
volumes:
diff --git a/compose.yml b/compose.yml
index 186dd97..9127e84 100644
--- a/compose.yml
+++ b/compose.yml
@@ -40,6 +40,14 @@ services:
- "host.docker.internal:host-gateway"
restart: unless-stopped
+ web:
+ build:
+ context: .
+ dockerfile: docker/web/Dockerfile
+ ports:
+ - "${VUE_PORT:-8506}:80"
+ restart: unless-stopped
+
searxng:
image: searxng/searxng:latest
ports:
diff --git a/config/user.yaml.example b/config/user.yaml.example
index b17c083..a2dbe94 100644
--- a/config/user.yaml.example
+++ b/config/user.yaml.example
@@ -43,6 +43,7 @@ dev_tier_override: null # overrides tier locally (for testing only)
wizard_complete: false
wizard_step: 0
dismissed_banners: []
+ui_preference: streamlit # UI preference — "streamlit" (default) or "vue" (Beta: Paid tier)
docs_dir: "~/Documents/JobSearch"
ollama_models_dir: "~/models/ollama"
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
diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile
new file mode 100644
index 0000000..de50164
--- /dev/null
+++ b/docker/web/Dockerfile
@@ -0,0 +1,13 @@
+# Stage 1: build
+FROM node:20-alpine AS build
+WORKDIR /app
+COPY web/package*.json ./
+RUN npm ci --prefer-offline
+COPY web/ ./
+RUN npm run build
+
+# Stage 2: serve
+FROM nginx:alpine
+COPY docker/web/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist /usr/share/nginx/html
+EXPOSE 80
diff --git a/docker/web/nginx.conf b/docker/web/nginx.conf
new file mode 100644
index 0000000..dcbcbb6
--- /dev/null
+++ b/docker/web/nginx.conf
@@ -0,0 +1,18 @@
+server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # SPA fallback
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Cache static assets
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+}
diff --git a/manage.sh b/manage.sh
index 69176c3..e0919d7 100755
--- a/manage.sh
+++ b/manage.sh
@@ -32,6 +32,7 @@ usage() {
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: app)"
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
+ echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing"
echo -e " ${GREEN}test${NC} Run test suite"
echo -e " ${GREEN}e2e [mode]${NC} Run E2E tests (mode: demo|cloud|local, default: demo)"
echo -e " Set E2E_HEADLESS=false to run headed via Xvfb"
@@ -91,6 +92,12 @@ case "$CMD" in
make preflight PROFILE="$PROFILE"
;;
+ models)
+ info "Checking ollama models..."
+ conda run -n job-seeker python scripts/preflight.py --models-only
+ success "Model check complete."
+ ;;
+
start)
info "Starting Peregrine (PROFILE=${PROFILE})..."
make start PROFILE="$PROFILE"
@@ -133,7 +140,7 @@ case "$CMD" in
&& echo "docker compose" \
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
$COMPOSE pull searxng ollama 2>/dev/null || true
- $COMPOSE build app
+ $COMPOSE build app web
success "Update complete. Run './manage.sh restart' to apply."
;;
diff --git a/requirements.txt b/requirements.txt
index 44c5506..8d9b611 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,6 +2,9 @@
# Extracted from environment.yml for Docker pip installs
# Keep in sync with environment.yml
+# ── CircuitForge shared core ───────────────────────────────────────────────
+-e ../circuitforge-core
+
# ── Web UI ────────────────────────────────────────────────────────────────
streamlit>=1.35
watchdog
@@ -78,3 +81,10 @@ lxml
# ── Documentation ────────────────────────────────────────────────────────
mkdocs>=1.5
mkdocs-material>=9.5
+
+# ── Vue SPA API backend ──────────────────────────────────────────────────
+fastapi>=0.100.0
+uvicorn[standard]>=0.20.0
+PyJWT>=2.8.0
+cryptography>=40.0.0
+python-multipart>=0.0.6
diff --git a/scripts/db.py b/scripts/db.py
index 4afbd77..611e5b4 100644
--- a/scripts/db.py
+++ b/scripts/db.py
@@ -9,30 +9,14 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
+from circuitforge_core.db import get_connection as _cf_get_connection
+
DEFAULT_DB = Path(os.environ.get("STAGING_DB", Path(__file__).parent.parent / "staging.db"))
def get_connection(db_path: Path = DEFAULT_DB, key: str = "") -> "sqlite3.Connection":
- """
- Open a database connection.
-
- In cloud mode with a key: uses SQLCipher (AES-256 encrypted, API-identical to sqlite3).
- Otherwise: vanilla sqlite3.
-
- Args:
- db_path: Path to the SQLite/SQLCipher database file.
- key: SQLCipher encryption key (hex string). Empty = unencrypted.
- """
- import os as _os
- cloud_mode = _os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes")
- if cloud_mode and key:
- from pysqlcipher3 import dbapi2 as _sqlcipher
- conn = _sqlcipher.connect(str(db_path))
- conn.execute(f"PRAGMA key='{key}'")
- return conn
- else:
- import sqlite3 as _sqlite3
- return _sqlite3.connect(str(db_path))
+ """Thin shim — delegates to circuitforge_core.db.get_connection."""
+ return _cf_get_connection(db_path, key)
CREATE_JOBS = """
diff --git a/scripts/llm_router.py b/scripts/llm_router.py
index 5b8a469..45f9fc1 100644
--- a/scripts/llm_router.py
+++ b/scripts/llm_router.py
@@ -2,168 +2,18 @@
LLM abstraction layer with priority fallback chain.
Reads config/llm.yaml. Tries backends in order; falls back on any error.
"""
-import os
-import yaml
-import requests
from pathlib import Path
-from openai import OpenAI
+
+from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
CONFIG_PATH = Path(__file__).parent.parent / "config" / "llm.yaml"
-class LLMRouter:
+class LLMRouter(_CoreLLMRouter):
+ """Peregrine-specific LLMRouter — defaults to Peregrine's config/llm.yaml."""
+
def __init__(self, config_path: Path = CONFIG_PATH):
- with open(config_path) as f:
- self.config = yaml.safe_load(f)
-
- def _is_reachable(self, base_url: str) -> bool:
- """Quick health-check ping. Returns True if backend is up."""
- health_url = base_url.rstrip("/").removesuffix("/v1") + "/health"
- try:
- resp = requests.get(health_url, timeout=2)
- return resp.status_code < 500
- except Exception:
- return False
-
- def _resolve_model(self, client: OpenAI, model: str) -> str:
- """Resolve __auto__ to the first model served by vLLM."""
- if model != "__auto__":
- return model
- models = client.models.list()
- return models.data[0].id
-
- def complete(self, prompt: str, system: str | None = None,
- model_override: str | None = None,
- fallback_order: list[str] | None = None,
- images: list[str] | None = None,
- max_tokens: int | None = None) -> str:
- """
- Generate a completion. Tries each backend in fallback_order.
-
- model_override: when set, replaces the configured model for
- openai_compat backends (e.g. pass a research-specific ollama model).
- fallback_order: when set, overrides config fallback_order for this
- call (e.g. pass config["research_fallback_order"] for research tasks).
- images: optional list of base64-encoded PNG/JPG strings. When provided,
- backends without supports_images=true are skipped. vision_service backends
- are only tried when images is provided.
- Raises RuntimeError if all backends are exhausted.
- """
- if os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"):
- raise RuntimeError(
- "AI inference is disabled in the public demo. "
- "Run your own instance to use AI features."
- )
- order = fallback_order if fallback_order is not None else self.config["fallback_order"]
- for name in order:
- backend = self.config["backends"][name]
-
- if not backend.get("enabled", True):
- print(f"[LLMRouter] {name}: disabled, skipping")
- continue
-
- supports_images = backend.get("supports_images", False)
- is_vision_service = backend["type"] == "vision_service"
-
- # vision_service only used when images provided
- if is_vision_service and not images:
- print(f"[LLMRouter] {name}: vision_service skipped (no images)")
- continue
-
- # non-vision backends skipped when images provided and they don't support it
- if images and not supports_images and not is_vision_service:
- print(f"[LLMRouter] {name}: no image support, skipping")
- continue
-
- if is_vision_service:
- if not self._is_reachable(backend["base_url"]):
- print(f"[LLMRouter] {name}: unreachable, skipping")
- continue
- try:
- resp = requests.post(
- backend["base_url"].rstrip("/") + "/analyze",
- json={
- "prompt": prompt,
- "image_base64": images[0] if images else "",
- },
- timeout=60,
- )
- resp.raise_for_status()
- print(f"[LLMRouter] Used backend: {name} (vision_service)")
- return resp.json()["text"]
- except Exception as e:
- print(f"[LLMRouter] {name}: error — {e}, trying next")
- continue
-
- elif backend["type"] == "openai_compat":
- if not self._is_reachable(backend["base_url"]):
- print(f"[LLMRouter] {name}: unreachable, skipping")
- continue
- try:
- client = OpenAI(
- base_url=backend["base_url"],
- api_key=backend.get("api_key") or "any",
- )
- raw_model = model_override or backend["model"]
- model = self._resolve_model(client, raw_model)
- messages = []
- if system:
- messages.append({"role": "system", "content": system})
- if images and supports_images:
- content = [{"type": "text", "text": prompt}]
- for img in images:
- content.append({
- "type": "image_url",
- "image_url": {"url": f"data:image/png;base64,{img}"},
- })
- messages.append({"role": "user", "content": content})
- else:
- messages.append({"role": "user", "content": prompt})
-
- create_kwargs: dict = {"model": model, "messages": messages}
- if max_tokens is not None:
- create_kwargs["max_tokens"] = max_tokens
- resp = client.chat.completions.create(**create_kwargs)
- print(f"[LLMRouter] Used backend: {name} ({model})")
- return resp.choices[0].message.content
-
- except Exception as e:
- print(f"[LLMRouter] {name}: error — {e}, trying next")
- continue
-
- elif backend["type"] == "anthropic":
- api_key = os.environ.get(backend["api_key_env"], "")
- if not api_key:
- print(f"[LLMRouter] {name}: {backend['api_key_env']} not set, skipping")
- continue
- try:
- import anthropic as _anthropic
- client = _anthropic.Anthropic(api_key=api_key)
- if images and supports_images:
- content = []
- for img in images:
- content.append({
- "type": "image",
- "source": {"type": "base64", "media_type": "image/png", "data": img},
- })
- content.append({"type": "text", "text": prompt})
- else:
- content = prompt
- kwargs: dict = {
- "model": backend["model"],
- "max_tokens": 4096,
- "messages": [{"role": "user", "content": content}],
- }
- if system:
- kwargs["system"] = system
- msg = client.messages.create(**kwargs)
- print(f"[LLMRouter] Used backend: {name}")
- return msg.content[0].text
- except Exception as e:
- print(f"[LLMRouter] {name}: error — {e}, trying next")
- continue
-
- raise RuntimeError("All LLM backends exhausted")
+ super().__init__(config_path)
# Module-level singleton for convenience
diff --git a/scripts/user_profile.py b/scripts/user_profile.py
index 456b094..eae7982 100644
--- a/scripts/user_profile.py
+++ b/scripts/user_profile.py
@@ -31,6 +31,7 @@ _DEFAULTS = {
"wizard_complete": False,
"wizard_step": 0,
"dismissed_banners": [],
+ "ui_preference": "streamlit",
"services": {
"streamlit_port": 8501,
"ollama_host": "localhost",
@@ -78,7 +79,37 @@ class UserProfile:
self.wizard_complete: bool = bool(data.get("wizard_complete", False))
self.wizard_step: int = int(data.get("wizard_step", 0))
self.dismissed_banners: list[str] = list(data.get("dismissed_banners", []))
+ raw_pref = data.get("ui_preference", "streamlit")
+ self.ui_preference: str = raw_pref if raw_pref in ("streamlit", "vue") else "streamlit"
self._svc = data["services"]
+ self._path = path
+
+ def save(self) -> None:
+ """Save all profile fields back to user.yaml."""
+ output = {
+ "name": self.name,
+ "email": self.email,
+ "phone": self.phone,
+ "linkedin": self.linkedin,
+ "career_summary": self.career_summary,
+ "candidate_voice": self.candidate_voice,
+ "nda_companies": self.nda_companies,
+ "docs_dir": str(self.docs_dir),
+ "ollama_models_dir": str(self.ollama_models_dir),
+ "vllm_models_dir": str(self.vllm_models_dir),
+ "inference_profile": self.inference_profile,
+ "mission_preferences": self.mission_preferences,
+ "candidate_accessibility_focus": self.candidate_accessibility_focus,
+ "candidate_lgbtq_focus": self.candidate_lgbtq_focus,
+ "tier": self.tier,
+ "dev_tier_override": self.dev_tier_override,
+ "wizard_complete": self.wizard_complete,
+ "wizard_step": self.wizard_step,
+ "dismissed_banners": self.dismissed_banners,
+ "ui_preference": self.ui_preference,
+ "services": self._svc,
+ }
+ self._path.write_text(yaml.dump(output, default_flow_style=False))
# ── Service URLs ──────────────────────────────────────────────────────────
def _url(self, host: str, port: int, ssl: bool) -> str:
diff --git a/tests/test_demo_toolbar.py b/tests/test_demo_toolbar.py
new file mode 100644
index 0000000..c7cb155
--- /dev/null
+++ b/tests/test_demo_toolbar.py
@@ -0,0 +1,82 @@
+"""Tests for app/components/demo_toolbar.py."""
+import sys
+from pathlib import Path
+import pytest
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from app.components.demo_toolbar import (
+ get_simulated_tier,
+ set_simulated_tier,
+ render_demo_toolbar,
+)
+
+
+def test_set_simulated_tier_updates_session_state(monkeypatch):
+ """set_simulated_tier writes to st.session_state.simulated_tier."""
+ session = {}
+ injected = []
+ monkeypatch.setattr("streamlit.components.v1.html", lambda h, height=0: injected.append(h))
+ monkeypatch.setattr("streamlit.session_state", session, raising=False)
+ monkeypatch.setattr("streamlit.rerun", lambda: None)
+
+ set_simulated_tier("premium")
+
+ assert session.get("simulated_tier") == "premium"
+ assert any("prgn_demo_tier=premium" in h for h in injected)
+
+
+def test_set_simulated_tier_invalid_ignored(monkeypatch):
+ """Invalid tier strings are rejected."""
+ session = {}
+ monkeypatch.setattr("streamlit.components.v1.html", lambda h, height=0: None)
+ monkeypatch.setattr("streamlit.session_state", session, raising=False)
+ monkeypatch.setattr("streamlit.rerun", lambda: None)
+
+ set_simulated_tier("ultramax")
+
+ assert "simulated_tier" not in session
+
+
+def test_get_simulated_tier_defaults_to_paid(monkeypatch):
+ """Returns 'paid' when no tier is set yet."""
+ monkeypatch.setattr("streamlit.session_state", {}, raising=False)
+ monkeypatch.setattr("streamlit.query_params", {}, raising=False)
+
+ assert get_simulated_tier() == "paid"
+
+
+def test_get_simulated_tier_reads_session(monkeypatch):
+ """Returns tier from st.session_state when set."""
+ monkeypatch.setattr("streamlit.session_state", {"simulated_tier": "free"}, raising=False)
+ monkeypatch.setattr("streamlit.query_params", {}, raising=False)
+
+ assert get_simulated_tier() == "free"
+
+
+def test_render_demo_toolbar_renders_pills(monkeypatch):
+ """render_demo_toolbar renders tier selection pills."""
+ session = {"simulated_tier": "paid"}
+ calls = []
+
+ def mock_button(label, key=None, type=None, use_container_width=False):
+ calls.append(("button", label, key, type))
+ return False # button not clicked
+
+ monkeypatch.setattr("streamlit.session_state", session, raising=False)
+ monkeypatch.setattr("streamlit.container", lambda: __import__("contextlib").nullcontext())
+ monkeypatch.setattr("streamlit.columns", lambda x: [__import__("contextlib").nullcontext() for _ in x])
+ monkeypatch.setattr("streamlit.caption", lambda x: None)
+ monkeypatch.setattr("streamlit.button", mock_button)
+ monkeypatch.setattr("streamlit.divider", lambda: None)
+
+ render_demo_toolbar()
+
+ # Verify buttons were rendered for all tiers
+ button_calls = [c for c in calls if c[0] == "button"]
+ assert len(button_calls) == 3
+ assert any("Paid ✓" in c[1] for c in button_calls) # current tier marked
+
+ primary_calls = [c for c in button_calls if c[3] == "primary"]
+ assert len(primary_calls) == 1
+ assert "Paid" in primary_calls[0][1]
diff --git a/tests/test_llm_router.py b/tests/test_llm_router.py
index 0d5a897..09451f6 100644
--- a/tests/test_llm_router.py
+++ b/tests/test_llm_router.py
@@ -24,7 +24,7 @@ def test_router_uses_first_reachable_backend():
mock_response.choices[0].message.content = "hello"
with patch.object(router, "_is_reachable", side_effect=[False, True, True, True, True]), \
- patch("scripts.llm_router.OpenAI") as MockOpenAI:
+ patch("circuitforge_core.llm.router.OpenAI") as MockOpenAI:
instance = MockOpenAI.return_value
instance.chat.completions.create.return_value = mock_response
mock_model = MagicMock()
@@ -54,7 +54,7 @@ def test_is_reachable_returns_false_on_connection_error():
router = LLMRouter(CONFIG_PATH)
- with patch("scripts.llm_router.requests.get", side_effect=requests.ConnectionError):
+ with patch("circuitforge_core.llm.router.requests.get", side_effect=requests.ConnectionError):
result = router._is_reachable("http://localhost:9999/v1")
assert result is False
@@ -92,8 +92,8 @@ def test_complete_skips_backend_without_image_support(tmp_path):
mock_resp.status_code = 200
mock_resp.json.return_value = {"text": "B — collaborative"}
- with patch("scripts.llm_router.requests.get") as mock_get, \
- patch("scripts.llm_router.requests.post") as mock_post:
+ with patch("circuitforge_core.llm.router.requests.get") as mock_get, \
+ patch("circuitforge_core.llm.router.requests.post") as mock_post:
# health check returns ok for vision_service
mock_get.return_value = MagicMock(status_code=200)
mock_post.return_value = mock_resp
@@ -127,7 +127,7 @@ def test_complete_without_images_skips_vision_service(tmp_path):
cfg_file.write_text(yaml.dump(cfg))
router = LLMRouter(config_path=cfg_file)
- with patch("scripts.llm_router.requests.post") as mock_post:
+ with patch("circuitforge_core.llm.router.requests.post") as mock_post:
try:
router.complete("text only prompt")
except RuntimeError:
diff --git a/tests/test_ui_switcher.py b/tests/test_ui_switcher.py
new file mode 100644
index 0000000..a742880
--- /dev/null
+++ b/tests/test_ui_switcher.py
@@ -0,0 +1,101 @@
+"""Tests for app/components/ui_switcher.py.
+
+Streamlit is not running during tests — mock all st.* calls.
+"""
+import sys
+from pathlib import Path
+from unittest.mock import patch
+import pytest
+import yaml
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+
+@pytest.fixture
+def profile_yaml(tmp_path):
+ data = {"name": "Test", "ui_preference": "streamlit", "wizard_complete": True}
+ p = tmp_path / "user.yaml"
+ p.write_text(yaml.dump(data))
+ return p
+
+
+def test_sync_cookie_injects_vue_js(profile_yaml, monkeypatch):
+ """When ui_preference is vue, JS sets prgn_ui=vue."""
+ import yaml as _yaml
+ profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
+
+ injected = []
+ monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
+ monkeypatch.setattr("streamlit.query_params", {}, raising=False)
+
+ from app.components.ui_switcher import sync_ui_cookie
+ sync_ui_cookie(profile_yaml, tier="paid")
+
+ assert any("prgn_ui=vue" in s for s in injected)
+
+
+def test_sync_cookie_injects_streamlit_js(profile_yaml, monkeypatch):
+ """When ui_preference is streamlit, JS sets prgn_ui=streamlit."""
+ injected = []
+ monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
+ monkeypatch.setattr("streamlit.query_params", {}, raising=False)
+
+ from app.components.ui_switcher import sync_ui_cookie
+ sync_ui_cookie(profile_yaml, tier="paid")
+
+ assert any("prgn_ui=streamlit" in s for s in injected)
+
+
+def test_sync_cookie_prgn_switch_param_overrides_yaml(profile_yaml, monkeypatch):
+ """?prgn_switch=streamlit in query params resets ui_preference to streamlit."""
+ import yaml as _yaml
+ profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
+
+ injected = []
+ monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
+ monkeypatch.setattr("streamlit.query_params", {"prgn_switch": "streamlit"}, raising=False)
+
+ with patch('app.components.ui_switcher._DEMO_MODE', False):
+ from app.components.ui_switcher import sync_ui_cookie
+ sync_ui_cookie(profile_yaml, tier="paid")
+
+ # user.yaml should now say streamlit
+ saved = _yaml.safe_load(profile_yaml.read_text())
+ assert saved["ui_preference"] == "streamlit"
+ # JS should set cookie to streamlit
+ assert any("prgn_ui=streamlit" in s for s in injected)
+
+
+def test_sync_cookie_downgrades_tier_resets_to_streamlit(profile_yaml, monkeypatch):
+ """Free-tier user with vue preference gets reset to streamlit."""
+ import yaml as _yaml
+ profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
+
+ injected = []
+ monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
+ monkeypatch.setattr("streamlit.query_params", {}, raising=False)
+
+ with patch('app.components.ui_switcher._DEMO_MODE', False):
+ from app.components.ui_switcher import sync_ui_cookie
+ sync_ui_cookie(profile_yaml, tier="free")
+
+ saved = _yaml.safe_load(profile_yaml.read_text())
+ assert saved["ui_preference"] == "streamlit"
+ assert any("prgn_ui=streamlit" in s for s in injected)
+
+
+def test_switch_ui_writes_yaml_and_calls_sync(profile_yaml, monkeypatch):
+ """switch_ui(to='vue') writes user.yaml and calls sync."""
+ import yaml as _yaml
+ synced = []
+ monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: synced.append(html))
+ monkeypatch.setattr("streamlit.query_params", {}, raising=False)
+ monkeypatch.setattr("streamlit.rerun", lambda: None)
+
+ with patch('app.components.ui_switcher._DEMO_MODE', False):
+ from app.components.ui_switcher import switch_ui
+ switch_ui(profile_yaml, to="vue", tier="paid")
+
+ saved = _yaml.safe_load(profile_yaml.read_text())
+ assert saved["ui_preference"] == "vue"
+ assert any("prgn_ui=vue" in s for s in synced)
diff --git a/tests/test_user_profile.py b/tests/test_user_profile.py
index 88c4c88..84c1d72 100644
--- a/tests/test_user_profile.py
+++ b/tests/test_user_profile.py
@@ -106,3 +106,34 @@ def test_effective_tier_no_override(tmp_path):
p.write_text("name: T\nemail: t@t.com\ncareer_summary: x\ntier: paid\n")
u = UserProfile(p)
assert u.effective_tier == "paid"
+
+def test_ui_preference_default(tmp_path):
+ """Fresh profile defaults to streamlit."""
+ p = tmp_path / "user.yaml"
+ p.write_text("name: Test User\n")
+ profile = UserProfile(p)
+ assert profile.ui_preference == "streamlit"
+
+def test_ui_preference_vue(tmp_path):
+ """Saved vue preference loads correctly."""
+ p = tmp_path / "user.yaml"
+ p.write_text("name: Test\nui_preference: vue\n")
+ profile = UserProfile(p)
+ assert profile.ui_preference == "vue"
+
+def test_ui_preference_roundtrip(tmp_path):
+ """Saving ui_preference: vue persists and reloads."""
+ p = tmp_path / "user.yaml"
+ p.write_text("name: Test\n")
+ profile = UserProfile(p)
+ profile.ui_preference = "vue"
+ profile.save()
+ reloaded = UserProfile(p)
+ assert reloaded.ui_preference == "vue"
+
+def test_ui_preference_invalid_falls_back(tmp_path):
+ """Unknown value falls back to streamlit."""
+ p = tmp_path / "user.yaml"
+ p.write_text("name: Test\nui_preference: newui\n")
+ profile = UserProfile(p)
+ assert profile.ui_preference == "streamlit"
diff --git a/tests/test_wizard_tiers.py b/tests/test_wizard_tiers.py
index 325f0b5..660c244 100644
--- a/tests/test_wizard_tiers.py
+++ b/tests/test_wizard_tiers.py
@@ -1,12 +1,16 @@
import sys
from pathlib import Path
+from unittest.mock import patch
+
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES, BYOK_UNLOCKABLE
def test_tiers_list():
- assert TIERS == ["free", "paid", "premium"]
+ # Peregrine uses the core tier list; "ultra" is included but no features require it yet
+ assert TIERS[:3] == ["free", "paid", "premium"]
+ assert "ultra" in TIERS
def test_can_use_free_feature_always():
@@ -112,3 +116,41 @@ def test_byok_false_preserves_original_gating():
# has_byok=False (default) must not change existing behaviour
assert can_use("free", "company_research", has_byok=False) is False
assert can_use("paid", "company_research", has_byok=False) is True
+
+
+# ── Vue UI Beta & Demo Tier tests ──────────────────────────────────────────────
+
+def test_vue_ui_beta_free_tier():
+ assert can_use("free", "vue_ui_beta") is False
+
+
+def test_vue_ui_beta_paid_tier():
+ assert can_use("paid", "vue_ui_beta") is True
+
+
+def test_vue_ui_beta_premium_tier():
+ assert can_use("premium", "vue_ui_beta") is True
+
+
+def test_can_use_demo_tier_overrides_real_tier():
+ # demo_tier="paid" overrides real tier "free" when DEMO_MODE is active
+ with patch('app.wizard.tiers._DEMO_MODE', True):
+ assert can_use("free", "company_research", demo_tier="paid") is True
+
+
+def test_can_use_demo_tier_free_restricts():
+ # demo_tier="free" restricts access even if real tier is "paid"
+ with patch('app.wizard.tiers._DEMO_MODE', True):
+ assert can_use("paid", "model_fine_tuning", demo_tier="free") is False
+
+
+def test_can_use_demo_tier_none_falls_back_to_real():
+ # demo_tier=None means no override regardless of DEMO_MODE
+ with patch('app.wizard.tiers._DEMO_MODE', True):
+ assert can_use("paid", "company_research", demo_tier=None) is True
+
+
+def test_can_use_demo_tier_does_not_affect_non_demo():
+ # When _DEMO_MODE is False, demo_tier is ignored
+ with patch('app.wizard.tiers._DEMO_MODE', False):
+ assert can_use("free", "company_research", demo_tier="paid") is False
diff --git a/web/src/components/ClassicUIButton.vue b/web/src/components/ClassicUIButton.vue
new file mode 100644
index 0000000..c22b3b0
--- /dev/null
+++ b/web/src/components/ClassicUIButton.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
diff --git a/web/src/composables/useFeatureFlag.ts b/web/src/composables/useFeatureFlag.ts
new file mode 100644
index 0000000..9cd2601
--- /dev/null
+++ b/web/src/composables/useFeatureFlag.ts
@@ -0,0 +1,48 @@
+/**
+ * useFeatureFlag — demo toolbar tier display helper.
+ *
+ * Reads the `prgn_demo_tier` cookie set by the Streamlit demo toolbar so the
+ * Vue SPA can visually reflect the simulated tier (e.g. in ClassicUIButton
+ * or feature-locked UI hints).
+ *
+ * ⚠️ NOT an authoritative feature gate. This is demo-only visual consistency.
+ * Production feature gating will use a future /api/features endpoint (issue #8).
+ * All real access control lives in the Python tier system (app/wizard/tiers.py).
+ */
+import { computed } from 'vue'
+
+const VALID_TIERS = ['free', 'paid', 'premium'] as const
+type Tier = (typeof VALID_TIERS)[number]
+
+function _readDemoTierCookie(): Tier | null {
+ const match = document.cookie
+ .split('; ')
+ .find((row) => row.startsWith('prgn_demo_tier='))
+ if (!match) return null
+ const value = match.split('=')[1] as Tier
+ return VALID_TIERS.includes(value) ? value : null
+}
+
+/**
+ * Returns the simulated demo tier from the `prgn_demo_tier` cookie,
+ * or `null` when not in demo mode (cookie absent).
+ *
+ * Use for visual indicators only — never for access control.
+ */
+export function useFeatureFlag() {
+ const demoTier = computed(() => _readDemoTierCookie())
+
+ const isDemoMode = computed(() => demoTier.value !== null)
+
+ /**
+ * Returns true if the simulated demo tier meets `required`.
+ * Always returns false outside demo mode.
+ */
+ function demoCanUse(required: Tier): boolean {
+ const order: Tier[] = ['free', 'paid', 'premium']
+ if (!demoTier.value) return false
+ return order.indexOf(demoTier.value) >= order.indexOf(required)
+ }
+
+ return { demoTier, isDemoMode, demoCanUse }
+}