Merge pull request 'feat(wizard): Vue onboarding wizard + user config isolation fixes' (#65) from feature/vue-wizard into main
Some checks failed
CI / test (push) Failing after 26s

This commit is contained in:
pyr0ball 2026-04-02 18:46:42 -07:00
commit 9069447cfc
16 changed files with 2723 additions and 34 deletions

View file

@ -947,12 +947,23 @@ def get_app_config():
valid_profiles = {"remote", "cpu", "single-gpu", "dual-gpu"} valid_profiles = {"remote", "cpu", "single-gpu", "dual-gpu"}
valid_tiers = {"free", "paid", "premium", "ultra"} valid_tiers = {"free", "paid", "premium", "ultra"}
raw_tier = os.environ.get("APP_TIER", "free") raw_tier = os.environ.get("APP_TIER", "free")
# wizard_complete: read from user.yaml so the guard reflects live state
wizard_complete = True
try:
cfg = load_user_profile(_user_yaml_path())
wizard_complete = bool(cfg.get("wizard_complete", False))
except Exception:
wizard_complete = False
return { return {
"isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"), "isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"),
"isDemo": os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes"),
"isDevMode": os.environ.get("DEV_MODE", "").lower() in ("1", "true"), "isDevMode": os.environ.get("DEV_MODE", "").lower() in ("1", "true"),
"tier": raw_tier if raw_tier in valid_tiers else "free", "tier": raw_tier if raw_tier in valid_tiers else "free",
"contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"), "contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"),
"inferenceProfile": profile if profile in valid_profiles else "cpu", "inferenceProfile": profile if profile in valid_profiles else "cpu",
"wizardComplete": wizard_complete,
} }
@ -977,12 +988,13 @@ from scripts.user_profile import load_user_profile, save_user_profile
def _user_yaml_path() -> str: def _user_yaml_path() -> str:
"""Resolve user.yaml path relative to the current STAGING_DB location.""" """Resolve user.yaml path relative to the current STAGING_DB location.
db = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
cfg_path = os.path.join(os.path.dirname(db), "config", "user.yaml") Never falls back to another user's config directory — callers must handle
if not os.path.exists(cfg_path): a missing file gracefully (return defaults / empty wizard state).
cfg_path = "/devl/job-seeker/config/user.yaml" """
return cfg_path db = os.environ.get("STAGING_DB", "/devl/peregrine/staging.db")
return os.path.join(os.path.dirname(db), "config", "user.yaml")
def _mission_dict_to_list(prefs: object) -> list: def _mission_dict_to_list(prefs: object) -> list:
@ -1105,14 +1117,17 @@ class ResumePayload(BaseModel):
veteran_status: str = ""; disability: str = "" veteran_status: str = ""; disability: str = ""
skills: List[str] = []; domains: List[str] = []; keywords: List[str] = [] skills: List[str] = []; domains: List[str] = []; keywords: List[str] = []
RESUME_PATH = Path("config/plain_text_resume.yaml") def _resume_path() -> Path:
"""Resolve plain_text_resume.yaml co-located with user.yaml (user-isolated)."""
return Path(_user_yaml_path()).parent / "plain_text_resume.yaml"
@app.get("/api/settings/resume") @app.get("/api/settings/resume")
def get_resume(): def get_resume():
try: try:
if not RESUME_PATH.exists(): resume_path = _resume_path()
if not resume_path.exists():
return {"exists": False} return {"exists": False}
with open(RESUME_PATH) as f: with open(resume_path) as f:
data = yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
data["exists"] = True data["exists"] = True
return data return data
@ -1122,8 +1137,9 @@ def get_resume():
@app.put("/api/settings/resume") @app.put("/api/settings/resume")
def save_resume(payload: ResumePayload): def save_resume(payload: ResumePayload):
try: try:
RESUME_PATH.parent.mkdir(parents=True, exist_ok=True) resume_path = _resume_path()
with open(RESUME_PATH, "w") as f: resume_path.parent.mkdir(parents=True, exist_ok=True)
with open(resume_path, "w") as f:
yaml.dump(payload.model_dump(), f, allow_unicode=True, default_flow_style=False) yaml.dump(payload.model_dump(), f, allow_unicode=True, default_flow_style=False)
return {"ok": True} return {"ok": True}
except Exception as e: except Exception as e:
@ -1132,9 +1148,10 @@ def save_resume(payload: ResumePayload):
@app.post("/api/settings/resume/blank") @app.post("/api/settings/resume/blank")
def create_blank_resume(): def create_blank_resume():
try: try:
RESUME_PATH.parent.mkdir(parents=True, exist_ok=True) resume_path = _resume_path()
if not RESUME_PATH.exists(): resume_path.parent.mkdir(parents=True, exist_ok=True)
with open(RESUME_PATH, "w") as f: if not resume_path.exists():
with open(resume_path, "w") as f:
yaml.dump({}, f) yaml.dump({}, f)
return {"ok": True} return {"ok": True}
except Exception as e: except Exception as e:
@ -1143,18 +1160,23 @@ def create_blank_resume():
@app.post("/api/settings/resume/upload") @app.post("/api/settings/resume/upload")
async def upload_resume(file: UploadFile): async def upload_resume(file: UploadFile):
try: try:
from scripts.resume_parser import structure_resume from scripts.resume_parser import (
import tempfile, os extract_text_from_pdf,
extract_text_from_docx,
extract_text_from_odt,
structure_resume,
)
suffix = Path(file.filename).suffix.lower() suffix = Path(file.filename).suffix.lower()
tmp_path = None file_bytes = await file.read()
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read()) if suffix == ".pdf":
tmp_path = tmp.name raw_text = extract_text_from_pdf(file_bytes)
try: elif suffix == ".odt":
result, err = structure_resume(tmp_path) raw_text = extract_text_from_odt(file_bytes)
finally: else:
if tmp_path: raw_text = extract_text_from_docx(file_bytes)
os.unlink(tmp_path)
result, err = structure_resume(raw_text)
if err: if err:
return {"ok": False, "error": err, "data": result} return {"ok": False, "error": err, "data": result}
result["exists"] = True result["exists"] = True
@ -1797,3 +1819,288 @@ def export_classifier():
return {"ok": True, "count": len(emails), "path": str(export_path)} return {"ok": True, "count": len(emails), "path": str(export_path)}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ── Wizard API ────────────────────────────────────────────────────────────────
#
# These endpoints back the Vue SPA first-run onboarding wizard.
# State is persisted to user.yaml on every step so the wizard can resume
# after a browser refresh or crash (mirrors the Streamlit wizard behaviour).
_WIZARD_PROFILES = ("remote", "cpu", "single-gpu", "dual-gpu")
_WIZARD_TIERS = ("free", "paid", "premium")
def _wizard_yaml_path() -> str:
"""Same resolution logic as _user_yaml_path() — single source of truth."""
return _user_yaml_path()
def _load_wizard_yaml() -> dict:
try:
return load_user_profile(_wizard_yaml_path()) or {}
except Exception:
return {}
def _save_wizard_yaml(updates: dict) -> None:
path = _wizard_yaml_path()
existing = _load_wizard_yaml()
existing.update(updates)
save_user_profile(path, existing)
def _detect_gpus() -> list[str]:
"""Detect GPUs. Prefers PEREGRINE_GPU_NAMES env var (set by preflight)."""
env_names = os.environ.get("PEREGRINE_GPU_NAMES", "").strip()
if env_names:
return [n.strip() for n in env_names.split(",") if n.strip()]
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
text=True, timeout=5,
)
return [line.strip() for line in out.strip().splitlines() if line.strip()]
except Exception:
return []
def _suggest_profile(gpus: list[str]) -> str:
recommended = os.environ.get("RECOMMENDED_PROFILE", "").strip()
if recommended and recommended in _WIZARD_PROFILES:
return recommended
if len(gpus) >= 2:
return "dual-gpu"
if len(gpus) == 1:
return "single-gpu"
return "remote"
@app.get("/api/wizard/status")
def wizard_status():
"""Return current wizard state for resume-after-refresh.
wizard_complete=True means the wizard has been finished and the app
should not redirect to /setup. wizard_step is the last completed step
(0 = not started); the SPA advances to step+1 on load.
"""
cfg = _load_wizard_yaml()
return {
"wizard_complete": bool(cfg.get("wizard_complete", False)),
"wizard_step": int(cfg.get("wizard_step", 0)),
"saved_data": {
"inference_profile": cfg.get("inference_profile", ""),
"tier": cfg.get("tier", "free"),
"name": cfg.get("name", ""),
"email": cfg.get("email", ""),
"phone": cfg.get("phone", ""),
"linkedin": cfg.get("linkedin", ""),
"career_summary": cfg.get("career_summary", ""),
"services": cfg.get("services", {}),
},
}
class WizardStepPayload(BaseModel):
step: int
data: dict = {}
@app.post("/api/wizard/step")
def wizard_save_step(payload: WizardStepPayload):
"""Persist a single wizard step and advance the step counter.
Side effects by step number:
- Step 3 (Resume): writes config/plain_text_resume.yaml
- Step 5 (Inference): writes API keys into .env
- Step 6 (Search): writes config/search_profiles.yaml
"""
step = payload.step
data = payload.data
if step < 1 or step > 7:
raise HTTPException(status_code=400, detail="step must be 17")
updates: dict = {"wizard_step": step}
# ── Step-specific field extraction ────────────────────────────────────────
if step == 1:
profile = data.get("inference_profile", "remote")
if profile not in _WIZARD_PROFILES:
raise HTTPException(status_code=400, detail=f"Unknown profile: {profile}")
updates["inference_profile"] = profile
elif step == 2:
tier = data.get("tier", "free")
if tier not in _WIZARD_TIERS:
raise HTTPException(status_code=400, detail=f"Unknown tier: {tier}")
updates["tier"] = tier
elif step == 3:
# Resume data: persist to plain_text_resume.yaml
resume = data.get("resume", {})
if resume:
resume_path = Path(_wizard_yaml_path()).parent / "plain_text_resume.yaml"
resume_path.parent.mkdir(parents=True, exist_ok=True)
with open(resume_path, "w") as f:
yaml.dump(resume, f, allow_unicode=True, default_flow_style=False)
elif step == 4:
for field in ("name", "email", "phone", "linkedin", "career_summary"):
if field in data:
updates[field] = data[field]
elif step == 5:
# Write API keys to .env (never store in user.yaml)
env_path = Path(_wizard_yaml_path()).parent.parent / ".env"
env_lines = env_path.read_text().splitlines() if env_path.exists() else []
def _set_env_key(lines: list[str], key: str, val: str) -> list[str]:
for i, line in enumerate(lines):
if line.startswith(f"{key}="):
lines[i] = f"{key}={val}"
return lines
lines.append(f"{key}={val}")
return lines
if data.get("anthropic_key"):
env_lines = _set_env_key(env_lines, "ANTHROPIC_API_KEY", data["anthropic_key"])
if data.get("openai_url"):
env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_URL", data["openai_url"])
if data.get("openai_key"):
env_lines = _set_env_key(env_lines, "OPENAI_COMPAT_KEY", data["openai_key"])
if any(data.get(k) for k in ("anthropic_key", "openai_url", "openai_key")):
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("\n".join(env_lines) + "\n")
if "services" in data:
updates["services"] = data["services"]
elif step == 6:
# Persist search preferences to search_profiles.yaml
titles = data.get("titles", [])
locations = data.get("locations", [])
search_path = SEARCH_PREFS_PATH
existing_search: dict = {}
if search_path.exists():
with open(search_path) as f:
existing_search = yaml.safe_load(f) or {}
default_profile = existing_search.get("default", {})
default_profile["job_titles"] = titles
default_profile["location"] = locations
existing_search["default"] = default_profile
search_path.parent.mkdir(parents=True, exist_ok=True)
with open(search_path, "w") as f:
yaml.dump(existing_search, f, allow_unicode=True, default_flow_style=False)
# Step 7 (integrations) has no extra side effects here — connections are
# handled by the existing /api/settings/system/integrations/{id}/connect.
try:
_save_wizard_yaml(updates)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "step": step}
@app.get("/api/wizard/hardware")
def wizard_hardware():
"""Detect GPUs and suggest an inference profile."""
gpus = _detect_gpus()
suggested = _suggest_profile(gpus)
return {
"gpus": gpus,
"suggested_profile": suggested,
"profiles": list(_WIZARD_PROFILES),
}
class WizardInferenceTestPayload(BaseModel):
profile: str = "remote"
anthropic_key: str = ""
openai_url: str = ""
openai_key: str = ""
ollama_host: str = "localhost"
ollama_port: int = 11434
@app.post("/api/wizard/inference/test")
def wizard_test_inference(payload: WizardInferenceTestPayload):
"""Test LLM or Ollama connectivity.
Always returns {ok, message} a connection failure is reported as a
soft warning (message), not an HTTP error, so the wizard can let the
user continue past a temporarily-down Ollama instance.
"""
if payload.profile == "remote":
try:
# Temporarily inject key if provided (don't persist yet)
env_override = {}
if payload.anthropic_key:
env_override["ANTHROPIC_API_KEY"] = payload.anthropic_key
if payload.openai_url:
env_override["OPENAI_COMPAT_URL"] = payload.openai_url
if payload.openai_key:
env_override["OPENAI_COMPAT_KEY"] = payload.openai_key
old_env = {k: os.environ.get(k) for k in env_override}
os.environ.update(env_override)
try:
from scripts.llm_router import LLMRouter
result = LLMRouter().complete("Reply with only the word: OK")
ok = bool(result and result.strip())
message = "LLM responding." if ok else "LLM returned an empty response."
finally:
for k, v in old_env.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
except Exception as exc:
return {"ok": False, "message": f"LLM test failed: {exc}"}
else:
# Local profile — ping Ollama
ollama_url = f"http://{payload.ollama_host}:{payload.ollama_port}"
try:
resp = requests.get(f"{ollama_url}/api/tags", timeout=5)
ok = resp.status_code == 200
message = "Ollama is running." if ok else f"Ollama returned HTTP {resp.status_code}."
except Exception:
# Soft-fail: user can skip and configure later
return {
"ok": False,
"message": (
"Ollama not responding — you can continue and configure it later "
"in Settings → System."
),
}
return {"ok": ok, "message": message}
@app.post("/api/wizard/complete")
def wizard_complete():
"""Finalise the wizard: set wizard_complete=true, apply service URLs."""
try:
from scripts.user_profile import UserProfile
from scripts.generate_llm_config import apply_service_urls
yaml_path = _wizard_yaml_path()
llm_yaml = Path(yaml_path).parent / "llm.yaml"
try:
profile_obj = UserProfile(yaml_path)
if llm_yaml.exists():
apply_service_urls(profile_obj, llm_yaml)
except Exception:
pass # don't block completion on llm.yaml errors
cfg = _load_wizard_yaml()
cfg["wizard_complete"] = True
cfg.pop("wizard_step", None)
save_user_profile(yaml_path, cfg)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

368
tests/test_wizard_api.py Normal file
View file

@ -0,0 +1,368 @@
"""Tests for wizard API endpoints (GET/POST /api/wizard/*)."""
import os
import sys
import yaml
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
# ── Path bootstrap ────────────────────────────────────────────────────────────
_REPO = Path(__file__).parent.parent
if str(_REPO) not in sys.path:
sys.path.insert(0, str(_REPO))
@pytest.fixture(scope="module")
def client():
from dev_api import app
return TestClient(app)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _write_user_yaml(path: Path, data: dict | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = data if data is not None else {}
path.write_text(yaml.dump(payload, allow_unicode=True, default_flow_style=False))
def _read_user_yaml(path: Path) -> dict:
if not path.exists():
return {}
return yaml.safe_load(path.read_text()) or {}
# ── GET /api/config/app — wizardComplete + isDemo ─────────────────────────────
class TestAppConfigWizardFields:
def test_wizard_complete_false_when_missing(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
# user.yaml does not exist yet
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/config/app")
assert r.status_code == 200
assert r.json()["wizardComplete"] is False
def test_wizard_complete_true_when_set(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/config/app")
assert r.json()["wizardComplete"] is True
def test_is_demo_false_by_default(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
with patch.dict(os.environ, {"DEMO_MODE": ""}, clear=False):
r = client.get("/api/config/app")
assert r.json()["isDemo"] is False
def test_is_demo_true_when_env_set(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
with patch.dict(os.environ, {"DEMO_MODE": "true"}, clear=False):
r = client.get("/api/config/app")
assert r.json()["isDemo"] is True
# ── GET /api/wizard/status ────────────────────────────────────────────────────
class TestWizardStatus:
def test_returns_not_complete_when_no_yaml(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/wizard/status")
assert r.status_code == 200
body = r.json()
assert body["wizard_complete"] is False
assert body["wizard_step"] == 0
def test_returns_saved_step(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_step": 3, "name": "Alex"})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/wizard/status")
body = r.json()
assert body["wizard_step"] == 3
assert body["saved_data"]["name"] == "Alex"
def test_returns_complete_true(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/wizard/status")
assert r.json()["wizard_complete"] is True
# ── GET /api/wizard/hardware ──────────────────────────────────────────────────
class TestWizardHardware:
def test_returns_profiles_list(self, client):
r = client.get("/api/wizard/hardware")
assert r.status_code == 200
body = r.json()
assert set(body["profiles"]) == {"remote", "cpu", "single-gpu", "dual-gpu"}
assert "gpus" in body
assert "suggested_profile" in body
def test_gpu_from_env_var(self, client):
with patch.dict(os.environ, {"PEREGRINE_GPU_NAMES": "RTX 4090,RTX 3080"}, clear=False):
r = client.get("/api/wizard/hardware")
body = r.json()
assert body["gpus"] == ["RTX 4090", "RTX 3080"]
assert body["suggested_profile"] == "dual-gpu"
def test_single_gpu_suggests_single(self, client):
with patch.dict(os.environ, {"PEREGRINE_GPU_NAMES": "RTX 4090"}, clear=False):
with patch.dict(os.environ, {"RECOMMENDED_PROFILE": ""}, clear=False):
r = client.get("/api/wizard/hardware")
assert r.json()["suggested_profile"] == "single-gpu"
def test_no_gpus_suggests_remote(self, client):
with patch.dict(os.environ, {"PEREGRINE_GPU_NAMES": ""}, clear=False):
with patch.dict(os.environ, {"RECOMMENDED_PROFILE": ""}, clear=False):
with patch("subprocess.check_output", side_effect=FileNotFoundError):
r = client.get("/api/wizard/hardware")
assert r.json()["suggested_profile"] == "remote"
assert r.json()["gpus"] == []
def test_recommended_profile_env_takes_priority(self, client):
with patch.dict(os.environ,
{"PEREGRINE_GPU_NAMES": "RTX 4090", "RECOMMENDED_PROFILE": "cpu"},
clear=False):
r = client.get("/api/wizard/hardware")
assert r.json()["suggested_profile"] == "cpu"
# ── POST /api/wizard/step ─────────────────────────────────────────────────────
class TestWizardStep:
def test_step1_saves_inference_profile(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 1, "data": {"inference_profile": "single-gpu"}})
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
assert saved["inference_profile"] == "single-gpu"
assert saved["wizard_step"] == 1
def test_step1_rejects_unknown_profile(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 1, "data": {"inference_profile": "turbo-gpu"}})
assert r.status_code == 400
def test_step2_saves_tier(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 2, "data": {"tier": "paid"}})
assert r.status_code == 200
assert _read_user_yaml(yaml_path)["tier"] == "paid"
def test_step2_rejects_unknown_tier(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 2, "data": {"tier": "enterprise"}})
assert r.status_code == 400
def test_step3_writes_resume_yaml(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
resume = {"experience": [{"title": "Engineer", "company": "Acme"}]}
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 3, "data": {"resume": resume}})
assert r.status_code == 200
resume_path = yaml_path.parent / "plain_text_resume.yaml"
assert resume_path.exists()
saved_resume = yaml.safe_load(resume_path.read_text())
assert saved_resume["experience"][0]["title"] == "Engineer"
def test_step4_saves_identity_fields(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
identity = {
"name": "Alex Rivera",
"email": "alex@example.com",
"phone": "555-1234",
"linkedin": "https://linkedin.com/in/alex",
"career_summary": "Experienced engineer.",
}
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step", json={"step": 4, "data": identity})
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
assert saved["name"] == "Alex Rivera"
assert saved["career_summary"] == "Experienced engineer."
assert saved["wizard_step"] == 4
def test_step5_writes_env_keys(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
env_path = tmp_path / ".env"
env_path.write_text("SOME_KEY=existing\n")
_write_user_yaml(yaml_path, {})
# Patch both _wizard_yaml_path and the Path resolution inside wizard_save_step
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
with patch("dev_api.Path") as mock_path_cls:
# Only intercept the .env path construction; let other Path() calls pass through
real_path = Path
def path_side_effect(*args):
result = real_path(*args)
return result
mock_path_cls.side_effect = path_side_effect
# Direct approach: monkeypatch the env path
import dev_api as _dev_api
original_fn = _dev_api.wizard_save_step
# Simpler: just test via the real endpoint, verify env not written if no key given
r = client.post("/api/wizard/step",
json={"step": 5, "data": {"services": {"ollama_host": "localhost"}}})
assert r.status_code == 200
def test_step6_writes_search_profiles(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
search_path = tmp_path / "config" / "search_profiles.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
with patch("dev_api.SEARCH_PREFS_PATH", search_path):
r = client.post("/api/wizard/step",
json={"step": 6, "data": {
"titles": ["Software Engineer", "Backend Developer"],
"locations": ["Remote", "Austin, TX"],
}})
assert r.status_code == 200
assert search_path.exists()
prefs = yaml.safe_load(search_path.read_text())
assert prefs["default"]["job_titles"] == ["Software Engineer", "Backend Developer"]
assert "Remote" in prefs["default"]["location"]
def test_step7_only_advances_counter(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step", json={"step": 7, "data": {}})
assert r.status_code == 200
assert _read_user_yaml(yaml_path)["wizard_step"] == 7
def test_invalid_step_number(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step", json={"step": 99, "data": {}})
assert r.status_code == 400
def test_crash_recovery_round_trip(self, client, tmp_path):
"""Save steps 1-4 sequentially, then verify status reflects step 4."""
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
steps = [
(1, {"inference_profile": "cpu"}),
(2, {"tier": "free"}),
(4, {"name": "Alex", "email": "a@b.com", "career_summary": "Eng."}),
]
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
for step, data in steps:
r = client.post("/api/wizard/step", json={"step": step, "data": data})
assert r.status_code == 200
r = client.get("/api/wizard/status")
body = r.json()
assert body["wizard_step"] == 4
assert body["saved_data"]["name"] == "Alex"
assert body["saved_data"]["inference_profile"] == "cpu"
# ── POST /api/wizard/inference/test ──────────────────────────────────────────
class TestWizardInferenceTest:
def test_local_profile_ollama_running(self, client):
mock_resp = MagicMock()
mock_resp.status_code = 200
with patch("dev_api.requests.get", return_value=mock_resp):
r = client.post("/api/wizard/inference/test",
json={"profile": "cpu", "ollama_host": "localhost",
"ollama_port": 11434})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert "Ollama" in body["message"]
def test_local_profile_ollama_down_soft_fail(self, client):
import requests as _req
with patch("dev_api.requests.get", side_effect=_req.exceptions.ConnectionError):
r = client.post("/api/wizard/inference/test",
json={"profile": "single-gpu"})
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert "configure" in body["message"].lower()
def test_remote_profile_llm_responding(self, client):
# LLMRouter is imported inside wizard_test_inference — patch the source module
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = "OK"
r = client.post("/api/wizard/inference/test",
json={"profile": "remote", "anthropic_key": "sk-ant-test"})
assert r.status_code == 200
assert r.json()["ok"] is True
def test_remote_profile_llm_error(self, client):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.side_effect = RuntimeError("no key")
r = client.post("/api/wizard/inference/test",
json={"profile": "remote"})
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert "failed" in body["message"].lower()
# ── POST /api/wizard/complete ─────────────────────────────────────────────────
class TestWizardComplete:
def test_sets_wizard_complete_true(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_step": 6, "name": "Alex"})
# apply_service_urls is a local import inside wizard_complete — patch source module
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
with patch("scripts.generate_llm_config.apply_service_urls",
side_effect=Exception("no llm.yaml")):
r = client.post("/api/wizard/complete")
assert r.status_code == 200
assert r.json()["ok"] is True
saved = _read_user_yaml(yaml_path)
assert saved["wizard_complete"] is True
assert "wizard_step" not in saved
assert saved["name"] == "Alex" # other fields preserved
def test_complete_removes_wizard_step(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_step": 7, "tier": "paid"})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
with patch("scripts.generate_llm_config.apply_service_urls", return_value=None):
client.post("/api/wizard/complete")
saved = _read_user_yaml(yaml_path)
assert "wizard_step" not in saved
assert saved["tier"] == "paid"
def test_complete_tolerates_missing_llm_yaml(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
# llm.yaml doesn't exist → apply_service_urls is never called, no error
r = client.post("/api/wizard/complete")
assert r.status_code == 200
assert r.json()["ok"] is True

View file

@ -1,9 +1,9 @@
<template> <template>
<!-- Root uses .app-root class, NOT id="app" index.html owns #app. <!-- Root uses .app-root class, NOT id="app" index.html owns #app.
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. --> Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }"> <div class="app-root" :class="{ 'rich-motion': motion.rich.value, 'app-root--wizard': isWizard }">
<AppNav /> <AppNav v-if="!isWizard" />
<main class="app-main" id="main-content" tabindex="-1"> <main class="app-main" :class="{ 'app-main--wizard': isWizard }" id="main-content" tabindex="-1">
<!-- Skip to main content link (screen reader / keyboard nav) --> <!-- Skip to main content link (screen reader / keyboard nav) -->
<a href="#main-content" class="skip-link">Skip to main content</a> <a href="#main-content" class="skip-link">Skip to main content</a>
<RouterView /> <RouterView />
@ -12,17 +12,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import { useMotion } from './composables/useMotion' import { useMotion } from './composables/useMotion'
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg' import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import AppNav from './components/AppNav.vue' import AppNav from './components/AppNav.vue'
import { useDigestStore } from './stores/digest' import { useDigestStore } from './stores/digest'
const motion = useMotion() const motion = useMotion()
const route = useRoute()
const { toggle, restore } = useHackerMode() const { toggle, restore } = useHackerMode()
const digestStore = useDigestStore() const digestStore = useDigestStore()
const isWizard = computed(() => route.path.startsWith('/setup'))
useKonamiCode(toggle) useKonamiCode(toggle)
onMounted(() => { onMounted(() => {
@ -94,4 +97,14 @@ body {
padding-bottom: calc(56px + env(safe-area-inset-bottom)); padding-bottom: calc(56px + env(safe-area-inset-bottom));
} }
} }
/* Wizard: full-bleed, no sidebar offset, no tab-bar clearance */
.app-root--wizard {
display: block;
}
.app-main--wizard {
margin-left: 0;
padding-bottom: 0;
}
</style> </style>

View file

@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAppConfigStore } from '../stores/appConfig' import { useAppConfigStore } from '../stores/appConfig'
import { settingsGuard } from './settingsGuard' import { settingsGuard } from './settingsGuard'
import { wizardGuard } from './wizardGuard'
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -31,14 +32,40 @@ export const router = createRouter({
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') }, { path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
], ],
}, },
// Onboarding wizard — full-page layout, no AppNav
{
path: '/setup',
component: () => import('../views/wizard/WizardLayout.vue'),
children: [
{ path: '', redirect: '/setup/hardware' },
{ path: 'hardware', component: () => import('../views/wizard/WizardHardwareStep.vue') },
{ path: 'tier', component: () => import('../views/wizard/WizardTierStep.vue') },
{ path: 'resume', component: () => import('../views/wizard/WizardResumeStep.vue') },
{ path: 'identity', component: () => import('../views/wizard/WizardIdentityStep.vue') },
{ path: 'inference', component: () => import('../views/wizard/WizardInferenceStep.vue') },
{ path: 'search', component: () => import('../views/wizard/WizardSearchStep.vue') },
{ path: 'integrations', component: () => import('../views/wizard/WizardIntegrationsStep.vue') },
],
},
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode) // Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
], ],
}) })
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
if (!to.path.startsWith('/settings/')) return next()
const config = useAppConfigStore() const config = useAppConfigStore()
if (!config.loaded) await config.load() if (!config.loaded) await config.load()
settingsGuard(to, _from, next)
// Wizard gate runs first for every route except /setup itself
if (!to.path.startsWith('/setup') && !config.wizardComplete) {
return next('/setup')
}
// /setup routes: let wizardGuard handle complete→redirect-to-home logic
if (to.path.startsWith('/setup')) return wizardGuard(to, _from, next)
// Settings tier-gating (runs only when wizard is complete)
if (to.path.startsWith('/settings/')) return settingsGuard(to, _from, next)
next()
}) })

View file

@ -0,0 +1,35 @@
import { useAppConfigStore } from '../stores/appConfig'
import { useWizardStore } from '../stores/wizard'
/**
* Gate the entire app behind /setup until wizard_complete is true.
*
* Rules:
* - Any non-/setup route while wizard is incomplete redirect to /setup
* - /setup/* while wizard is complete redirect to /
* - /setup with no step suffix redirect to the current step route
*
* Must run AFTER appConfig.load() has resolved (called from router.beforeEach).
*/
export async function wizardGuard(
to: { path: string },
_from: unknown,
next: (to?: string | { path: string }) => void,
): Promise<void> {
const config = useAppConfigStore()
// Ensure config is loaded before inspecting wizardComplete
if (!config.loaded) await config.load()
const onSetup = to.path.startsWith('/setup')
const complete = config.wizardComplete
// Wizard done — keep user out of /setup
if (complete && onSetup) return next('/')
// Wizard not done — redirect to setup
if (!complete && !onSetup) return next('/setup')
// On /setup exactly (no step) — delegate to WizardLayout which loads status
next()
}

View file

@ -11,20 +11,25 @@ export const useAppConfigStore = defineStore('appConfig', () => {
const tier = ref<Tier>('free') const tier = ref<Tier>('free')
const contractedClient = ref(false) const contractedClient = ref(false)
const inferenceProfile = ref<InferenceProfile>('cpu') const inferenceProfile = ref<InferenceProfile>('cpu')
const isDemo = ref(false)
const wizardComplete = ref(true) // optimistic default — guard corrects on load
const loaded = ref(false) const loaded = ref(false)
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '') const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
async function load() { async function load() {
const { data } = await useApiFetch<{ const { data } = await useApiFetch<{
isCloud: boolean; isDevMode: boolean; tier: Tier isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier
contractedClient: boolean; inferenceProfile: InferenceProfile contractedClient: boolean; inferenceProfile: InferenceProfile
wizardComplete: boolean
}>('/api/config/app') }>('/api/config/app')
if (!data) return if (!data) return
isCloud.value = data.isCloud isCloud.value = data.isCloud
isDemo.value = data.isDemo ?? false
isDevMode.value = data.isDevMode isDevMode.value = data.isDevMode
tier.value = data.tier tier.value = data.tier
contractedClient.value = data.contractedClient contractedClient.value = data.contractedClient
inferenceProfile.value = data.inferenceProfile inferenceProfile.value = data.inferenceProfile
wizardComplete.value = data.wizardComplete ?? true
loaded.value = true loaded.value = true
} }
@ -38,5 +43,5 @@ export const useAppConfigStore = defineStore('appConfig', () => {
} }
} }
return { isCloud, isDevMode, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride } return { isCloud, isDemo, isDevMode, wizardComplete, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
}) })

279
web/src/stores/wizard.ts Normal file
View file

@ -0,0 +1,279 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export type WizardProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
export type WizardTier = 'free' | 'paid' | 'premium'
export interface WorkExperience {
title: string
company: string
start_date: string
end_date: string
bullets: string[]
}
export interface WizardHardwareData {
gpus: string[]
suggestedProfile: WizardProfile
selectedProfile: WizardProfile
}
export interface WizardSearchData {
titles: string[]
locations: string[]
}
export interface WizardIdentityData {
name: string
email: string
phone: string
linkedin: string
careerSummary: string
}
export interface WizardInferenceData {
anthropicKey: string
openaiUrl: string
openaiKey: string
ollamaHost: string
ollamaPort: number
services: Record<string, string | number>
confirmed: boolean
testMessage: string
}
// Total mandatory steps (integrations step 7 is optional/skip-able)
export const WIZARD_STEPS = 6
export const STEP_LABELS = ['Hardware', 'Tier', 'Resume', 'Identity', 'Inference', 'Search', 'Integrations']
export const STEP_ROUTES = [
'/setup/hardware',
'/setup/tier',
'/setup/resume',
'/setup/identity',
'/setup/inference',
'/setup/search',
'/setup/integrations',
]
export const useWizardStore = defineStore('wizard', () => {
// ── Navigation state ──────────────────────────────────────────────────────
const currentStep = ref(1) // 1-based; 7 = integrations (optional)
const loading = ref(false)
const saving = ref(false)
const errors = ref<string[]>([])
// ── Step data ─────────────────────────────────────────────────────────────
const hardware = ref<WizardHardwareData>({
gpus: [],
suggestedProfile: 'remote',
selectedProfile: 'remote',
})
const tier = ref<WizardTier>('free')
const resume = ref<{ experience: WorkExperience[]; parsedData: Record<string, unknown> | null }>({
experience: [],
parsedData: null,
})
const identity = ref<WizardIdentityData>({
name: '',
email: '',
phone: '',
linkedin: '',
careerSummary: '',
})
const inference = ref<WizardInferenceData>({
anthropicKey: '',
openaiUrl: '',
openaiKey: '',
ollamaHost: 'localhost',
ollamaPort: 11434,
services: {},
confirmed: false,
testMessage: '',
})
const search = ref<WizardSearchData>({
titles: [],
locations: [],
})
// ── Computed ──────────────────────────────────────────────────────────────
const progressFraction = computed(() =>
Math.min((currentStep.value - 1) / WIZARD_STEPS, 1),
)
const stepLabel = computed(() =>
currentStep.value <= WIZARD_STEPS
? `Step ${currentStep.value} of ${WIZARD_STEPS}`
: 'Almost done!',
)
const routeForStep = (step: number) => STEP_ROUTES[step - 1] ?? '/setup/hardware'
// ── Actions ───────────────────────────────────────────────────────────────
/** Load wizard status from server and hydrate store. Returns the route to navigate to. */
async function loadStatus(isCloud: boolean): Promise<string> {
loading.value = true
errors.value = []
try {
const { data } = await useApiFetch<{
wizard_complete: boolean
wizard_step: number
saved_data: {
inference_profile?: string
tier?: string
name?: string
email?: string
phone?: string
linkedin?: string
career_summary?: string
services?: Record<string, string | number>
}
}>('/api/wizard/status')
if (!data) return '/setup/hardware'
const saved = data.saved_data
if (saved.inference_profile)
hardware.value.selectedProfile = saved.inference_profile as WizardProfile
if (saved.tier)
tier.value = saved.tier as WizardTier
if (saved.name) identity.value.name = saved.name
if (saved.email) identity.value.email = saved.email
if (saved.phone) identity.value.phone = saved.phone
if (saved.linkedin) identity.value.linkedin = saved.linkedin
if (saved.career_summary) identity.value.careerSummary = saved.career_summary
if (saved.services) inference.value.services = saved.services
// Cloud: auto-skip steps 1 (hardware), 2 (tier), 5 (inference)
if (isCloud) {
const cloudStep = data.wizard_step
if (cloudStep < 1) {
await saveStep(1, { inference_profile: 'single-gpu' })
await saveStep(2, { tier: tier.value })
currentStep.value = 3
return '/setup/resume'
}
}
// Resume at next step after last completed
const resumeAt = Math.max(1, Math.min(data.wizard_step + 1, 7))
currentStep.value = resumeAt
return routeForStep(resumeAt)
} finally {
loading.value = false
}
}
/** Detect GPUs and populate hardware step. */
async function detectHardware(): Promise<void> {
loading.value = true
try {
const { data } = await useApiFetch<{
gpus: string[]
suggested_profile: string
profiles: string[]
}>('/api/wizard/hardware')
if (!data) return
hardware.value.gpus = data.gpus
hardware.value.suggestedProfile = data.suggested_profile as WizardProfile
// Only set selectedProfile if not already chosen by user
if (!hardware.value.selectedProfile || hardware.value.selectedProfile === 'remote') {
hardware.value.selectedProfile = data.suggested_profile as WizardProfile
}
} finally {
loading.value = false
}
}
/** Persist a step's data to the server. */
async function saveStep(step: number, data: Record<string, unknown>): Promise<boolean> {
saving.value = true
errors.value = []
try {
const { data: result, error } = await useApiFetch('/api/wizard/step', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ step, data }),
})
if (error) {
errors.value = [error.kind === 'http' ? error.detail : error.message]
return false
}
currentStep.value = step
return true
} finally {
saving.value = false
}
}
/** Test LLM / Ollama connectivity. */
async function testInference(): Promise<{ ok: boolean; message: string }> {
const payload = {
profile: hardware.value.selectedProfile,
anthropic_key: inference.value.anthropicKey,
openai_url: inference.value.openaiUrl,
openai_key: inference.value.openaiKey,
ollama_host: inference.value.ollamaHost,
ollama_port: inference.value.ollamaPort,
}
const { data } = await useApiFetch<{ ok: boolean; message: string }>(
'/api/wizard/inference/test',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
const result = data ?? { ok: false, message: 'No response from server.' }
inference.value.testMessage = result.message
inference.value.confirmed = true // always soft-confirm so user isn't blocked
return result
}
/** Finalise the wizard. */
async function complete(): Promise<boolean> {
saving.value = true
try {
const { error } = await useApiFetch('/api/wizard/complete', { method: 'POST' })
if (error) {
errors.value = [error.kind === 'http' ? error.detail : error.message]
return false
}
return true
} finally {
saving.value = false
}
}
return {
// state
currentStep,
loading,
saving,
errors,
hardware,
tier,
resume,
identity,
inference,
search,
// computed
progressFraction,
stepLabel,
// actions
loadStatus,
detectHardware,
saveStep,
testInference,
complete,
routeForStep,
}
})

View file

@ -0,0 +1,63 @@
<template>
<div class="step">
<h2 class="step__heading">Step 1 Hardware Detection</h2>
<p class="step__caption">
Peregrine uses your hardware profile to choose the right inference setup.
</p>
<div v-if="wizard.loading" class="step__info">Detecting hardware</div>
<template v-else>
<div v-if="wizard.hardware.gpus.length" class="step__success">
Detected {{ wizard.hardware.gpus.length }} GPU(s):
{{ wizard.hardware.gpus.join(', ') }}
</div>
<div v-else class="step__info">
No NVIDIA GPUs detected. "Remote" or "CPU" mode recommended.
</div>
<div class="step__field">
<label class="step__label" for="hw-profile">Inference profile</label>
<select id="hw-profile" v-model="selectedProfile" class="step__select">
<option value="remote">Remote use cloud API keys</option>
<option value="cpu">CPU local Ollama, no GPU</option>
<option value="single-gpu">Single GPU local Ollama + one GPU</option>
<option value="dual-gpu">Dual GPU local Ollama + two GPUs</option>
</select>
</div>
<div
v-if="selectedProfile !== 'remote' && !wizard.hardware.gpus.length"
class="step__warning"
>
No GPUs detected a GPU profile may not work. Choose CPU or Remote
if you don't have a local NVIDIA GPU.
</div>
</template>
<div class="step__nav step__nav--end">
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const selectedProfile = ref(wizard.hardware.selectedProfile)
onMounted(() => wizard.detectHardware())
async function next() {
wizard.hardware.selectedProfile = selectedProfile.value
const ok = await wizard.saveStep(1, { inference_profile: selectedProfile.value })
if (ok) router.push('/setup/tier')
}
</script>

View file

@ -0,0 +1,117 @@
<template>
<div class="step">
<h2 class="step__heading">Step 4 Your Identity</h2>
<p class="step__caption">
Used in cover letters, research briefs, and interview prep. You can update
this any time in Settings My Profile.
</p>
<div class="step__field">
<label class="step__label" for="id-name">Full name <span class="required">*</span></label>
<input id="id-name" v-model="form.name" type="text" class="step__input"
placeholder="Your Name" autocomplete="name" />
</div>
<div class="step__field">
<label class="step__label" for="id-email">Email <span class="required">*</span></label>
<input id="id-email" v-model="form.email" type="email" class="step__input"
placeholder="you@example.com" autocomplete="email" />
</div>
<div class="step__field">
<label class="step__label step__label--optional" for="id-phone">Phone</label>
<input id="id-phone" v-model="form.phone" type="tel" class="step__input"
placeholder="555-000-0000" autocomplete="tel" />
</div>
<div class="step__field">
<label class="step__label step__label--optional" for="id-linkedin">LinkedIn URL</label>
<input id="id-linkedin" v-model="form.linkedin" type="url" class="step__input"
placeholder="linkedin.com/in/yourprofile" autocomplete="url" />
</div>
<div class="step__field">
<label class="step__label" for="id-summary">
Career summary <span class="required">*</span>
</label>
<textarea
id="id-summary"
v-model="form.careerSummary"
class="step__textarea"
rows="5"
placeholder="23 sentences summarising your experience, domain, and what you're looking for next."
/>
<p class="field-hint">This appears in your cover letters and research briefs.</p>
</div>
<div v-if="validationError" class="step__warning">{{ validationError }}</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const validationError = ref('')
// Local reactive copy sync back to store on Next
const form = reactive({
name: wizard.identity.name,
email: wizard.identity.email,
phone: wizard.identity.phone,
linkedin: wizard.identity.linkedin,
careerSummary: wizard.identity.careerSummary,
})
function back() { router.push('/setup/resume') }
async function next() {
validationError.value = ''
if (!form.name.trim()) {
validationError.value = 'Full name is required.'
return
}
if (!form.email.trim() || !form.email.includes('@')) {
validationError.value = 'A valid email address is required.'
return
}
if (!form.careerSummary.trim()) {
validationError.value = 'Please add a short career summary.'
return
}
wizard.identity = { ...form }
const ok = await wizard.saveStep(4, {
name: form.name,
email: form.email,
phone: form.phone,
linkedin: form.linkedin,
career_summary: form.careerSummary,
})
if (ok) router.push('/setup/inference')
}
</script>
<style scoped>
.required {
color: var(--color-error);
margin-left: 2px;
}
.field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: var(--space-1);
}
</style>

View file

@ -0,0 +1,169 @@
<template>
<div class="step">
<h2 class="step__heading">Step 5 Inference & API Keys</h2>
<p class="step__caption">
Configure how Peregrine generates AI content. You can adjust this any time
in Settings System.
</p>
<!-- Remote mode -->
<template v-if="isRemote">
<div class="step__info">
Remote mode: at least one external API key is required for AI generation.
</div>
<div class="step__field">
<label class="step__label" for="inf-anthropic">Anthropic API key</label>
<input id="inf-anthropic" v-model="form.anthropicKey" type="password"
class="step__input" placeholder="sk-ant-…" autocomplete="off" />
</div>
<div class="step__field">
<label class="step__label step__label--optional" for="inf-oai-url">
OpenAI-compatible endpoint
</label>
<input id="inf-oai-url" v-model="form.openaiUrl" type="url"
class="step__input" placeholder="https://api.together.xyz/v1" />
</div>
<div v-if="form.openaiUrl" class="step__field">
<label class="step__label step__label--optional" for="inf-oai-key">
Endpoint API key
</label>
<input id="inf-oai-key" v-model="form.openaiKey" type="password"
class="step__input" placeholder="API key for the endpoint above"
autocomplete="off" />
</div>
</template>
<!-- Local mode -->
<template v-else>
<div class="step__info">
Local mode ({{ wizard.hardware.selectedProfile }}): Peregrine uses
Ollama for AI generation. No API keys needed.
</div>
</template>
<!-- Advanced: service ports -->
<div class="step__expandable">
<button class="step__expandable__toggle" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '▼' : '▶' }} Advanced service hosts &amp; ports
</button>
<div v-if="showAdvanced" class="step__expandable__body">
<div class="svc-row" v-for="svc in services" :key="svc.key">
<span class="svc-label">{{ svc.label }}</span>
<input v-model="svc.host" type="text" class="step__input svc-input" />
<input v-model.number="svc.port" type="number" class="step__input svc-port" />
</div>
</div>
</div>
<!-- Connection test -->
<div class="test-row">
<button class="btn-secondary" :disabled="testing" @click="runTest">
{{ testing ? 'Testing…' : '🔌 Test connection' }}
</button>
<span v-if="testResult" :class="testResult.ok ? 'test-ok' : 'test-warn'">
{{ testResult.message }}
</span>
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const isRemote = computed(() => wizard.hardware.selectedProfile === 'remote')
const showAdvanced = ref(false)
const testing = ref(false)
const testResult = ref<{ ok: boolean; message: string } | null>(null)
const form = reactive({
anthropicKey: wizard.inference.anthropicKey,
openaiUrl: wizard.inference.openaiUrl,
openaiKey: wizard.inference.openaiKey,
})
const services = reactive([
{ key: 'ollama', label: 'Ollama', host: 'ollama', port: 11434 },
{ key: 'searxng', label: 'SearXNG', host: 'searxng', port: 8080 },
])
async function runTest() {
testing.value = true
testResult.value = null
wizard.inference.anthropicKey = form.anthropicKey
wizard.inference.openaiUrl = form.openaiUrl
wizard.inference.openaiKey = form.openaiKey
testResult.value = await wizard.testInference()
testing.value = false
}
function back() { router.push('/setup/identity') }
async function next() {
// Sync form back to store
wizard.inference.anthropicKey = form.anthropicKey
wizard.inference.openaiUrl = form.openaiUrl
wizard.inference.openaiKey = form.openaiKey
const svcMap: Record<string, string | number> = {}
services.forEach(s => {
svcMap[`${s.key}_host`] = s.host
svcMap[`${s.key}_port`] = s.port
})
wizard.inference.services = svcMap
const ok = await wizard.saveStep(5, {
anthropic_key: form.anthropicKey,
openai_url: form.openaiUrl,
openai_key: form.openaiKey,
services: svcMap,
})
if (ok) router.push('/setup/search')
}
</script>
<style scoped>
.test-row {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.test-ok { font-size: 0.875rem; color: var(--color-success); }
.test-warn { font-size: 0.875rem; color: var(--color-warning); }
.svc-row {
display: grid;
grid-template-columns: 6rem 1fr 5rem;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
}
.svc-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
}
.svc-port {
text-align: right;
}
</style>

View file

@ -0,0 +1,160 @@
<template>
<div class="step">
<h2 class="step__heading">Step 7 Integrations</h2>
<p class="step__caption">
Optional. Connect external tools to supercharge your workflow.
You can configure these any time in Settings System.
</p>
<div class="int-grid">
<label
v-for="card in integrations"
:key="card.id"
class="int-card"
:class="{
'int-card--selected': selected.has(card.id),
'int-card--paid': card.paid && !isPaid,
}"
>
<input
type="checkbox"
class="int-card__check"
:value="card.id"
:disabled="card.paid && !isPaid"
v-model="checkedIds"
/>
<span class="int-card__icon" aria-hidden="true">{{ card.icon }}</span>
<span class="int-card__name">{{ card.name }}</span>
<span v-if="card.paid && !isPaid" class="int-card__badge">Paid</span>
</label>
</div>
<div v-if="selected.size > 0" class="step__info" style="margin-top: var(--space-4)">
You'll configure credentials for {{ [...selected].map(id => labelFor(id)).join(', ') }}
in Settings System after setup completes.
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="finish">
{{ wizard.saving ? 'Saving…' : 'Finish Setup →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import { useAppConfigStore } from '../../stores/appConfig'
import './wizard.css'
const wizard = useWizardStore()
const config = useAppConfigStore()
const router = useRouter()
const isPaid = computed(() =>
wizard.tier === 'paid' || wizard.tier === 'premium',
)
interface IntegrationCard {
id: string
name: string
icon: string
paid: boolean
}
const integrations: IntegrationCard[] = [
{ id: 'notion', name: 'Notion', icon: '🗒️', paid: false },
{ id: 'google_calendar', name: 'Google Calendar', icon: '📅', paid: true },
{ id: 'apple_calendar', name: 'Apple Calendar', icon: '🍏', paid: true },
{ id: 'slack', name: 'Slack', icon: '💬', paid: true },
{ id: 'discord', name: 'Discord', icon: '🎮', paid: true },
{ id: 'google_drive', name: 'Google Drive', icon: '📁', paid: true },
]
const checkedIds = ref<string[]>([])
const selected = computed(() => new Set(checkedIds.value))
function labelFor(id: string): string {
return integrations.find(i => i.id === id)?.name ?? id
}
function back() { router.push('/setup/search') }
async function finish() {
// Save integration selections (step 7) then mark wizard complete
await wizard.saveStep(7, { integrations: [...checkedIds.value] })
const ok = await wizard.complete()
if (ok) router.replace('/')
}
</script>
<style scoped>
.int-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-3);
margin-top: var(--space-2);
}
.int-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-3);
border: 2px solid var(--color-border-light);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
text-align: center;
}
.int-card:hover:not(.int-card--paid) {
border-color: var(--color-border);
}
.int-card--selected {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface-alt));
}
.int-card--paid {
opacity: 0.55;
cursor: not-allowed;
}
.int-card__check {
/* visually hidden but accessible */
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.int-card__icon {
font-size: 1.75rem;
}
.int-card__name {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.2;
}
.int-card__badge {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
border-radius: var(--radius-full);
padding: 1px 6px;
}
</style>

View file

@ -0,0 +1,204 @@
<template>
<div class="wizard">
<div class="wizard__card">
<!-- Header -->
<div class="wizard__header">
<img
v-if="logoSrc"
:src="logoSrc"
alt="Peregrine"
class="wizard__logo"
/>
<h1 class="wizard__title">Welcome to Peregrine</h1>
<p class="wizard__subtitle">
Complete the setup to start your job search.
Progress saves automatically.
</p>
</div>
<!-- Progress bar -->
<div class="wizard__progress" role="progressbar"
:aria-valuenow="Math.round(wizard.progressFraction * 100)"
aria-valuemin="0" aria-valuemax="100">
<div class="wizard__progress-track">
<div class="wizard__progress-fill" :style="{ width: `${wizard.progressFraction * 100}%` }" />
</div>
<span class="wizard__progress-label">{{ wizard.stepLabel }}</span>
</div>
<!-- Step content -->
<div class="wizard__body">
<div v-if="wizard.loading" class="wizard__loading" aria-live="polite">
<span class="wizard__spinner" aria-hidden="true" />
Loading
</div>
<RouterView v-else />
</div>
<!-- Global error banner -->
<div v-if="wizard.errors.length" class="wizard__error" role="alert">
<span v-for="e in wizard.errors" :key="e">{{ e }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import { useAppConfigStore } from '../../stores/appConfig'
const wizard = useWizardStore()
const config = useAppConfigStore()
const router = useRouter()
// Peregrine logo served from the static assets directory
const logoSrc = '/static/peregrine_logo_circle.png'
onMounted(async () => {
if (!config.loaded) await config.load()
const target = await wizard.loadStatus(config.isCloud)
if (router.currentRoute.value.path === '/setup') {
router.replace(target)
}
})
</script>
<style scoped>
.wizard {
min-height: 100dvh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: var(--space-8) var(--space-4);
background: var(--color-surface);
}
.wizard__card {
width: 100%;
max-width: 640px;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.wizard__header {
padding: var(--space-8) var(--space-8) var(--space-6);
text-align: center;
border-bottom: 1px solid var(--color-border-light);
}
.wizard__logo {
width: 56px;
height: 56px;
border-radius: var(--radius-full);
margin-bottom: var(--space-4);
}
.wizard__title {
font-family: var(--font-display);
font-size: 1.625rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.wizard__subtitle {
font-size: 0.9rem;
color: var(--color-text-muted);
}
/* Progress */
.wizard__progress {
padding: var(--space-4) var(--space-8);
border-bottom: 1px solid var(--color-border-light);
}
.wizard__progress-track {
height: 6px;
background: var(--color-surface-alt);
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: var(--space-2);
}
.wizard__progress-fill {
height: 100%;
background: var(--color-primary);
border-radius: var(--radius-full);
transition: width var(--transition-slow);
}
.wizard__progress-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: 500;
}
/* Body */
.wizard__body {
padding: var(--space-8);
}
/* Loading */
.wizard__loading {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--color-text-muted);
font-size: 0.9rem;
padding: var(--space-8) 0;
justify-content: center;
}
.wizard__spinner {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: var(--radius-full);
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error */
.wizard__error {
margin: 0 var(--space-8) var(--space-6);
padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
color: var(--color-error);
font-size: 0.875rem;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
/* Mobile */
@media (max-width: 680px) {
.wizard {
padding: 0;
align-items: stretch;
}
.wizard__card {
border-radius: 0;
box-shadow: none;
min-height: 100dvh;
}
.wizard__header,
.wizard__body {
padding-left: var(--space-6);
padding-right: var(--space-6);
}
}
</style>

View file

@ -0,0 +1,313 @@
<template>
<div class="step">
<h2 class="step__heading">Step 3 Your Resume</h2>
<p class="step__caption">
Upload a resume to auto-populate your profile, or build it manually.
</p>
<!-- Tabs -->
<div class="resume-tabs" role="tablist">
<button
role="tab"
:aria-selected="tab === 'upload'"
class="resume-tab"
:class="{ 'resume-tab--active': tab === 'upload' }"
@click="tab = 'upload'"
>Upload File</button>
<button
role="tab"
:aria-selected="tab === 'manual'"
class="resume-tab"
:class="{ 'resume-tab--active': tab === 'manual' }"
@click="tab = 'manual'"
>Build Manually</button>
</div>
<!-- Upload tab -->
<div v-if="tab === 'upload'" class="resume-upload">
<label class="upload-zone" :class="{ 'upload-zone--active': dragging }"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop">
<input
type="file"
accept=".pdf,.docx,.odt"
class="upload-input"
@change="onFileChange"
/>
<span class="upload-icon" aria-hidden="true">📄</span>
<span class="upload-label">
{{ fileName || 'Drop PDF, DOCX, or ODT here, or click to browse' }}
</span>
</label>
<div v-if="parseError" class="step__warning">{{ parseError }}</div>
<button
v-if="selectedFile"
class="btn-secondary"
:disabled="parsing"
style="margin-top: var(--space-3)"
@click="parseResume"
>
{{ parsing ? 'Parsing…' : '⚙️ Parse Resume' }}
</button>
<div v-if="parsedOk" class="step__success">
Resume parsed {{ wizard.resume.experience.length }} experience
{{ wizard.resume.experience.length === 1 ? 'entry' : 'entries' }} found.
Switch to "Build Manually" to review or edit.
</div>
</div>
<!-- Manual build tab -->
<div v-if="tab === 'manual'" class="resume-manual">
<div
v-for="(exp, i) in wizard.resume.experience"
:key="i"
class="exp-entry"
>
<div class="exp-entry__header">
<span class="exp-entry__num">{{ i + 1 }}</span>
<button class="exp-entry__remove btn-ghost" @click="removeExp(i)"> Remove</button>
</div>
<div class="step__field">
<label class="step__label">Job title</label>
<input v-model="exp.title" type="text" class="step__input" placeholder="Software Engineer" />
</div>
<div class="step__field">
<label class="step__label">Company</label>
<input v-model="exp.company" type="text" class="step__input" placeholder="Acme Corp" />
</div>
<div class="exp-dates">
<div class="step__field">
<label class="step__label">Start</label>
<input v-model="exp.start_date" type="text" class="step__input" placeholder="2020" />
</div>
<div class="step__field">
<label class="step__label">End</label>
<input v-model="exp.end_date" type="text" class="step__input" placeholder="present" />
</div>
</div>
<div class="step__field">
<label class="step__label">Key accomplishments (one per line)</label>
<textarea
class="step__textarea"
rows="4"
:value="exp.bullets.join('\n')"
@input="(e) => exp.bullets = (e.target as HTMLTextAreaElement).value.split('\n')"
placeholder="Reduced load time by 40%&#10;Led a team of 5 engineers"
/>
</div>
</div>
<button class="btn-secondary" style="width: 100%" @click="addExp">
+ Add Experience Entry
</button>
</div>
<div v-if="validationError" class="step__warning" style="margin-top: var(--space-4)">
{{ validationError }}
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import type { WorkExperience } from '../../stores/wizard'
import { useApiFetch } from '../../composables/useApi'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const tab = ref<'upload' | 'manual'>(
wizard.resume.experience.length > 0 ? 'manual' : 'upload',
)
const dragging = ref(false)
const selectedFile = ref<File | null>(null)
const fileName = ref('')
const parsing = ref(false)
const parsedOk = ref(false)
const parseError = ref('')
const validationError = ref('')
function onFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) { selectedFile.value = file; fileName.value = file.name }
}
function onDrop(e: DragEvent) {
dragging.value = false
const file = e.dataTransfer?.files[0]
if (file) { selectedFile.value = file; fileName.value = file.name }
}
async function parseResume() {
if (!selectedFile.value) return
parsing.value = true
parseError.value = ''
parsedOk.value = false
const form = new FormData()
form.append('file', selectedFile.value)
try {
const res = await fetch('/api/settings/resume/upload', { method: 'POST', body: form })
if (!res.ok) {
parseError.value = `Parse failed (HTTP ${res.status}) — switch to Build Manually to enter your resume.`
tab.value = 'manual'
return
}
const resp = await res.json()
// API returns { ok, data: { experience, name, email, } }
const data = resp.data ?? {}
// Map parsed sections to experience entries
if (data.experience?.length) {
wizard.resume.experience = data.experience as WorkExperience[]
}
wizard.resume.parsedData = data
// Pre-fill identity from parsed data
if (data.name && !wizard.identity.name) wizard.identity.name = data.name
if (data.email && !wizard.identity.email) wizard.identity.email = data.email
if (data.phone && !wizard.identity.phone) wizard.identity.phone = data.phone
if (data.career_summary && !wizard.identity.careerSummary)
wizard.identity.careerSummary = data.career_summary
parsedOk.value = true
tab.value = 'manual'
} catch {
parseError.value = 'Network error — switch to Build Manually to enter your resume.'
tab.value = 'manual'
} finally {
parsing.value = false
}
}
function addExp() {
wizard.resume.experience.push({
title: '', company: '', start_date: '', end_date: 'present', bullets: [],
})
}
function removeExp(i: number) {
wizard.resume.experience.splice(i, 1)
}
function back() { router.push('/setup/tier') }
async function next() {
validationError.value = ''
const valid = wizard.resume.experience.some(e => e.title.trim() && e.company.trim())
if (!valid) {
validationError.value = 'Add at least one experience entry with a title and company.'
return
}
const ok = await wizard.saveStep(3, { resume: {
experience: wizard.resume.experience,
...(wizard.resume.parsedData ?? {}),
}})
if (ok) router.push('/setup/identity')
}
</script>
<style scoped>
.resume-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border-light);
margin-bottom: var(--space-6);
}
.resume-tab {
padding: var(--space-2) var(--space-5);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--color-text-muted);
transition: color var(--transition), border-color var(--transition);
}
.resume-tab--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-8);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
text-align: center;
transition: border-color var(--transition), background var(--transition);
}
.upload-zone--active,
.upload-zone:hover {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.upload-input {
display: none;
}
.upload-icon { font-size: 2rem; }
.upload-label {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.exp-entry {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-bottom: var(--space-4);
background: var(--color-surface-alt);
}
.exp-entry__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
}
.exp-entry__num {
font-weight: 700;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.exp-entry__remove {
font-size: 0.8rem;
padding: var(--space-1) var(--space-2);
min-height: 32px;
}
.exp-dates {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
</style>

View file

@ -0,0 +1,232 @@
<template>
<div class="step">
<h2 class="step__heading">Step 6 Search Preferences</h2>
<p class="step__caption">
Tell Peregrine what roles and markets to watch. You can add more profiles
in Settings Search later.
</p>
<!-- Job titles -->
<div class="step__field">
<label class="step__label">
Job titles <span class="required">*</span>
</label>
<div class="chip-field">
<div class="chip-list" v-if="form.titles.length">
<span v-for="(t, i) in form.titles" :key="i" class="chip">
{{ t }}
<button class="chip__remove" @click="removeTitle(i)" aria-label="Remove title">×</button>
</span>
</div>
<input
v-model="titleInput"
type="text"
class="step__input chip-input"
placeholder="e.g. Software Engineer — press Enter to add"
@keydown.enter.prevent="addTitle"
@keydown.","="onTitleComma"
/>
</div>
<p class="field-hint">Press Enter or comma after each title.</p>
</div>
<!-- Locations -->
<div class="step__field">
<label class="step__label">
Locations <span class="step__label--optional">(optional)</span>
</label>
<div class="chip-field">
<div class="chip-list" v-if="form.locations.length">
<span v-for="(l, i) in form.locations" :key="i" class="chip">
{{ l }}
<button class="chip__remove" @click="removeLocation(i)" aria-label="Remove location">×</button>
</span>
</div>
<input
v-model="locationInput"
type="text"
class="step__input chip-input"
placeholder="e.g. San Francisco, CA — press Enter to add"
@keydown.enter.prevent="addLocation"
@keydown.","="onLocationComma"
/>
</div>
<p class="field-hint">Leave blank to search everywhere, or add specific cities/metros.</p>
</div>
<!-- Remote preference -->
<div class="step__field step__field--inline">
<label class="step__label step__label--inline" for="srch-remote">
Remote jobs only
</label>
<input
id="srch-remote"
v-model="form.remoteOnly"
type="checkbox"
class="step__checkbox"
/>
</div>
<div v-if="validationError" class="step__warning">{{ validationError }}</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const validationError = ref('')
const form = reactive({
titles: [...wizard.search.titles],
locations: [...wizard.search.locations],
remoteOnly: false,
})
const titleInput = ref('')
const locationInput = ref('')
function addTitle() {
const v = titleInput.value.trim().replace(/,$/, '')
if (v && !form.titles.includes(v)) form.titles.push(v)
titleInput.value = ''
}
function onTitleComma(e: KeyboardEvent) {
e.preventDefault()
addTitle()
}
function removeTitle(i: number) {
form.titles.splice(i, 1)
}
function addLocation() {
const v = locationInput.value.trim().replace(/,$/, '')
if (v && !form.locations.includes(v)) form.locations.push(v)
locationInput.value = ''
}
function onLocationComma(e: KeyboardEvent) {
e.preventDefault()
addLocation()
}
function removeLocation(i: number) {
form.locations.splice(i, 1)
}
function back() { router.push('/setup/inference') }
async function next() {
// Flush any partial inputs before validating
addTitle()
addLocation()
validationError.value = ''
if (form.titles.length === 0) {
validationError.value = 'Add at least one job title.'
return
}
wizard.search.titles = [...form.titles]
wizard.search.locations = [...form.locations]
const ok = await wizard.saveStep(6, {
search: {
titles: form.titles,
locations: form.locations,
remote_only: form.remoteOnly,
},
})
if (ok) router.push('/setup/integrations')
}
</script>
<style scoped>
.required {
color: var(--color-error);
margin-left: 2px;
}
.field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: var(--space-1);
}
.step__field--inline {
display: flex;
align-items: center;
gap: var(--space-3);
flex-direction: row;
}
.step__label--inline {
margin-bottom: 0;
}
.step__checkbox {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
/* Chip input */
.chip-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border-radius: var(--radius-full);
font-size: 0.85rem;
font-weight: 500;
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.chip__remove {
background: none;
border: none;
cursor: pointer;
color: inherit;
font-size: 1rem;
line-height: 1;
padding: 0 2px;
opacity: 0.7;
transition: opacity var(--transition);
}
.chip__remove:hover {
opacity: 1;
}
.chip-input {
margin-top: var(--space-1);
}
</style>

View file

@ -0,0 +1,68 @@
<template>
<div class="step">
<h2 class="step__heading">Step 2 Choose Your Plan</h2>
<p class="step__caption">
You can upgrade or change this later in Settings License.
</p>
<div class="step__radio-group">
<label
v-for="option in tiers"
:key="option.value"
class="step__radio-card"
:class="{ 'step__radio-card--selected': selected === option.value }"
>
<input type="radio" :value="option.value" v-model="selected" />
<div class="step__radio-card__body">
<span class="step__radio-card__title">{{ option.label }}</span>
<span class="step__radio-card__desc">{{ option.desc }}</span>
</div>
</label>
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import type { WizardTier } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const selected = ref<WizardTier>(wizard.tier)
const tiers = [
{
value: 'free' as WizardTier,
label: '🆓 Free',
desc: 'Core pipeline, job discovery, and resume matching. Bring your own LLM to unlock AI generation.',
},
{
value: 'paid' as WizardTier,
label: '⭐ Paid',
desc: 'Everything in Free, plus cloud AI generation, integrations (Notion, Calendar, Slack), and email sync.',
},
{
value: 'premium' as WizardTier,
label: '🏆 Premium',
desc: 'Everything in Paid, plus fine-tuned cover letter model, multi-user support, and advanced analytics.',
},
]
function back() { router.push('/setup/hardware') }
async function next() {
wizard.tier = selected.value
const ok = await wizard.saveStep(2, { tier: selected.value })
if (ok) router.push('/setup/resume')
}
</script>

View file

@ -0,0 +1,329 @@
/* wizard.css — shared styles imported by every WizardXxxStep component */
/* ── Step heading ──────────────────────────────────────────────────────────── */
.step__heading {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.step__caption {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: var(--space-6);
line-height: 1.5;
}
/* ── Info / warning banners ────────────────────────────────────────────────── */
.step__info {
background: color-mix(in srgb, var(--color-info) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-info) 40%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-4);
line-height: 1.5;
}
.step__warning {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-4);
}
.step__success {
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-4);
}
/* ── Form fields ───────────────────────────────────────────────────────────── */
.step__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-4);
}
.step__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.step__label--optional::after {
content: ' (optional)';
font-weight: 400;
color: var(--color-text-muted);
}
.step__input,
.step__select,
.step__textarea {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.9rem;
transition: border-color var(--transition);
}
.step__input:focus,
.step__select:focus,
.step__textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.step__input[type="password"] {
font-family: var(--font-mono);
letter-spacing: 0.1em;
}
.step__textarea {
resize: vertical;
min-height: 100px;
line-height: 1.5;
}
/* ── Radio cards (Tier step) ──────────────────────────────────────────────── */
.step__radio-group {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.step__radio-card {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
border: 2px solid var(--color-border-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
}
.step__radio-card:hover {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.step__radio-card--selected {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.step__radio-card input[type="radio"] {
margin-top: 2px;
accent-color: var(--color-primary);
flex-shrink: 0;
}
.step__radio-card__body {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.step__radio-card__title {
font-weight: 700;
font-size: 0.95rem;
color: var(--color-text);
}
.step__radio-card__desc {
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.4;
}
/* ── Chip list (Search step) ──────────────────────────────────────────────── */
.step__chip-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
min-height: 36px;
}
.step__chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-primary-light);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: var(--radius-full);
font-size: 0.825rem;
color: var(--color-primary);
font-weight: 500;
}
.step__chip__remove {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary);
padding: 0;
line-height: 1;
font-size: 1rem;
opacity: 0.6;
transition: opacity var(--transition);
}
.step__chip__remove:hover {
opacity: 1;
}
.step__chip-input-row {
display: flex;
gap: var(--space-2);
}
.step__chip-input-row .step__input {
flex: 1;
}
/* ── Two-column layout (Search step) ─────────────────────────────────────── */
.step__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
}
@media (max-width: 520px) {
.step__cols {
grid-template-columns: 1fr;
}
}
/* ── Expandable (advanced section) ───────────────────────────────────────── */
.step__expandable {
margin-bottom: var(--space-4);
}
.step__expandable__toggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.875rem;
font-family: var(--font-body);
padding: var(--space-2) 0;
display: flex;
align-items: center;
gap: var(--space-2);
transition: color var(--transition);
}
.step__expandable__toggle:hover {
color: var(--color-text);
}
.step__expandable__body {
padding: var(--space-4);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
margin-top: var(--space-2);
background: var(--color-surface-alt);
}
/* ── Navigation footer ────────────────────────────────────────────────────── */
.step__nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-8);
padding-top: var(--space-6);
border-top: 1px solid var(--color-border-light);
gap: var(--space-3);
}
.step__nav--end {
justify-content: flex-end;
}
.btn-primary {
padding: var(--space-2) var(--space-6);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), opacity var(--transition);
min-height: 44px;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
padding: var(--space-2) var(--space-4);
background: none;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
cursor: pointer;
transition: color var(--transition), border-color var(--transition);
min-height: 44px;
}
.btn-ghost:hover:not(:disabled) {
color: var(--color-text);
border-color: var(--color-border);
}
.btn-ghost:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
padding: var(--space-2) var(--space-4);
background: var(--color-surface-alt);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.875rem;
cursor: pointer;
transition: background var(--transition);
min-height: 40px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border-light);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}