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 } +}