chore(merge): merge main into feature/vue-spa — resolve ApplyWorkspace conflict

ApplyWorkspace.vue: kept HEAD (vue-spa) version for resume optimizer panel,
cl-error__actions wrapper, and ResumeOptimizerPanel import. main's older
version lacked these additions.
This commit is contained in:
pyr0ball 2026-03-31 21:25:15 -07:00
commit 931a07d4e0
25 changed files with 1019 additions and 225 deletions

View file

@ -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

View file

@ -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,27 +154,30 @@ def _task_indicator():
return
st.divider()
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 "🕐"
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()
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

View file

@ -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 = """
<script>
(function() {{
document.cookie = 'prgn_demo_tier={tier}; path=/; SameSite=Lax';
}})();
</script>
"""
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()

View file

@ -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 = """
<script>
(function() {{
document.cookie = 'prgn_ui={value}; path=/; SameSite=Lax';
{navigate_js}
}})();
</script>
"""
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=<value> 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)

View file

@ -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"""<style>
@keyframes _pg_arrow_float {{
0%, 100% {{
transform: translateY(0px);
filter: drop-shadow(0 0 2px #4fc3f7);
}}
50% {{
transform: translateY(4px);
filter: drop-shadow(0 0 8px #4fc3f7);
}}
}}
/* Target the expand-arrow SVG inside the multiselect dropdown indicator */
.stMultiSelect [data-baseweb="select"] > div + div svg {{
animation: _pg_arrow_float 1.3s ease-in-out infinite;
cursor: pointer;
}}
</style>""", 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'<div style="font-size:0.8em; color:#4fc3f7; margin-top:-10px; margin-bottom:4px;">'
f'&nbsp;↑&nbsp;{_title_sugg_count} new suggestion{"s" if _title_sugg_count != 1 else ""} '
f'added — open the dropdown to browse</div>',
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 ─────────────────────────────────────────────────────────

View file

@ -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(

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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"

View file

@ -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

13
docker/web/Dockerfile Normal file
View file

@ -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

18
docker/web/nginx.conf Normal file
View file

@ -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";
}
}

View file

@ -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."
;;

View file

@ -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

View file

@ -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 = """

View file

@ -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

View file

@ -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:

View file

@ -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]

View file

@ -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:

101
tests/test_ui_switcher.py Normal file
View file

@ -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)

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,48 @@
<template>
<button
class="classic-ui-btn"
:title="label"
@click="switchToClassic"
>
{{ label }}
</button>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
label?: string
}>(), {
label: 'Switch to Classic UI',
})
function switchToClassic(): void {
// Set cookie so Caddy routes next request to Streamlit
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
// Append ?prgn_switch=streamlit so Streamlit's sync_ui_cookie()
// updates user.yaml to match cookie alone can't be read server-side
const url = new URL(window.location.href)
url.searchParams.set('prgn_switch', 'streamlit')
window.location.href = url.toString()
}
</script>
<style scoped>
.classic-ui-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.9rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, #444);
background: transparent;
color: var(--color-text-muted, #aaa);
font-size: 0.8rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.classic-ui-btn:hover {
color: var(--color-text, #eee);
border-color: var(--color-text, #eee);
}
</style>

View file

@ -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<Tier | null>(() => _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 }
}