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
|
## [0.6.2] — 2026-03-18
|
||||||
|
|
||||||
### Added
|
### 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
|
import streamlit as st
|
||||||
from scripts.db import DEFAULT_DB, init_db, get_active_tasks
|
from scripts.db import DEFAULT_DB, init_db, get_active_tasks
|
||||||
from app.feedback import inject_feedback_button
|
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
|
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(
|
st.set_page_config(
|
||||||
page_title="Peregrine",
|
page_title="Peregrine",
|
||||||
page_icon="💼",
|
page_icon=str(_LOGO_CIRCLE) if _LOGO_CIRCLE.exists() else "💼",
|
||||||
layout="wide",
|
layout="wide",
|
||||||
)
|
)
|
||||||
|
|
||||||
resolve_session("peregrine")
|
resolve_session("peregrine")
|
||||||
init_db(get_db_path())
|
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 ──────────
|
# ── Startup cleanup — runs once per server process via cache_resource ──────────
|
||||||
@st.cache_resource
|
@st.cache_resource
|
||||||
def _startup() -> None:
|
def _startup() -> None:
|
||||||
|
|
@ -89,6 +99,15 @@ _show_wizard = not IS_DEMO and (
|
||||||
if _show_wizard:
|
if _show_wizard:
|
||||||
_setup_page = st.Page("pages/0_Setup.py", title="Setup", icon="👋")
|
_setup_page = st.Page("pages/0_Setup.py", title="Setup", icon="👋")
|
||||||
st.navigation({"": [_setup_page]}).run()
|
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()
|
st.stop()
|
||||||
|
|
||||||
# ── Navigation ─────────────────────────────────────────────────────────────────
|
# ── Navigation ─────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -113,6 +132,21 @@ pg = st.navigation(pages)
|
||||||
# ── Background task sidebar indicator ─────────────────────────────────────────
|
# ── Background task sidebar indicator ─────────────────────────────────────────
|
||||||
# Fragment polls every 3s so stage labels update live without a full page reload.
|
# 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.
|
# 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)
|
@st.fragment(run_every=3)
|
||||||
def _task_indicator():
|
def _task_indicator():
|
||||||
tasks = get_active_tasks(get_db_path())
|
tasks = get_active_tasks(get_db_path())
|
||||||
|
|
@ -120,27 +154,30 @@ def _task_indicator():
|
||||||
return
|
return
|
||||||
st.divider()
|
st.divider()
|
||||||
st.markdown(f"**⏳ {len(tasks)} task(s) running**")
|
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 "🕐"
|
icon = "⏳" if t["status"] == "running" else "🕐"
|
||||||
task_type = t["task_type"]
|
label = _TASK_LABELS.get(t["task_type"], t["task_type"].replace("_", " ").title())
|
||||||
if task_type == "cover_letter":
|
stage = t.get("stage") or ""
|
||||||
label = "Cover letter"
|
detail = f" · {stage}" if stage else ""
|
||||||
elif task_type == "company_research":
|
prefix = "" if i == 0 else "↳ "
|
||||||
label = "Research"
|
st.caption(f"{prefix}{icon} {label}{detail}")
|
||||||
elif task_type == "email_sync":
|
|
||||||
label = "Email sync"
|
# All other tasks (cover letter, email sync, etc.) as individual rows
|
||||||
elif task_type == "discovery":
|
for t in other_tasks:
|
||||||
label = "Discovery"
|
icon = "⏳" if t["status"] == "running" else "🕐"
|
||||||
elif task_type == "enrich_descriptions":
|
label = _TASK_LABELS.get(t["task_type"], t["task_type"].replace("_", " ").title())
|
||||||
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 ""
|
stage = t.get("stage") or ""
|
||||||
detail = f" · {stage}" if stage else (f" — {t.get('company')}" if t.get("company") else "")
|
detail = f" · {stage}" if stage else (f" — {t.get('company')}" if t.get("company") else "")
|
||||||
st.caption(f"{icon} {label}{detail}")
|
st.caption(f"{icon} {label}{detail}")
|
||||||
|
|
@ -156,6 +193,13 @@ def _get_version() -> str:
|
||||||
except Exception:
|
except Exception:
|
||||||
return "dev"
|
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:
|
with st.sidebar:
|
||||||
if IS_DEMO:
|
if IS_DEMO:
|
||||||
st.info(
|
st.info(
|
||||||
|
|
@ -185,7 +229,31 @@ with st.sidebar:
|
||||||
)
|
)
|
||||||
|
|
||||||
st.divider()
|
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()}")
|
st.caption(f"Peregrine {_get_version()}")
|
||||||
inject_feedback_button(page=pg.title)
|
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()
|
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",
|
_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.")
|
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(
|
st.multiselect(
|
||||||
"Job titles",
|
"Job titles",
|
||||||
options=st.session_state.get("_sp_title_options", p.get("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.",
|
help="Select from known titles. Suggestions from ✨ Suggest appear here — pick the ones you want.",
|
||||||
label_visibility="collapsed",
|
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])
|
_add_t_col, _add_t_btn = st.columns([5, 1])
|
||||||
with _add_t_col:
|
with _add_t_col:
|
||||||
st.text_input("Add a title", key="_sp_new_title", label_visibility="collapsed",
|
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_current: list[str] = kw_data.get(kw_category, [])
|
||||||
kw_suggestions = _load_sugg(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
|
# 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_custom = [t for t in kw_current if t not in kw_suggestions]
|
||||||
kw_options = kw_suggestions + kw_custom
|
kw_options = kw_suggestions + kw_custom
|
||||||
|
|
@ -833,6 +868,7 @@ with tab_resume:
|
||||||
label_visibility="collapsed",
|
label_visibility="collapsed",
|
||||||
placeholder=f"Custom: {kw_placeholder}",
|
placeholder=f"Custom: {kw_placeholder}",
|
||||||
)
|
)
|
||||||
|
_tag_just_added = False
|
||||||
if kw_btn_col.button("+", key=f"kw_add_{kw_category}", help="Add custom tag"):
|
if kw_btn_col.button("+", key=f"kw_add_{kw_category}", help="Add custom tag"):
|
||||||
cleaned = _filter_tag(kw_raw)
|
cleaned = _filter_tag(kw_raw)
|
||||||
if cleaned is None:
|
if cleaned is None:
|
||||||
|
|
@ -840,13 +876,19 @@ with tab_resume:
|
||||||
elif cleaned in kw_options:
|
elif cleaned in kw_options:
|
||||||
st.info(f"'{cleaned}' is already in the list — select it above.")
|
st.info(f"'{cleaned}' is already in the list — select it above.")
|
||||||
else:
|
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]
|
kw_new_list = kw_selected + [cleaned]
|
||||||
|
st.session_state[_reset_key] = True
|
||||||
kw_data[kw_category] = kw_new_list
|
kw_data[kw_category] = kw_new_list
|
||||||
kw_changed = True
|
kw_changed = True
|
||||||
|
_tag_just_added = True
|
||||||
|
|
||||||
# Detect multiselect changes
|
# Detect multiselect changes. Skip when a tag was just added — the change
|
||||||
if sorted(kw_selected) != sorted(kw_current):
|
# 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_data[kw_category] = kw_selected
|
||||||
kw_changed = True
|
kw_changed = True
|
||||||
|
|
||||||
|
|
@ -999,6 +1041,11 @@ with tab_system:
|
||||||
_env_path.write_text("\n".join(_env_lines) + "\n")
|
_env_path.write_text("\n".join(_env_lines) + "\n")
|
||||||
st.success("Deployment settings saved. Run `./manage.sh restart` to apply.")
|
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()
|
st.divider()
|
||||||
|
|
||||||
# ── LLM Backends ─────────────────────────────────────────────────────────
|
# ── LLM Backends ─────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Tier definitions and feature gates for Peregrine.
|
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 maps feature key → minimum tier required.
|
||||||
Features not in FEATURES are available to all tiers (free).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os as _os
|
||||||
from pathlib import Path
|
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.
|
# Maps feature key → minimum tier string required.
|
||||||
# Features absent from this dict are free (available to all).
|
# Features absent from this dict are free (available to all).
|
||||||
|
|
@ -58,6 +63,9 @@ FEATURES: dict[str, str] = {
|
||||||
"google_calendar_sync": "paid",
|
"google_calendar_sync": "paid",
|
||||||
"apple_calendar_sync": "paid",
|
"apple_calendar_sync": "paid",
|
||||||
"slack_notifications": "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).
|
# Features that unlock when the user supplies any LLM backend (local or BYOK).
|
||||||
|
|
@ -75,6 +83,13 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"survey_assistant",
|
"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):
|
# Free integrations (not in FEATURES):
|
||||||
# google_drive_sync, dropbox_sync, onedrive_sync, mega_sync,
|
# google_drive_sync, dropbox_sync, onedrive_sync, mega_sync,
|
||||||
# nextcloud_sync, discord_notifications, home_assistant
|
# nextcloud_sync, discord_notifications, home_assistant
|
||||||
|
|
@ -101,34 +116,40 @@ def has_configured_llm(config_path: Path | None = None) -> bool:
|
||||||
return False
|
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.
|
"""Return True if the given tier has access to the feature.
|
||||||
|
|
||||||
has_byok: pass has_configured_llm() to unlock BYOK_UNLOCKABLE features
|
has_byok: pass has_configured_llm() to unlock BYOK_UNLOCKABLE features
|
||||||
for users who supply their own LLM backend regardless of tier.
|
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 True for unknown features (not gated).
|
||||||
Returns False for unknown/invalid tier strings.
|
Returns False for unknown/invalid tier strings.
|
||||||
"""
|
"""
|
||||||
required = FEATURES.get(feature)
|
effective_tier = demo_tier if (demo_tier is not None and _DEMO_MODE) else tier
|
||||||
if required is None:
|
# Pass Peregrine's BYOK_UNLOCKABLE via has_byok collapse — core's frozenset is empty
|
||||||
return True # not gated — available to all
|
|
||||||
if has_byok and feature in BYOK_UNLOCKABLE:
|
if has_byok and feature in BYOK_UNLOCKABLE:
|
||||||
return True
|
return True
|
||||||
try:
|
return _core_can_use(feature, effective_tier, _features=FEATURES)
|
||||||
return TIERS.index(tier) >= TIERS.index(required)
|
|
||||||
except ValueError:
|
|
||||||
return False # invalid tier string
|
|
||||||
|
|
||||||
|
|
||||||
def tier_label(feature: str, has_byok: bool = False) -> str:
|
def tier_label(feature: str, has_byok: bool = False) -> str:
|
||||||
"""Return a display label for a locked feature, or '' if free/unlocked."""
|
"""Return a display label for a locked feature, or '' if free/unlocked."""
|
||||||
if has_byok and feature in BYOK_UNLOCKABLE:
|
if has_byok and feature in BYOK_UNLOCKABLE:
|
||||||
return ""
|
return ""
|
||||||
required = FEATURES.get(feature)
|
raw = _core_tier_label(feature, _features=FEATURES)
|
||||||
if required is None:
|
if not raw or raw == "free":
|
||||||
return ""
|
return ""
|
||||||
return "🔒 Paid" if required == "paid" else "⭐ Premium"
|
return "🔒 Paid" if raw == "paid" else "⭐ Premium"
|
||||||
|
|
||||||
|
|
||||||
def effective_tier(
|
def effective_tier(
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,14 @@ services:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/web/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8508:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ services:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/web/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8507:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@ services:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/web/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${VUE_PORT:-8506}:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
image: searxng/searxng:latest
|
image: searxng/searxng:latest
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ dev_tier_override: null # overrides tier locally (for testing only)
|
||||||
wizard_complete: false
|
wizard_complete: false
|
||||||
wizard_step: 0
|
wizard_step: 0
|
||||||
dismissed_banners: []
|
dismissed_banners: []
|
||||||
|
ui_preference: streamlit # UI preference — "streamlit" (default) or "vue" (Beta: Paid tier)
|
||||||
|
|
||||||
docs_dir: "~/Documents/JobSearch"
|
docs_dir: "~/Documents/JobSearch"
|
||||||
ollama_models_dir: "~/models/ollama"
|
ollama_models_dir: "~/models/ollama"
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ mission_preferences:
|
||||||
social_impact: Want my work to reach people who need it most.
|
social_impact: Want my work to reach people who need it most.
|
||||||
name: Demo User
|
name: Demo User
|
||||||
nda_companies: []
|
nda_companies: []
|
||||||
ollama_models_dir: ~/models/ollama
|
ollama_models_dir: /root/models/ollama
|
||||||
phone: ''
|
phone: ''
|
||||||
services:
|
services:
|
||||||
ollama_host: localhost
|
ollama_host: localhost
|
||||||
|
|
@ -39,6 +39,7 @@ services:
|
||||||
vllm_ssl: false
|
vllm_ssl: false
|
||||||
vllm_ssl_verify: true
|
vllm_ssl_verify: true
|
||||||
tier: free
|
tier: free
|
||||||
vllm_models_dir: ~/models/vllm
|
ui_preference: streamlit
|
||||||
|
vllm_models_dir: /root/models/vllm
|
||||||
wizard_complete: true
|
wizard_complete: true
|
||||||
wizard_step: 0
|
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}logs [service]${NC} Tail logs (default: app)"
|
||||||
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
|
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
|
||||||
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
|
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}test${NC} Run test suite"
|
||||||
echo -e " ${GREEN}e2e [mode]${NC} Run E2E tests (mode: demo|cloud|local, default: demo)"
|
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"
|
echo -e " Set E2E_HEADLESS=false to run headed via Xvfb"
|
||||||
|
|
@ -91,6 +92,12 @@ case "$CMD" in
|
||||||
make preflight PROFILE="$PROFILE"
|
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)
|
start)
|
||||||
info "Starting Peregrine (PROFILE=${PROFILE})..."
|
info "Starting Peregrine (PROFILE=${PROFILE})..."
|
||||||
make start PROFILE="$PROFILE"
|
make start PROFILE="$PROFILE"
|
||||||
|
|
@ -133,7 +140,7 @@ case "$CMD" in
|
||||||
&& echo "docker compose" \
|
&& echo "docker compose" \
|
||||||
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
|
||||||
$COMPOSE pull searxng ollama 2>/dev/null || true
|
$COMPOSE pull searxng ollama 2>/dev/null || true
|
||||||
$COMPOSE build app
|
$COMPOSE build app web
|
||||||
success "Update complete. Run './manage.sh restart' to apply."
|
success "Update complete. Run './manage.sh restart' to apply."
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
# Extracted from environment.yml for Docker pip installs
|
# Extracted from environment.yml for Docker pip installs
|
||||||
# Keep in sync with environment.yml
|
# Keep in sync with environment.yml
|
||||||
|
|
||||||
|
# ── CircuitForge shared core ───────────────────────────────────────────────
|
||||||
|
-e ../circuitforge-core
|
||||||
|
|
||||||
# ── Web UI ────────────────────────────────────────────────────────────────
|
# ── Web UI ────────────────────────────────────────────────────────────────
|
||||||
streamlit>=1.35
|
streamlit>=1.35
|
||||||
watchdog
|
watchdog
|
||||||
|
|
@ -78,3 +81,10 @@ lxml
|
||||||
# ── Documentation ────────────────────────────────────────────────────────
|
# ── Documentation ────────────────────────────────────────────────────────
|
||||||
mkdocs>=1.5
|
mkdocs>=1.5
|
||||||
mkdocs-material>=9.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 pathlib import Path
|
||||||
from typing import Optional
|
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"))
|
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":
|
def get_connection(db_path: Path = DEFAULT_DB, key: str = "") -> "sqlite3.Connection":
|
||||||
"""
|
"""Thin shim — delegates to circuitforge_core.db.get_connection."""
|
||||||
Open a database connection.
|
return _cf_get_connection(db_path, key)
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
CREATE_JOBS = """
|
CREATE_JOBS = """
|
||||||
|
|
|
||||||
|
|
@ -2,168 +2,18 @@
|
||||||
LLM abstraction layer with priority fallback chain.
|
LLM abstraction layer with priority fallback chain.
|
||||||
Reads config/llm.yaml. Tries backends in order; falls back on any error.
|
Reads config/llm.yaml. Tries backends in order; falls back on any error.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import yaml
|
|
||||||
import requests
|
|
||||||
from pathlib import Path
|
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"
|
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):
|
def __init__(self, config_path: Path = CONFIG_PATH):
|
||||||
with open(config_path) as f:
|
super().__init__(config_path)
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton for convenience
|
# Module-level singleton for convenience
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ _DEFAULTS = {
|
||||||
"wizard_complete": False,
|
"wizard_complete": False,
|
||||||
"wizard_step": 0,
|
"wizard_step": 0,
|
||||||
"dismissed_banners": [],
|
"dismissed_banners": [],
|
||||||
|
"ui_preference": "streamlit",
|
||||||
"services": {
|
"services": {
|
||||||
"streamlit_port": 8501,
|
"streamlit_port": 8501,
|
||||||
"ollama_host": "localhost",
|
"ollama_host": "localhost",
|
||||||
|
|
@ -78,7 +79,37 @@ class UserProfile:
|
||||||
self.wizard_complete: bool = bool(data.get("wizard_complete", False))
|
self.wizard_complete: bool = bool(data.get("wizard_complete", False))
|
||||||
self.wizard_step: int = int(data.get("wizard_step", 0))
|
self.wizard_step: int = int(data.get("wizard_step", 0))
|
||||||
self.dismissed_banners: list[str] = list(data.get("dismissed_banners", []))
|
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._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 ──────────────────────────────────────────────────────────
|
# ── Service URLs ──────────────────────────────────────────────────────────
|
||||||
def _url(self, host: str, port: int, ssl: bool) -> str:
|
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"
|
mock_response.choices[0].message.content = "hello"
|
||||||
|
|
||||||
with patch.object(router, "_is_reachable", side_effect=[False, True, True, True, True]), \
|
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 = MockOpenAI.return_value
|
||||||
instance.chat.completions.create.return_value = mock_response
|
instance.chat.completions.create.return_value = mock_response
|
||||||
mock_model = MagicMock()
|
mock_model = MagicMock()
|
||||||
|
|
@ -54,7 +54,7 @@ def test_is_reachable_returns_false_on_connection_error():
|
||||||
|
|
||||||
router = LLMRouter(CONFIG_PATH)
|
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")
|
result = router._is_reachable("http://localhost:9999/v1")
|
||||||
|
|
||||||
assert result is False
|
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.status_code = 200
|
||||||
mock_resp.json.return_value = {"text": "B — collaborative"}
|
mock_resp.json.return_value = {"text": "B — collaborative"}
|
||||||
|
|
||||||
with patch("scripts.llm_router.requests.get") as mock_get, \
|
with patch("circuitforge_core.llm.router.requests.get") as mock_get, \
|
||||||
patch("scripts.llm_router.requests.post") as mock_post:
|
patch("circuitforge_core.llm.router.requests.post") as mock_post:
|
||||||
# health check returns ok for vision_service
|
# health check returns ok for vision_service
|
||||||
mock_get.return_value = MagicMock(status_code=200)
|
mock_get.return_value = MagicMock(status_code=200)
|
||||||
mock_post.return_value = mock_resp
|
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))
|
cfg_file.write_text(yaml.dump(cfg))
|
||||||
|
|
||||||
router = LLMRouter(config_path=cfg_file)
|
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:
|
try:
|
||||||
router.complete("text only prompt")
|
router.complete("text only prompt")
|
||||||
except RuntimeError:
|
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")
|
p.write_text("name: T\nemail: t@t.com\ncareer_summary: x\ntier: paid\n")
|
||||||
u = UserProfile(p)
|
u = UserProfile(p)
|
||||||
assert u.effective_tier == "paid"
|
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
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES, BYOK_UNLOCKABLE
|
from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES, BYOK_UNLOCKABLE
|
||||||
|
|
||||||
|
|
||||||
def test_tiers_list():
|
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():
|
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
|
# has_byok=False (default) must not change existing behaviour
|
||||||
assert can_use("free", "company_research", has_byok=False) is False
|
assert can_use("free", "company_research", has_byok=False) is False
|
||||||
assert can_use("paid", "company_research", has_byok=False) is True
|
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