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:
commit
931a07d4e0
25 changed files with 1019 additions and 225 deletions
44
CHANGELOG.md
44
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
|
||||
|
|
|
|||
112
app/app.py
112
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,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
|
||||
|
|
|
|||
72
app/components/demo_toolbar.py
Normal file
72
app/components/demo_toolbar.py
Normal 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()
|
||||
251
app/components/ui_switcher.py
Normal file
251
app/components/ui_switcher.py
Normal 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)
|
||||
|
|
@ -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' ↑ {_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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
13
docker/web/Dockerfile
Normal 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
18
docker/web/nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
;;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
82
tests/test_demo_toolbar.py
Normal file
82
tests/test_demo_toolbar.py
Normal 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]
|
||||
|
|
@ -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
101
tests/test_ui_switcher.py
Normal 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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
web/src/components/ClassicUIButton.vue
Normal file
48
web/src/components/ClassicUIButton.vue
Normal 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>
|
||||
48
web/src/composables/useFeatureFlag.ts
Normal file
48
web/src/composables/useFeatureFlag.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Reference in a new issue