feat: Interview prep Q&A, cf-orch hardware profile, a11y fixes, dark theme
Some checks failed
CI / Backend (Python) (push) Failing after 2m15s
CI / Frontend (Vue) (push) Failing after 21s
Mirror / mirror (push) Failing after 9s

Backend
- dev-api.py: Q&A suggest endpoint, Log Contact, cf-orch node detection in wizard
  hardware step, canonical search_profiles format (profiles:[...]), connections
  settings endpoints, Resume Library endpoints
- db_migrate.py: migrations 002/003/004 — ATS columns, resume review, final
  resume struct
- discover.py: _normalize_profiles() for legacy wizard YAML format compat
- resume_optimizer.py: section-by-section resume parsing + scoring
- task_runner.py: Q&A and contact-log task types
- company_research.py: accessibility brief column wiring
- generate_cover_letter.py: restore _candidate module-level binding

Frontend
- InterviewPrepView.vue: Q&A chat tab, Log Contact form, MarkdownView rendering
- InterviewCard.vue: new reusable card component for interviews kanban
- InterviewsView.vue: rejected analytics section with stage breakdown chips
- ResumeProfileView.vue: sync with new resume store shape
- SearchPrefsView.vue: cf-orch toggle, profile format migration
- SystemSettingsView.vue: connections settings wiring
- ConnectionsSettingsView.vue: new view for integration connections
- MarkdownView.vue: new component for safe markdown rendering
- ApplyWorkspace.vue: a11y — h1→h2 demotion, aria-expanded on Q&A toggle,
  confirmation dialog on Reject action (#98 #99 #100)
- peregrine.css: explicit [data-theme="dark"] token block for light-OS users (#101),
  :focus-visible outline (#97)
- wizard.css: cf-orch hardware step styles
- WizardHardwareStep.vue: cf-orch node display, profile selection with orch option
- WizardLayout.vue: hardware step wiring

Infra
- compose.yml / compose.cloud.yml: cf-orch agent sidecar, llm.cloud.yaml mount
- Dockerfile.cfcore: cf-core editable install in image build
- HANDOFF-xanderland.md: Podman/systemd setup guide for beta tester
- podman-standalone.sh: standalone Podman run script

Tests
- test_dev_api_settings.py: remove stale worktree path bootstrap (credential_store
  now in main repo); fix job_boards fixture to use non-empty list
- test_wizard_api.py: update profiles assertion to superset check (cf-orch added);
  update step6 assertion to canonical profiles[].titles format
This commit is contained in:
pyr0ball 2026-04-14 17:01:18 -07:00
parent 91943022a8
commit 8e36863a49
51 changed files with 3823 additions and 385 deletions

View file

@ -26,6 +26,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY circuitforge-core/ /circuitforge-core/
RUN pip install --no-cache-dir /circuitforge-core
# circuitforge-orch client — needed for LLMRouter cf_orch allocation.
# Optional: if the directory doesn't exist the COPY will fail at build time; keep
# cf-orch as a sibling of peregrine in the build context.
COPY circuitforge-orch/ /circuitforge-orch/
RUN pip install --no-cache-dir /circuitforge-orch
COPY peregrine/requirements.txt .
# Skip the cfcore line — already installed above from the local copy
RUN grep -v 'circuitforge-core' requirements.txt | pip install --no-cache-dir -r /dev/stdin
@ -39,6 +45,13 @@ COPY peregrine/scrapers/ /app/scrapers/
COPY peregrine/ .
# Remove per-user config files that are gitignored but may exist locally.
# Defense-in-depth: the parent .dockerignore should already exclude these,
# but an explicit rm guarantees they never end up in the cloud image.
RUN rm -f config/user.yaml config/plain_text_resume.yaml config/notion.yaml \
config/email.yaml config/tokens.yaml config/craigslist.yaml \
config/adzuna.yaml .env
EXPOSE 8501
CMD ["streamlit", "run", "app/app.py", \

153
HANDOFF-xanderland.md Normal file
View file

@ -0,0 +1,153 @@
# Peregrine → xanderland.tv Setup Handoff
**Written from:** dev machine (CircuitForge dev env)
**Target:** xanderland.tv (beta tester, rootful Podman + systemd)
**Date:** 2026-02-27
---
## What we're doing
Getting Peregrine running on the beta tester's server as a Podman container managed by systemd. He already runs SearXNG and other services in the same style — rootful Podman with `--net=host`, `--restart=unless-stopped`, registered as systemd units.
The script `podman-standalone.sh` in the repo root handles the container setup.
---
## Step 1 — Get the repo onto xanderland.tv
From navi (or directly if you have a route):
```bash
ssh xanderland.tv "sudo git clone <repo-url> /opt/peregrine"
```
Or if it's already there, just pull:
```bash
ssh xanderland.tv "cd /opt/peregrine && sudo git pull"
```
---
## Step 2 — Verify /opt/peregrine looks right
```bash
ssh xanderland.tv "ls /opt/peregrine"
```
Expect to see: `Dockerfile`, `compose.yml`, `manage.sh`, `podman-standalone.sh`, `config/`, `app/`, `scripts/`, etc.
---
## Step 3 — Config
```bash
ssh xanderland.tv
cd /opt/peregrine
sudo mkdir -p data
sudo cp config/llm.yaml.example config/llm.yaml
sudo cp config/notion.yaml.example config/notion.yaml # only if he wants Notion sync
```
Then edit `config/llm.yaml` and set `searxng_url` to his existing SearXNG instance
(default is `http://localhost:8888` — confirm his actual port).
He won't need Anthropic/OpenAI keys to start — the setup wizard lets him pick local Ollama
or whatever he has running.
---
## Step 4 — Fix DOCS_DIR in the script
The script defaults `DOCS_DIR=/Library/Documents/JobSearch` which is the original user's path.
Update it to wherever his job search documents actually live, or a placeholder empty dir:
```bash
sudo mkdir -p /opt/peregrine/docs # placeholder if he has no docs yet
```
Then edit the script:
```bash
sudo sed -i 's|DOCS_DIR=.*|DOCS_DIR=/opt/peregrine/docs|' /opt/peregrine/podman-standalone.sh
```
---
## Step 5 — Build the image
```bash
ssh xanderland.tv "cd /opt/peregrine && sudo podman build -t localhost/peregrine:latest ."
```
Takes a few minutes on first run (downloads python:3.11-slim, installs deps).
---
## Step 6 — Run the script
```bash
ssh xanderland.tv "sudo bash /opt/peregrine/podman-standalone.sh"
```
This starts a single container (`peregrine`) with `--net=host` and `--restart=unless-stopped`.
SearXNG is NOT included — his existing instance is used.
Verify it came up:
```bash
ssh xanderland.tv "sudo podman ps | grep peregrine"
ssh xanderland.tv "sudo podman logs peregrine"
```
Health check endpoint: `http://xanderland.tv:8501/_stcore/health`
---
## Step 7 — Register as a systemd service
```bash
ssh xanderland.tv
sudo podman generate systemd --new --name peregrine \
| sudo tee /etc/systemd/system/peregrine.service
sudo systemctl daemon-reload
sudo systemctl enable --now peregrine
```
Confirm:
```bash
sudo systemctl status peregrine
```
---
## Step 8 — First-run wizard
Open `http://xanderland.tv:8501` in a browser.
The setup wizard (page 0) will gate the app until `config/user.yaml` is created.
He'll fill in his profile — name, resume, LLM backend preferences. This writes
`config/user.yaml` and unlocks the rest of the UI.
---
## Troubleshooting
| Symptom | Check |
|---------|-------|
| Container exits immediately | `sudo podman logs peregrine` — usually a missing config file |
| Port 8501 already in use | `sudo ss -tlnp \| grep 8501` — something else on that port |
| SearXNG not reachable | Confirm `searxng_url` in `config/llm.yaml` and that JSON format is enabled in SearXNG settings |
| Wizard loops / won't save | `config/` volume mount permissions — `sudo chown -R 1000:1000 /opt/peregrine/config` |
---
## To update Peregrine later
```bash
cd /opt/peregrine
sudo git pull
sudo podman build -t localhost/peregrine:latest .
sudo podman restart peregrine
```
No need to touch the systemd unit — it launches fresh via `--new` in the generate step.

View file

@ -14,23 +14,22 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.user_profile import UserProfile
_USER_YAML = Path(__file__).parent.parent / "config" / "user.yaml"
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
_name = _profile.name if _profile else "Job Seeker"
from scripts.db import init_db, get_job_counts, purge_jobs, purge_email_data, \
purge_non_remote, archive_jobs, kill_stuck_tasks, cancel_task, \
get_task_for_job, get_active_tasks, insert_job, get_existing_urls
from scripts.task_runner import submit_task
from app.cloud_session import resolve_session, get_db_path
_CONFIG_DIR = Path(__file__).parent.parent / "config"
from app.cloud_session import resolve_session, get_db_path, get_config_dir
resolve_session("peregrine")
init_db(get_db_path())
_CONFIG_DIR = get_config_dir()
_USER_YAML = _CONFIG_DIR / "user.yaml"
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
_name = _profile.name if _profile else "Job Seeker"
def _email_configured() -> bool:
_e = Path(__file__).parent.parent / "config" / "email.yaml"
_e = get_config_dir() / "email.yaml"
if not _e.exists():
return False
import yaml as _yaml
@ -38,7 +37,7 @@ def _email_configured() -> bool:
return bool(_cfg.get("username") or _cfg.get("user") or _cfg.get("imap_host"))
def _notion_configured() -> bool:
_n = Path(__file__).parent.parent / "config" / "notion.yaml"
_n = get_config_dir() / "notion.yaml"
if not _n.exists():
return False
import yaml as _yaml
@ -46,7 +45,7 @@ def _notion_configured() -> bool:
return bool(_cfg.get("token"))
def _keywords_configured() -> bool:
_k = Path(__file__).parent.parent / "config" / "resume_keywords.yaml"
_k = get_config_dir() / "resume_keywords.yaml"
if not _k.exists():
return False
import yaml as _yaml

View file

@ -203,8 +203,16 @@ def get_config_dir() -> Path:
isolated and never shared across tenants.
Local: repo-level config/ directory.
"""
if CLOUD_MODE and st.session_state.get("db_path"):
return Path(st.session_state["db_path"]).parent / "config"
if CLOUD_MODE:
db_path = st.session_state.get("db_path")
if db_path:
return Path(db_path).parent / "config"
# Session not resolved yet (resolve_session() should have called st.stop() already).
# Return an isolated empty temp dir rather than the repo config, which may contain
# another user's data baked into the image.
_safe = Path("/tmp/peregrine-cloud-noconfig")
_safe.mkdir(exist_ok=True)
return _safe
return Path(__file__).parent.parent / "config"

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

View file

@ -51,6 +51,8 @@ services:
dockerfile: peregrine/Dockerfile.cfcore
command: >
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
ports:
- "127.0.0.1:8601:8601" # localhost-only — Caddy + avocet imitate tab
volumes:
- /devl/menagerie-data:/devl/menagerie-data
- ./config/llm.cloud.yaml:/app/config/llm.yaml:ro
@ -65,6 +67,7 @@ services:
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
- PYTHONUNBUFFERED=1
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
- CF_ORCH_URL=http://host.docker.internal:7700
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
@ -81,6 +84,9 @@ services:
- api
restart: unless-stopped
# cf-orch-agent: not needed in cloud — a host-native agent already runs on :7701
# and is registered with the coordinator. app/api reach it via CF_ORCH_URL.
searxng:
image: searxng/searxng:latest
volumes:

View file

@ -61,6 +61,7 @@ services:
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
- CF_ORCH_URL=${CF_ORCH_URL:-http://host.docker.internal:7700}
- PYTHONUNBUFFERED=1
extra_hosts:
- "host.docker.internal:host-gateway"
@ -129,6 +130,31 @@ services:
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
restart: unless-stopped
cf-orch-agent:
build:
context: ..
dockerfile: peregrine/Dockerfile.cfcore
command: ["/bin/sh", "/app/docker/cf-orch-agent/start.sh"]
ports:
- "${CF_ORCH_AGENT_PORT:-7701}:7701"
environment:
- CF_ORCH_COORDINATOR_URL=${CF_ORCH_COORDINATOR_URL:-http://host.docker.internal:7700}
- CF_ORCH_NODE_ID=${CF_ORCH_NODE_ID:-peregrine}
- CF_ORCH_AGENT_PORT=${CF_ORCH_AGENT_PORT:-7701}
- CF_ORCH_ADVERTISE_HOST=${CF_ORCH_ADVERTISE_HOST:-}
- PYTHONUNBUFFERED=1
extra_hosts:
- "host.docker.internal:host-gateway"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
restart: unless-stopped
finetune:
build:
context: .

View file

@ -0,0 +1,23 @@
# config/label_tool.yaml — Multi-account IMAP config for the email label tool
# Copy to config/label_tool.yaml and fill in your credentials.
# This file is gitignored.
accounts:
- name: "Gmail"
host: "imap.gmail.com"
port: 993
username: "you@gmail.com"
password: "your-app-password" # Use an App Password, not your login password
folder: "INBOX"
days_back: 90
- name: "Outlook"
host: "outlook.office365.com"
port: 993
username: "you@outlook.com"
password: "your-app-password"
folder: "INBOX"
days_back: 90
# Optional: limit emails fetched per account per run (0 = unlimited)
max_per_account: 500

View file

@ -45,6 +45,11 @@ backends:
model: __auto__
supports_images: false
type: openai_compat
cf_orch:
service: vllm
model_candidates:
- Qwen2.5-3B-Instruct
ttl_s: 300
vllm_research:
api_key: ''
base_url: http://host.docker.internal:8000/v1
@ -52,6 +57,11 @@ backends:
model: __auto__
supports_images: false
type: openai_compat
cf_orch:
service: vllm
model_candidates:
- Qwen2.5-3B-Instruct
ttl_s: 300
fallback_order:
- vllm
- ollama

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
#!/bin/sh
# Start the cf-orch agent. Adds --advertise-host only when CF_ORCH_ADVERTISE_HOST is set.
set -e
ARGS="--coordinator ${CF_ORCH_COORDINATOR_URL:-http://host.docker.internal:7700} \
--node-id ${CF_ORCH_NODE_ID:-peregrine} \
--host 0.0.0.0 \
--port ${CF_ORCH_AGENT_PORT:-7701}"
if [ -n "${CF_ORCH_ADVERTISE_HOST}" ]; then
ARGS="$ARGS --advertise-host ${CF_ORCH_ADVERTISE_HOST}"
fi
exec cf-orch agent $ARGS

View file

@ -4,6 +4,8 @@
Peregrine automates the full job search lifecycle: discovery, matching, cover letter generation, application tracking, and interview preparation. It is privacy-first and local-first — your data never leaves your machine unless you configure an external integration.
![Peregrine dashboard](screenshots/01-dashboard.png)
---
## Quick Start

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View file

@ -1,5 +1,7 @@
# Apply Workspace
![Peregrine apply workspace with cover letter generator and ATS optimizer](../screenshots/03-apply.png)
The Apply Workspace is where you generate cover letters, export application documents, and record that you have applied to a job.
---

View file

@ -1,5 +1,7 @@
# Job Review
![Peregrine job review triage](../screenshots/02-review.png)
The Job Review page is where you approve or reject newly discovered jobs before they enter the application pipeline.
---

View file

@ -0,0 +1,7 @@
-- Add ATS resume optimizer columns introduced in v0.8.x.
-- Existing DBs that were created before the baseline included these columns
-- need this migration to add them. Safe to run on new DBs: IF NOT EXISTS guards
-- are not available for ADD COLUMN in SQLite, so we use a try/ignore pattern
-- at the application level (db_migrate.py wraps each migration in a transaction).
ALTER TABLE jobs ADD COLUMN optimized_resume TEXT;
ALTER TABLE jobs ADD COLUMN ats_gap_report TEXT;

View file

@ -0,0 +1,3 @@
-- Resume review draft and version archive columns (migration 003)
ALTER TABLE jobs ADD COLUMN resume_draft_json TEXT;
ALTER TABLE jobs ADD COLUMN resume_archive_json TEXT;

View file

@ -0,0 +1,5 @@
-- Migration 004: add resume_final_struct to jobs table
-- Stores the approved resume as a structured JSON dict alongside the plain text
-- (resume_optimized_text). Enables YAML export and future re-processing without
-- re-parsing the plain text.
ALTER TABLE jobs ADD COLUMN resume_final_struct TEXT;

92
podman-standalone.sh Executable file
View file

@ -0,0 +1,92 @@
#!/usr/bin/env bash
# podman-standalone.sh — Peregrine rootful Podman setup (no Compose)
#
# For beta testers running system Podman (non-rootless) with systemd.
# Mirrors the manage.sh "remote" profile: app + SearXNG only.
# Ollama/vLLM/vision are expected as host services if needed.
#
# ── Prerequisites ────────────────────────────────────────────────────────────
# 1. Clone the repo:
# sudo git clone <repo-url> /opt/peregrine
#
# 2. Build the app image:
# cd /opt/peregrine && sudo podman build -t localhost/peregrine:latest .
#
# 3. Create a config directory and copy the example configs:
# sudo mkdir -p /opt/peregrine/{config,data}
# sudo cp /opt/peregrine/config/*.example /opt/peregrine/config/
# # Edit /opt/peregrine/config/llm.yaml, notion.yaml, etc. as needed
#
# 4. Run this script:
# sudo bash /opt/peregrine/podman-standalone.sh
#
# ── After setup — generate systemd unit files ────────────────────────────────
# sudo podman generate systemd --new --name peregrine-searxng \
# | sudo tee /etc/systemd/system/peregrine-searxng.service
# sudo podman generate systemd --new --name peregrine \
# | sudo tee /etc/systemd/system/peregrine.service
# sudo systemctl daemon-reload
# sudo systemctl enable --now peregrine-searxng peregrine
#
# ── SearXNG ──────────────────────────────────────────────────────────────────
# Peregrine expects a SearXNG instance with JSON format enabled.
# If you already run one, skip the SearXNG container and set the URL in
# config/llm.yaml (searxng_url key). The default is http://localhost:8888.
#
# ── Ports ────────────────────────────────────────────────────────────────────
# Peregrine UI → http://localhost:8501
#
# ── To use a different Streamlit port ────────────────────────────────────────
# Uncomment the CMD override at the bottom of the peregrine run block and
# set PORT= to your desired port. The Dockerfile default is 8501.
#
set -euo pipefail
REPO_DIR=/opt/peregrine
DATA_DIR=/opt/peregrine/data
DOCS_DIR=/Library/Documents/JobSearch # ← adjust to your docs path
TZ=America/Los_Angeles
# ── Peregrine App ─────────────────────────────────────────────────────────────
# Image is built locally — no registry auto-update label.
# To update: sudo podman build -t localhost/peregrine:latest /opt/peregrine
# sudo podman restart peregrine
#
# Env vars: ANTHROPIC_API_KEY, OPENAI_COMPAT_URL, OPENAI_COMPAT_KEY are
# optional — only needed if you're using those backends in config/llm.yaml.
#
sudo podman run -d \
--name=peregrine \
--restart=unless-stopped \
--net=host \
-v ${REPO_DIR}/config:/app/config:Z \
-v ${DATA_DIR}:/app/data:Z \
-v ${DOCS_DIR}:/docs:z \
-e STAGING_DB=/app/data/staging.db \
-e DOCS_DIR=/docs \
-e PYTHONUNBUFFERED=1 \
-e PYTHONLOGGING=WARNING \
-e TZ=${TZ} \
--health-cmd="curl -f http://localhost:8501/_stcore/health || exit 1" \
--health-interval=30s \
--health-timeout=10s \
--health-start-period=60s \
--health-retries=3 \
localhost/peregrine:latest
# To override the default port (8501), uncomment and edit the line below,
# then remove the image name above and place it at the end of the CMD:
# streamlit run app/app.py --server.port=8501 --server.headless=true --server.fileWatcherType=none
echo ""
echo "Peregrine is starting up."
echo " App: http://localhost:8501"
echo ""
echo "Check container health with:"
echo " sudo podman ps"
echo " sudo podman logs peregrine"
echo ""
echo "To register as a systemd service:"
echo " sudo podman generate systemd --new --name peregrine \\"
echo " | sudo tee /etc/systemd/system/peregrine.service"
echo " sudo systemctl daemon-reload"
echo " sudo systemctl enable --now peregrine"

View file

@ -277,7 +277,8 @@ def _load_resume_and_keywords() -> tuple[dict, list[str]]:
return resume, keywords
def research_company(job: dict, use_scraper: bool = True, on_stage=None) -> dict:
def research_company(job: dict, use_scraper: bool = True, on_stage=None,
config_path: "Path | None" = None) -> dict:
"""
Generate a pre-interview research brief for a job.
@ -295,7 +296,7 @@ def research_company(job: dict, use_scraper: bool = True, on_stage=None) -> dict
"""
from scripts.llm_router import LLMRouter
router = LLMRouter()
router = LLMRouter(config_path=config_path) if config_path else LLMRouter()
research_order = router.config.get("research_fallback_order") or router.config["fallback_order"]
company = job.get("company") or "the company"
title = job.get("title") or "this role"

View file

@ -56,7 +56,56 @@ def migrate_db(db_path: Path) -> list[str]:
sql = path.read_text(encoding="utf-8")
log.info("Applying migration %s to %s", version, db_path.name)
try:
con.executescript(sql)
# Execute statements individually so that ALTER TABLE ADD COLUMN
# errors caused by already-existing columns (pre-migration DBs
# created from a newer schema) are treated as no-ops rather than
# fatal failures.
statements = [s.strip() for s in sql.split(";") if s.strip()]
for stmt in statements:
# Strip leading SQL comment lines (-- ...) before processing.
# Checking startswith("--") on the raw chunk would skip entire
# multi-line statements whose first line is a comment.
stripped_lines = [
ln for ln in stmt.splitlines()
if not ln.strip().startswith("--")
]
stmt = "\n".join(stripped_lines).strip()
if not stmt:
continue
# Pre-check: if this is ADD COLUMN and the column already exists, skip.
# This guards against schema_migrations being ahead of the actual schema
# (e.g. DB reset after migrations were recorded).
stmt_upper = stmt.upper()
if "ALTER TABLE" in stmt_upper and "ADD COLUMN" in stmt_upper:
# Extract table name and column name from the statement
import re as _re
m = _re.match(
r"ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)",
stmt, _re.IGNORECASE
)
if m:
tbl, col = m.group(1), m.group(2)
existing = {
row[1]
for row in con.execute(f"PRAGMA table_info({tbl})")
}
if col in existing:
log.info(
"Migration %s: column %s.%s already exists, skipping",
version, tbl, col,
)
continue
try:
con.execute(stmt)
except sqlite3.OperationalError as stmt_exc:
msg = str(stmt_exc).lower()
if "duplicate column name" in msg or "already exists" in msg:
log.info(
"Migration %s: statement already applied, skipping: %s",
version, stmt_exc,
)
else:
raise
con.execute(
"INSERT INTO schema_migrations (version) VALUES (?)", (version,)
)

View file

@ -34,11 +34,38 @@ CUSTOM_SCRAPERS: dict[str, object] = {
}
def _normalize_profiles(raw: dict) -> dict:
"""Normalize search_profiles.yaml to the canonical {profiles: [...]} format.
The onboarding wizard (pre-fix) wrote a flat `default: {...}` structure.
Canonical format is `profiles: [{name, titles/job_titles, boards, ...}]`.
This converts on load so both formats work without a migration.
"""
if "profiles" in raw:
return raw
# Wizard-written format: top-level keys are profile names (usually "default")
profiles = []
for name, body in raw.items():
if not isinstance(body, dict):
continue
# job_boards: [{name, enabled}] → boards: [name] (enabled only)
job_boards = body.pop("job_boards", None)
if job_boards and "boards" not in body:
body["boards"] = [b["name"] for b in job_boards if b.get("enabled", True)]
# blocklist_* keys live in load_blocklist, not per-profile — drop them
body.pop("blocklist_companies", None)
body.pop("blocklist_industries", None)
body.pop("blocklist_locations", None)
profiles.append({"name": name, **body})
return {"profiles": profiles}
def load_config(config_dir: Path | None = None) -> tuple[dict, dict]:
cfg = config_dir or CONFIG_DIR
profiles_path = cfg / "search_profiles.yaml"
notion_path = cfg / "notion.yaml"
profiles = yaml.safe_load(profiles_path.read_text())
raw = yaml.safe_load(profiles_path.read_text()) or {}
profiles = _normalize_profiles(raw)
notion_cfg = yaml.safe_load(notion_path.read_text()) if notion_path.exists() else {"field_map": {}, "token": None, "database_id": None}
return profiles, notion_cfg
@ -212,14 +239,43 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
_rp = profile.get("remote_preference", "both")
_is_remote: bool | None = True if _rp == "remote" else (False if _rp == "onsite" else None)
# When filtering for remote-only, also drop hybrid roles at the description level.
# Job boards (especially LinkedIn) tag hybrid listings as is_remote=True, so the
# board-side filter alone is not reliable. We match specific work-arrangement
# phrases to avoid false positives like "hybrid cloud" or "hybrid architecture".
_HYBRID_PHRASES = [
"hybrid role", "hybrid position", "hybrid work", "hybrid schedule",
"hybrid model", "hybrid arrangement", "hybrid opportunity",
"in-office/remote", "in office/remote", "remote/in-office",
"remote/office", "office/remote",
"days in office", "days per week in", "days onsite", "days on-site",
"required to be in office", "required in office",
]
if _rp == "remote":
exclude_kw = exclude_kw + _HYBRID_PHRASES
for location in profile["locations"]:
# ── JobSpy boards ──────────────────────────────────────────────────
if boards:
print(f" [jobspy] {location} — boards: {', '.join(boards)}")
# Validate boards against the installed JobSpy Site enum.
# One unsupported name in the list aborts the entire scrape_jobs() call.
try:
from jobspy import Site as _Site
_valid = {s.value for s in _Site}
_filtered = [b for b in boards if b in _valid]
_dropped = [b for b in boards if b not in _valid]
if _dropped:
print(f" [jobspy] Skipping unsupported boards: {', '.join(_dropped)}")
except ImportError:
_filtered = boards # fallback: pass through unchanged
if not _filtered:
print(f" [jobspy] No valid boards for {location} — skipping")
continue
print(f" [jobspy] {location} — boards: {', '.join(_filtered)}")
try:
jobspy_kwargs: dict = dict(
site_name=boards,
site_name=_filtered,
search_term=" OR ".join(f'"{t}"' for t in (profile.get("titles") or profile.get("job_titles", []))),
location=location,
results_wanted=results_per_board,

View file

@ -42,6 +42,7 @@ def _build_system_context(profile=None) -> str:
return " ".join(parts)
SYSTEM_CONTEXT = _build_system_context()
_candidate = _profile.name if _profile else "the candidate"
# ── Mission-alignment detection ───────────────────────────────────────────────

View file

@ -301,7 +301,7 @@ def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str)
elif section == "experience":
# For experience, we keep the structured entries but replace the bullets.
# The LLM rewrites the whole section as plain text; we re-parse the bullets.
updated["experience"] = _reparse_experience_bullets(resume["experience"], rewritten)
updated["experience"] = _reparse_experience_bullets(resume.get("experience", []), rewritten)
return updated
@ -345,6 +345,198 @@ def _reparse_experience_bullets(
return result
# ── Gap framing ───────────────────────────────────────────────────────────────
def frame_skill_gaps(
struct: dict[str, Any],
gap_framings: list[dict],
job: dict[str, Any],
candidate_voice: str = "",
) -> dict[str, Any]:
"""Inject honest framing language for skills the candidate doesn't have directly.
For each gap framing decision the user provided:
- mode "adjacent": user has related experience injects one bridging sentence
into the most relevant experience entry's bullets
- mode "learning": actively developing the skill prepends a structured
"Developing: X (context)" note to the skills list
- mode "skip": no connection at all no change
The user-supplied context text is the source of truth. The LLM's job is only
to phrase it naturally in resume style not to invent new claims.
Args:
struct: Resume dict (already processed by apply_review_decisions).
gap_framings: List of dicts with keys:
skill the ATS term the candidate lacks
mode "adjacent" | "learning" | "skip"
context candidate's own words describing their related background
job: Job dict for role context in prompts.
candidate_voice: Free-text style note from user.yaml.
Returns:
New resume dict with framing language injected.
"""
from scripts.llm_router import LLMRouter
router = LLMRouter()
updated = dict(struct)
updated["experience"] = [dict(e) for e in (struct.get("experience") or [])]
adjacent_framings = [f for f in gap_framings if f.get("mode") == "adjacent" and f.get("context")]
learning_framings = [f for f in gap_framings if f.get("mode") == "learning" and f.get("context")]
# ── Adjacent experience: inject bridging sentence into most relevant entry ─
for framing in adjacent_framings:
skill = framing["skill"]
context = framing["context"]
# Find the experience entry most likely to be relevant (simple keyword match)
best_entry_idx = _find_most_relevant_entry(updated["experience"], skill)
if best_entry_idx is None:
continue
entry = updated["experience"][best_entry_idx]
bullets = list(entry.get("bullets") or [])
voice_note = (
f'\n\nCandidate voice/style: "{candidate_voice}". Match this tone.'
) if candidate_voice else ""
prompt = (
f"You are adding one honest framing sentence to a resume bullet list.\n\n"
f"The candidate does not have direct experience with '{skill}', "
f"but they have relevant background they described as:\n"
f' "{context}"\n\n'
f"Job context: {job.get('title', '')} at {job.get('company', '')}.\n\n"
f"RULES:\n"
f"1. Add exactly ONE new bullet point that bridges their background to '{skill}'.\n"
f"2. Do NOT fabricate anything beyond what their context description says.\n"
f"3. Use honest language: 'adjacent experience in', 'strong foundation applicable to', "
f" 'directly transferable background in', etc.\n"
f"4. Return ONLY the single new bullet text — no prefix, no explanation."
f"{voice_note}\n\n"
f"Existing bullets for context:\n"
+ "\n".join(f"{b}" for b in bullets[:3])
)
try:
new_bullet = router.complete(prompt).strip()
new_bullet = re.sub(r"^[•\-–—*◦▪▸►]\s*", "", new_bullet).strip()
if new_bullet:
bullets.append(new_bullet)
new_entry = dict(entry)
new_entry["bullets"] = bullets
updated["experience"][best_entry_idx] = new_entry
except Exception:
log.warning(
"[resume_optimizer] frame_skill_gaps adjacent failed for skill %r", skill,
exc_info=True,
)
# ── Learning framing: add structured note to skills list ──────────────────
if learning_framings:
skills = list(updated.get("skills") or [])
for framing in learning_framings:
skill = framing["skill"]
context = framing["context"].strip()
# Format: "Developing: Kubernetes (strong Docker/container orchestration background)"
note = f"Developing: {skill} ({context})" if context else f"Developing: {skill}"
if note not in skills:
skills.append(note)
updated["skills"] = skills
return updated
def _find_most_relevant_entry(
experience: list[dict],
skill: str,
) -> int | None:
"""Return the index of the experience entry most relevant to a skill term.
Uses simple keyword overlap between the skill and entry title/bullets.
Falls back to the most recent (first) entry if no match found.
"""
if not experience:
return None
skill_words = set(skill.lower().split())
best_idx = 0
best_score = -1
for i, entry in enumerate(experience):
entry_text = (
(entry.get("title") or "") + " " +
" ".join(entry.get("bullets") or [])
).lower()
entry_words = set(entry_text.split())
score = len(skill_words & entry_words)
if score > best_score:
best_score = score
best_idx = i
return best_idx
def apply_review_decisions(
draft: dict[str, Any],
decisions: dict[str, Any],
) -> dict[str, Any]:
"""Apply user section-level review decisions to the rewritten struct.
Handles approved skills, summary accept/reject, and per-entry experience
accept/reject. Returns the updated struct; does not call the LLM.
Args:
draft: The review draft dict from build_review_diff (contains
"sections" and "rewritten_struct").
decisions: Dict of per-section decisions from the review UI:
skills: {"approved_additions": [...]}
summary: {"accepted": bool}
experience: {"accepted_entries": [{"title", "company", "accepted"}]}
Returns:
Updated resume struct ready for gap framing and final render.
"""
struct = dict(draft.get("rewritten_struct") or {})
sections = draft.get("sections") or []
# ── Skills: keep original + only approved additions ────────────────────
skills_decision = decisions.get("skills", {})
approved_additions = set(skills_decision.get("approved_additions") or [])
for sec in sections:
if sec["section"] == "skills":
original_kept = set(sec.get("kept") or [])
struct["skills"] = sorted(original_kept | approved_additions)
break
# ── Summary: accept proposed or revert to original ──────────────────────
if not decisions.get("summary", {}).get("accepted", True):
for sec in sections:
if sec["section"] == "summary":
struct["career_summary"] = sec.get("original", struct.get("career_summary", ""))
break
# ── Experience: per-entry accept/reject ─────────────────────────────────
exp_decisions: dict[str, bool] = {
f"{ed.get('title', '')}|{ed.get('company', '')}": ed.get("accepted", True)
for ed in (decisions.get("experience", {}).get("accepted_entries") or [])
}
for sec in sections:
if sec["section"] == "experience":
for entry_diff in (sec.get("entries") or []):
key = f"{entry_diff['title']}|{entry_diff['company']}"
if not exp_decisions.get(key, True):
for exp_entry in (struct.get("experience") or []):
if (exp_entry.get("title") == entry_diff["title"] and
exp_entry.get("company") == entry_diff["company"]):
exp_entry["bullets"] = entry_diff["original_bullets"]
break
return struct
# ── Hallucination guard ───────────────────────────────────────────────────────
def hallucination_check(original: dict[str, Any], rewritten: dict[str, Any]) -> bool:
@ -437,3 +629,207 @@ def render_resume_text(resume: dict[str, Any]) -> str:
lines.append("")
return "\n".join(lines)
# ── Review diff builder ────────────────────────────────────────────────────────
def build_review_diff(
original: dict[str, Any],
rewritten: dict[str, Any],
) -> dict[str, Any]:
"""Build a structured diff between original and rewritten resume for the review UI.
Returns a dict with:
sections: list of per-section diffs
rewritten_struct: the full rewritten resume dict (used by finalize endpoint)
Each section diff has:
section: "skills" | "summary" | "experience"
type: "skills_diff" | "text_diff" | "bullets_diff"
For skills_diff:
added: list of new skill strings (each requires user approval)
removed: list of removed skill strings
kept: list of unchanged skills
For text_diff (summary):
original: str
proposed: str
For bullets_diff (experience):
entries: list of {title, company, original_bullets, proposed_bullets}
"""
sections = []
# ── Skills diff ────────────────────────────────────────────────────────
orig_skills = set(s.strip() for s in (original.get("skills") or []))
new_skills = set(s.strip() for s in (rewritten.get("skills") or []))
added = sorted(new_skills - orig_skills)
removed = sorted(orig_skills - new_skills)
kept = sorted(orig_skills & new_skills)
if added or removed:
sections.append({
"section": "skills",
"type": "skills_diff",
"added": added,
"removed": removed,
"kept": kept,
})
# ── Summary diff ───────────────────────────────────────────────────────
orig_summary = (original.get("career_summary") or "").strip()
new_summary = (rewritten.get("career_summary") or "").strip()
if orig_summary != new_summary and new_summary:
sections.append({
"section": "summary",
"type": "text_diff",
"original": orig_summary,
"proposed": new_summary,
})
# ── Experience diff ────────────────────────────────────────────────────
orig_exp = original.get("experience") or []
new_exp = rewritten.get("experience") or []
entry_diffs = []
for orig_entry, new_entry in zip(orig_exp, new_exp):
orig_bullets = orig_entry.get("bullets") or []
new_bullets = new_entry.get("bullets") or []
if orig_bullets != new_bullets:
entry_diffs.append({
"title": orig_entry.get("title", ""),
"company": orig_entry.get("company", ""),
"original_bullets": orig_bullets,
"proposed_bullets": new_bullets,
})
if entry_diffs:
sections.append({
"section": "experience",
"type": "bullets_diff",
"entries": entry_diffs,
})
return {
"sections": sections,
"rewritten_struct": rewritten,
}
# ── PDF export ─────────────────────────────────────────────────────────────────
def export_pdf(resume: dict[str, Any], output_path: str) -> None:
"""Render a structured resume dict to a clean PDF using reportlab.
Uses a single-column layout with section headers, consistent spacing,
and a readable sans-serif body font suitable for ATS submission.
Args:
resume: Structured resume dict (same format as resume_parser output).
output_path: Absolute path for the output .pdf file.
"""
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.units import inch
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
from reportlab.lib import colors
MARGIN = 0.75 * inch
name_style = ParagraphStyle(
"name", fontName="Helvetica-Bold", fontSize=16, leading=20,
alignment=TA_CENTER, spaceAfter=2,
)
contact_style = ParagraphStyle(
"contact", fontName="Helvetica", fontSize=9, leading=12,
alignment=TA_CENTER, spaceAfter=6,
textColor=colors.HexColor("#555555"),
)
section_style = ParagraphStyle(
"section", fontName="Helvetica-Bold", fontSize=10, leading=14,
spaceBefore=10, spaceAfter=2,
textColor=colors.HexColor("#1a1a2e"),
)
body_style = ParagraphStyle(
"body", fontName="Helvetica", fontSize=9, leading=13, alignment=TA_LEFT,
)
role_style = ParagraphStyle(
"role", fontName="Helvetica-Bold", fontSize=9, leading=13,
)
meta_style = ParagraphStyle(
"meta", fontName="Helvetica-Oblique", fontSize=8, leading=12,
textColor=colors.HexColor("#555555"), spaceAfter=2,
)
bullet_style = ParagraphStyle(
"bullet", fontName="Helvetica", fontSize=9, leading=13, leftIndent=12,
)
def hr():
return HRFlowable(width="100%", thickness=0.5,
color=colors.HexColor("#cccccc"),
spaceAfter=4, spaceBefore=2)
story = []
if resume.get("name"):
story.append(Paragraph(resume["name"], name_style))
contact_parts = [p for p in (
resume.get("email", ""), resume.get("phone", ""),
resume.get("location", ""), resume.get("linkedin", ""),
) if p]
if contact_parts:
story.append(Paragraph(" | ".join(contact_parts), contact_style))
story.append(hr())
summary = (resume.get("career_summary") or "").strip()
if summary:
story.append(Paragraph("SUMMARY", section_style))
story.append(hr())
story.append(Paragraph(summary, body_style))
story.append(Spacer(1, 4))
if resume.get("experience"):
story.append(Paragraph("EXPERIENCE", section_style))
story.append(hr())
for exp in resume["experience"]:
dates = f"{exp.get('start_date', '')}{exp.get('end_date', '')}"
story.append(Paragraph(
f"{exp.get('title', '')} | {exp.get('company', '')}", role_style
))
story.append(Paragraph(dates, meta_style))
for bullet in (exp.get("bullets") or []):
story.append(Paragraph(f"{bullet}", bullet_style))
story.append(Spacer(1, 4))
if resume.get("education"):
story.append(Paragraph("EDUCATION", section_style))
story.append(hr())
for edu in resume["education"]:
degree = f"{edu.get('degree', '')} {edu.get('field', '')}".strip()
story.append(Paragraph(
f"{degree} | {edu.get('institution', '')} {edu.get('graduation_year', '')}".strip(),
body_style,
))
story.append(Spacer(1, 4))
if resume.get("skills"):
story.append(Paragraph("SKILLS", section_style))
story.append(hr())
story.append(Paragraph(", ".join(resume["skills"]), body_style))
story.append(Spacer(1, 4))
if resume.get("achievements"):
story.append(Paragraph("ACHIEVEMENTS", section_style))
story.append(hr())
for a in resume["achievements"]:
story.append(Paragraph(f"{a}", bullet_style))
doc = SimpleDocTemplate(
output_path, pagesize=LETTER,
leftMargin=MARGIN, rightMargin=MARGIN,
topMargin=MARGIN, bottomMargin=MARGIN,
)
doc.build(story)

View file

@ -16,6 +16,61 @@ from pathlib import Path
log = logging.getLogger(__name__)
def _normalize_aihawk_resume(raw: dict) -> dict:
"""Convert a plain_text_resume.yaml (AIHawk format) into the optimizer struct.
Handles two AIHawk variants:
- Newer Peregrine wizard output: already uses bullets/start_date/end_date/career_summary
- Older raw AIHawk format: uses responsibilities (str), period ("YYYY Present")
"""
import re as _re
def _split_responsibilities(text: str) -> list[str]:
lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()]
return lines if lines else [text.strip()]
def _parse_period(period: str) -> tuple[str, str]:
parts = _re.split(r"\s*[–—-]\s*", period, maxsplit=1)
start = parts[0].strip() if parts else ""
end = parts[1].strip() if len(parts) > 1 else "Present"
return start, end
experience = []
for entry in raw.get("experience", []):
if "responsibilities" in entry:
bullets = _split_responsibilities(entry["responsibilities"])
else:
bullets = entry.get("bullets", [])
if "period" in entry:
start_date, end_date = _parse_period(entry["period"])
else:
start_date = entry.get("start_date", "")
end_date = entry.get("end_date", "Present")
experience.append({
"title": entry.get("title", ""),
"company": entry.get("company", ""),
"start_date": start_date,
"end_date": end_date,
"bullets": bullets,
})
# career_summary may be a string or absent; assessment field is a legacy bool in some profiles
career_summary = raw.get("career_summary", "")
if not isinstance(career_summary, str):
career_summary = ""
return {
"career_summary": career_summary,
"experience": experience,
"education": raw.get("education", []),
"skills": raw.get("skills", []),
"achievements": raw.get("achievements", []),
}
from scripts.db import (
DEFAULT_DB,
insert_task,
@ -196,9 +251,12 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
elif task_type == "company_research":
from scripts.company_research import research_company
_cfg_dir = Path(db_path).parent / "config"
_user_llm_cfg = _cfg_dir / "llm.yaml"
result = research_company(
job,
on_stage=lambda s: update_task_stage(db_path, task_id, s),
config_path=_user_llm_cfg if _user_llm_cfg.exists() else None,
)
save_research(db_path, job_id=job_id, **result)
@ -287,13 +345,25 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
)
from scripts.user_profile import load_user_profile
_user_yaml = Path(db_path).parent / "config" / "user.yaml"
description = job.get("description", "")
resume_path = load_user_profile().get("resume_path", "")
resume_path = load_user_profile(str(_user_yaml)).get("resume_path", "")
# Parse the candidate's resume
update_task_stage(db_path, task_id, "parsing resume")
resume_text = Path(resume_path).read_text(errors="replace") if resume_path else ""
resume_struct, parse_err = structure_resume(resume_text)
_plain_yaml = Path(db_path).parent / "config" / "plain_text_resume.yaml"
if resume_path and Path(resume_path).exists():
resume_text = Path(resume_path).read_text(errors="replace")
resume_struct, parse_err = structure_resume(resume_text)
elif _plain_yaml.exists():
import yaml as _yaml
_raw = _yaml.safe_load(_plain_yaml.read_text(encoding="utf-8")) or {}
resume_struct = _normalize_aihawk_resume(_raw)
resume_text = resume_struct.get("career_summary", "")
parse_err = ""
else:
resume_text = ""
resume_struct, parse_err = structure_resume("")
# Extract keyword gaps and build gap report (free tier)
update_task_stage(db_path, task_id, "extracting keyword gaps")
@ -301,21 +371,38 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
prioritized = prioritize_gaps(gaps, resume_struct)
gap_report = _json.dumps(prioritized, indent=2)
# Full rewrite (paid tier only)
rewritten_text = ""
# Full rewrite (paid tier only) → enters awaiting_review, not completed
p = _json.loads(params or "{}")
selected_gaps = p.get("selected_gaps", None)
if selected_gaps is not None:
selected_set = set(selected_gaps)
prioritized = [g for g in prioritized if g.get("term") in selected_set]
if p.get("full_rewrite", False):
update_task_stage(db_path, task_id, "rewriting resume sections")
candidate_voice = load_user_profile().get("candidate_voice", "")
candidate_voice = load_user_profile(str(_user_yaml)).get("candidate_voice", "")
rewritten = rewrite_for_ats(resume_struct, prioritized, job, candidate_voice)
if hallucination_check(resume_struct, rewritten):
rewritten_text = render_resume_text(rewritten)
from scripts.resume_optimizer import build_review_diff
from scripts.db import save_resume_draft
draft = build_review_diff(resume_struct, rewritten)
# Attach gap report to draft for reference in the review UI
draft["gap_report"] = prioritized
save_resume_draft(db_path, job_id=job_id,
draft_json=_json.dumps(draft))
# Save gap report now; final text written after user review
save_optimized_resume(db_path, job_id=job_id,
text="", gap_report=gap_report)
# Park task in awaiting_review — finalize endpoint resolves it
update_task_status(db_path, task_id, "awaiting_review")
return
else:
log.warning("[task_runner] resume_optimize hallucination check failed for job %d", job_id)
save_optimized_resume(db_path, job_id=job_id,
text=rewritten_text,
gap_report=gap_report)
save_optimized_resume(db_path, job_id=job_id,
text="", gap_report=gap_report)
else:
# Gap-only run (free tier): save report, no draft
save_optimized_resume(db_path, job_id=job_id,
text="", gap_report=gap_report)
elif task_type == "prepare_training":
from scripts.prepare_training_data import build_records, write_jsonl, DEFAULT_OUTPUT

View file

@ -7,35 +7,7 @@ from pathlib import Path
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
_WORKTREE = "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa"
# ── Path bootstrap ────────────────────────────────────────────────────────────
# dev_api.py inserts /Library/Development/CircuitForge/peregrine into sys.path
# at import time; the worktree has credential_store but the main repo doesn't.
# Insert the worktree first so 'scripts' resolves to the worktree version, then
# pre-cache it in sys.modules so Python won't re-look-up when dev_api adds the
# main peregrine root.
if _WORKTREE not in sys.path:
sys.path.insert(0, _WORKTREE)
# Pre-cache the worktree scripts package and submodules before dev_api import
import importlib, types
def _ensure_worktree_scripts():
import importlib.util as _ilu
_wt = _WORKTREE
# Only load if not already loaded from the worktree
_spec = _ilu.spec_from_file_location("scripts", f"{_wt}/scripts/__init__.py",
submodule_search_locations=[f"{_wt}/scripts"])
if _spec is None:
return
_mod = _ilu.module_from_spec(_spec)
sys.modules.setdefault("scripts", _mod)
try:
_spec.loader.exec_module(_mod)
except Exception:
pass
_ensure_worktree_scripts()
# credential_store.py was merged to main repo — no worktree path manipulation needed
@pytest.fixture(scope="module")
@ -211,7 +183,8 @@ def test_get_search_prefs_returns_dict(tmp_path, monkeypatch):
fake_path = tmp_path / "config" / "search_profiles.yaml"
fake_path.parent.mkdir(parents=True, exist_ok=True)
with open(fake_path, "w") as f:
yaml.dump({"default": {"remote_preference": "remote", "job_boards": []}}, f)
yaml.dump({"default": {"remote_preference": "remote",
"job_boards": [{"name": "linkedin", "enabled": True}]}}, f)
monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path)
from dev_api import app

View file

@ -104,7 +104,7 @@ class TestWizardHardware:
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 {"remote", "cpu", "single-gpu", "dual-gpu"}.issubset(set(body["profiles"]))
assert "gpus" in body
assert "suggested_profile" in body
@ -245,8 +245,10 @@ class TestWizardStep:
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"]
# Step 6 writes canonical {profiles: [{name, titles, locations, ...}]} format
default = next(p for p in prefs["profiles"] if p["name"] == "default")
assert default["titles"] == ["Software Engineer", "Backend Developer"]
assert "Remote" in default["locations"]
def test_step7_only_advances_counter(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"

39
web/package-lock.json generated
View file

@ -12,9 +12,12 @@
"@fontsource/fraunces": "^5.2.9",
"@fontsource/jetbrains-mono": "^5.2.8",
"@heroicons/vue": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1",
"animejs": "^4.3.6",
"dompurify": "^3.4.0",
"marked": "^18.0.0",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
@ -1718,6 +1721,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1735,6 +1747,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@ -2944,6 +2962,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
@ -3472,6 +3499,18 @@
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/marked": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",

View file

@ -15,9 +15,12 @@
"@fontsource/fraunces": "^5.2.9",
"@fontsource/jetbrains-mono": "^5.2.8",
"@heroicons/vue": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1",
"animejs": "^4.3.6",
"dompurify": "^3.4.0",
"marked": "^18.0.0",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^5.0.3"

View file

@ -77,6 +77,7 @@ body {
}
/* ── Dark mode ─────────────────────────────────────── */
/* Covers both: OS-level dark preference AND explicit dark theme selection in UI */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) {
--app-primary: #68A8D8; /* Falcon Blue (dark) — 6.54:1 on #16202e ✅ AA */
@ -97,6 +98,26 @@ body {
}
}
/* Explicit [data-theme="dark"] fires when user picks dark via theme picker
on a light-OS machine (where prefers-color-scheme: dark won't match) */
[data-theme="dark"]:not([data-theme="hacker"]) {
--app-primary: #68A8D8;
--app-primary-hover: #7BBDE6;
--app-primary-light: #0D1F35;
--app-accent: #F6872A;
--app-accent-hover: #FF9840;
--app-accent-light: #2D1505;
--app-accent-text: #1a2338;
--score-mid-high: #5ba3d9;
--status-synced: #9b8fea;
--status-survey: #b08fea;
--status-phone: #4ec9be;
--status-offer: #f5a43a;
}
/* ── Hacker mode (Konami easter egg) ──────────────── */
[data-theme="hacker"] {
--app-primary: #00ff41;

View file

@ -28,7 +28,7 @@
<span v-if="job.is_remote" class="remote-badge">Remote</span>
</div>
<h1 class="job-details__title">{{ job.title }}</h1>
<h2 class="job-details__title">{{ job.title }}</h2>
<div class="job-details__company">
{{ job.company }}
<span v-if="job.location" aria-hidden="true"> · </span>
@ -38,7 +38,7 @@
<!-- Description -->
<div class="job-details__desc" :class="{ 'job-details__desc--clamped': !descExpanded }">
{{ job.description ?? 'No description available.' }}
<MarkdownView :content="job.description ?? 'No description available.'" />
</div>
<button
v-if="(job.description?.length ?? 0) > 300"
@ -199,7 +199,7 @@
<!-- Application Q&A -->
<div class="qa-section">
<button class="section-toggle" @click="qaExpanded = !qaExpanded">
<button class="section-toggle" :aria-expanded="qaExpanded" @click="qaExpanded = !qaExpanded">
<span class="section-toggle__label">Application Q&amp;A</span>
<span v-if="qaItems.length" class="qa-count">{{ qaItems.length }}</span>
<span class="section-toggle__icon" aria-hidden="true">{{ qaExpanded ? '▲' : '▼' }}</span>
@ -290,6 +290,7 @@ import { useAppConfigStore } from '../stores/appConfig'
import type { Job } from '../stores/review'
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
import ResumeLibraryCard from './ResumeLibraryCard.vue'
import MarkdownView from './MarkdownView.vue'
const config = useAppConfigStore()
@ -458,6 +459,10 @@ async function markApplied() {
async function rejectListing() {
if (actioning.value) return
const title = job.value?.title ?? 'this listing'
const company = job.value?.company ?? ''
const label = company ? `"${title}" at ${company}` : `"${title}"`
if (!window.confirm(`Reject ${label}? This cannot be undone.`)) return
actioning.value = 'reject'
await useApiFetch(`/api/jobs/${props.jobId}/reject`, { method: 'POST' })
actioning.value = null
@ -706,7 +711,6 @@ declare module '../stores/review' {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word;
}
@ -860,7 +864,7 @@ declare module '../stores/review' {
overflow: hidden;
}
.cl-editor__textarea:focus { outline: none; }
.cl-editor__textarea:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
.cl-regen {
align-self: flex-end;
@ -1209,9 +1213,12 @@ declare module '../stores/review' {
}
.qa-item__answer:focus {
outline: none;
border-color: var(--app-primary);
}
.qa-item__answer:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.qa-suggest-btn { align-self: flex-end; }
@ -1234,9 +1241,12 @@ declare module '../stores/review' {
}
.qa-add__input:focus {
outline: none;
border-color: var(--app-primary);
}
.qa-add__input:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.qa-add__input::placeholder { color: var(--color-text-muted); }

View file

@ -4,6 +4,44 @@ import type { PipelineJob } from '../stores/interviews'
import type { StageSignal, PipelineStage } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'
// Date picker
const DATE_STAGES = new Set(['phone_screen', 'interviewing'])
function toDatetimeLocal(iso: string | null | undefined): string {
if (!iso) return ''
// Trim seconds/ms so <input type="datetime-local"> accepts it
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function onDateChange(value: string) {
if (!value) return
const prev = props.job.interview_date
// Optimistic update
props.job.interview_date = new Date(value).toISOString()
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/interview_date`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interview_date: value }),
})
if (error) props.job.interview_date = prev
}
// Calendar push
type CalPushStatus = 'idle' | 'loading' | 'synced' | 'failed'
const calPushStatus = ref<CalPushStatus>('idle')
let calPushTimer: ReturnType<typeof setTimeout> | null = null
async function pushCalendar() {
if (calPushStatus.value === 'loading') return
calPushStatus.value = 'loading'
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/calendar_push`, { method: 'POST' })
calPushStatus.value = error ? 'failed' : 'synced'
if (calPushTimer) clearTimeout(calPushTimer)
calPushTimer = setTimeout(() => { calPushStatus.value = 'idle' }, 3000)
}
const props = defineProps<{
job: PipelineJob
focused?: boolean
@ -178,6 +216,17 @@ const columnColor = computed(() => {
<div v-if="interviewDateLabel" class="date-chip">
{{ dateChipIcon }} {{ interviewDateLabel }}
</div>
<!-- Inline date picker for phone_screen and interviewing -->
<div v-if="DATE_STAGES.has(job.status)" class="date-picker-wrap">
<input
type="datetime-local"
class="date-picker"
:value="toDatetimeLocal(job.interview_date)"
:aria-label="`Interview date for ${job.title}`"
@change="onDateChange(($event.target as HTMLInputElement).value)"
@click.stop
/>
</div>
</div>
<footer class="card-footer">
<button class="card-action" @click.stop="emit('move', job.id)">Move to </button>
@ -188,6 +237,20 @@ const columnColor = computed(() => {
class="card-action"
@click.stop="emit('survey', job.id)"
>Survey </button>
<!-- Calendar push phone_screen and interviewing only -->
<button
v-if="DATE_STAGES.has(job.status)"
class="card-action card-action--cal"
:class="`card-action--cal-${calPushStatus}`"
:disabled="calPushStatus === 'loading'"
@click.stop="pushCalendar"
:aria-label="`Push ${job.title} to calendar`"
>
<span v-if="calPushStatus === 'loading'"></span>
<span v-else-if="calPushStatus === 'synced'">Synced </span>
<span v-else-if="calPushStatus === 'failed'">Failed </span>
<span v-else>📅 Calendar</span>
</button>
</footer>
<!-- Signal banners -->
<template v-if="job.stage_signals?.length">
@ -338,6 +401,31 @@ const columnColor = computed(() => {
align-self: flex-start;
}
.date-picker-wrap {
margin-top: 4px;
}
.date-picker {
width: 100%;
font-size: 0.72rem;
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 6px);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
transition: border-color var(--transition, 150ms);
}
.date-picker:hover,
.date-picker:focus {
border-color: var(--color-info);
}
.date-picker:focus-visible {
outline: 2px solid var(--color-info);
outline-offset: 2px;
}
.card-footer {
border-top: 1px solid var(--color-border-light);
@ -363,6 +451,26 @@ const columnColor = computed(() => {
background: var(--color-surface);
}
.card-action--cal {
margin-left: auto;
min-width: 72px;
text-align: center;
transition: background var(--transition, 150ms), color var(--transition, 150ms);
}
.card-action--cal-synced {
color: var(--color-success);
}
.card-action--cal-failed {
color: var(--color-error);
}
.card-action--cal:disabled {
opacity: 0.6;
cursor: default;
}
.signal-banner {
border-top: 1px solid transparent; /* color set inline */
padding: 8px 12px;

View file

@ -0,0 +1,77 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="markdown-body" :class="className" v-html="rendered" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const props = defineProps<{
content: string
className?: string
}>()
// Configure marked: gfm for GitHub-flavored markdown, breaks converts \n <br>
marked.setOptions({ gfm: true, breaks: true })
const rendered = computed(() => {
if (!props.content?.trim()) return ''
const html = marked(props.content) as string
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','br','strong','em','b','i','ul','ol','li','h1','h2','h3','h4','blockquote','code','pre','a','hr'],
ALLOWED_ATTR: ['href','target','rel'],
})
})
</script>
<style scoped>
.markdown-body { line-height: 1.6; color: var(--color-text); }
.markdown-body :deep(p) { margin: 0 0 0.75em; }
.markdown-body :deep(p:last-child) { margin-bottom: 0; }
.markdown-body :deep(ul), .markdown-body :deep(ol) { margin: 0 0 0.75em; padding-left: 1.5em; }
.markdown-body :deep(li) { margin-bottom: 0.25em; }
.markdown-body :deep(h1), .markdown-body :deep(h2), .markdown-body :deep(h3), .markdown-body :deep(h4) {
font-weight: 700; margin: 1em 0 0.4em; color: var(--color-text);
}
.markdown-body :deep(h1) { font-size: 1.2em; }
.markdown-body :deep(h2) { font-size: 1.1em; }
.markdown-body :deep(h3) { font-size: 1em; }
.markdown-body :deep(strong), .markdown-body :deep(b) { font-weight: 700; }
.markdown-body :deep(em), .markdown-body :deep(i) { font-style: italic; }
.markdown-body :deep(code) {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
padding: 0.1em 0.3em;
border-radius: var(--radius-sm);
}
.markdown-body :deep(pre) {
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
overflow-x: auto;
font-size: 0.875em;
}
.markdown-body :deep(pre code) { background: none; border: none; padding: 0; }
.markdown-body :deep(blockquote) {
border-left: 3px solid var(--color-accent);
margin: 0.75em 0;
padding: 0.25em 0 0.25em 1em;
color: var(--color-text-muted);
font-style: italic;
}
.markdown-body :deep(hr) {
border: none;
border-top: 1px solid var(--color-border);
margin: 1em 0;
}
.markdown-body :deep(a) {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
</style>

View file

@ -26,6 +26,7 @@ export const router = createRouter({
{ path: 'resume', component: () => import('../views/settings/ResumeProfileView.vue') },
{ path: 'search', component: () => import('../views/settings/SearchPrefsView.vue') },
{ path: 'system', component: () => import('../views/settings/SystemSettingsView.vue') },
{ path: 'connections', component: () => import('../views/settings/ConnectionsSettingsView.vue') },
{ path: 'fine-tune', component: () => import('../views/settings/FineTuneView.vue') },
{ path: 'license', component: () => import('../views/settings/LicenseView.vue') },
{ path: 'data', component: () => import('../views/settings/DataView.vue') },

View file

@ -22,6 +22,11 @@ export interface Contact {
received_at: string | null
}
export interface QAItem {
question: string
answer: string
}
export interface TaskStatus {
status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null
stage: string | null
@ -43,6 +48,8 @@ export const usePrepStore = defineStore('prep', () => {
const research = ref<ResearchBrief | null>(null)
const contacts = ref<Contact[]>([])
const contactsError = ref<string | null>(null)
const qaItems = ref<QAItem[]>([])
const qaError = ref<string | null>(null)
const taskStatus = ref<TaskStatus>({ status: null, stage: null, message: null })
const fullJob = ref<FullJobDetail | null>(null)
const loading = ref(false)
@ -64,6 +71,8 @@ export const usePrepStore = defineStore('prep', () => {
research.value = null
contacts.value = []
contactsError.value = null
qaItems.value = []
qaError.value = null
taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null
error.value = null
@ -72,9 +81,10 @@ export const usePrepStore = defineStore('prep', () => {
loading.value = true
try {
const [researchResult, contactsResult, taskResult, jobResult] = await Promise.all([
const [researchResult, contactsResult, qaResult, taskResult, jobResult] = await Promise.all([
useApiFetch<ResearchBrief>(`/api/jobs/${jobId}/research`),
useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`),
useApiFetch<QAItem[]>(`/api/jobs/${jobId}/qa`),
useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`),
useApiFetch<FullJobDetail>(`/api/jobs/${jobId}`),
])
@ -100,6 +110,15 @@ export const usePrepStore = defineStore('prep', () => {
contactsError.value = null
}
// Q&A failure is non-fatal — degrade the Practice Q&A tab only
if (qaResult.error && !(qaResult.error.kind === 'http' && qaResult.error.status === 404)) {
qaError.value = 'Could not load Q&A history.'
qaItems.value = []
} else {
qaItems.value = qaResult.data ?? []
qaError.value = null
}
taskStatus.value = taskResult.data ?? { status: null, stage: null, message: null }
fullJob.value = jobResult.data ?? null
@ -144,11 +163,23 @@ export const usePrepStore = defineStore('prep', () => {
}, 3000)
}
async function fetchContacts(jobId: number) {
const { data, error: fetchError } = await useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`)
if (fetchError) {
contactsError.value = 'Could not load email history.'
} else {
contacts.value = data ?? []
contactsError.value = null
}
}
function clear() {
_clearInterval()
research.value = null
contacts.value = []
contactsError.value = null
qaItems.value = []
qaError.value = null
taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null
loading.value = false
@ -160,12 +191,15 @@ export const usePrepStore = defineStore('prep', () => {
research,
contacts,
contactsError,
qaItems,
qaError,
taskStatus,
fullJob,
loading,
error,
currentJobId,
fetchFor,
fetchContacts,
generateResearch,
pollTask,
clear,

View file

@ -31,6 +31,11 @@ export const useResumeStore = defineStore('settings/resume', () => {
const veteran_status = ref(''); const disability = ref('')
// Keywords
const skills = ref<string[]>([]); const domains = ref<string[]>([]); const keywords = ref<string[]>([])
// LLM suggestions (pending, not yet accepted)
const skillSuggestions = ref<string[]>([])
const domainSuggestions = ref<string[]>([])
const keywordSuggestions = ref<string[]>([])
const suggestingField = ref<'skills' | 'domains' | 'keywords' | null>(null)
function syncFromProfile(p: { name: string; email: string; phone: string; linkedin_url: string }) {
name.value = p.name; email.value = p.email
@ -100,6 +105,30 @@ export const useResumeStore = defineStore('settings/resume', () => {
experience.value.splice(idx, 1)
}
async function suggestTags(field: 'skills' | 'domains' | 'keywords') {
suggestingField.value = field
const current = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/resume/suggest-tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: field, current }),
})
suggestingField.value = null
if (!data?.suggestions) return
const existing = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
const fresh = data.suggestions.filter(s => !existing.includes(s))
if (field === 'skills') skillSuggestions.value = fresh
else if (field === 'domains') domainSuggestions.value = fresh
else keywordSuggestions.value = fresh
}
function acceptTagSuggestion(field: 'skills' | 'domains' | 'keywords', value: string) {
addTag(field, value)
if (field === 'skills') skillSuggestions.value = skillSuggestions.value.filter(s => s !== value)
else if (field === 'domains') domainSuggestions.value = domainSuggestions.value.filter(s => s !== value)
else keywordSuggestions.value = keywordSuggestions.value.filter(s => s !== value)
}
function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
const trimmed = value.trim()
@ -119,7 +148,8 @@ export const useResumeStore = defineStore('settings/resume', () => {
experience, salary_min, salary_max, notice_period, remote, relocation, assessment, background_check,
gender, pronouns, ethnicity, veteran_status, disability,
skills, domains, keywords,
skillSuggestions, domainSuggestions, keywordSuggestions, suggestingField,
syncFromProfile, load, save, createBlank,
addExperience, removeExperience, addTag, removeTag,
addExperience, removeExperience, addTag, removeTag, suggestTags, acceptTagSuggestion,
}
})

View file

@ -1,10 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStorage } from '@vueuse/core'
import { usePrepStore } from '../stores/prep'
import { useInterviewsStore } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'
import type { PipelineJob } from '../stores/interviews'
import type { QAItem } from '../stores/prep'
import MarkdownView from '../components/MarkdownView.vue'
const route = useRoute()
const router = useRouter()
@ -26,9 +29,25 @@ const PREP_VALID_STATUSES = ['phone_screen', 'interviewing', 'offer'] as const
const job = ref<PipelineJob | null>(null)
// Tabs
type TabId = 'jd' | 'email' | 'letter'
type TabId = 'jd' | 'email' | 'letter' | 'qa'
const activeTab = ref<TabId>('jd')
// Q&A tab state
const qaMessages = ref<QAItem[]>([])
const qaInput = ref('')
const qaSubmitting = ref(false)
const qaError = ref<string | null>(null)
const qaChatEl = ref<HTMLElement | null>(null)
// Log Contact form state
const logDirection = ref<'inbound' | 'outbound'>('outbound')
const logSubject = ref('')
const logAddr = ref('')
const logBody = ref('')
const logSubmitting = ref(false)
const logError = ref<string | null>(null)
const logSuccess = ref(false)
// Call notes (localStorage via @vueuse/core)
const notesKey = computed(() => `cf-prep-notes-${jobId.value ?? 'none'}`)
const callNotes = useStorage(notesKey, '')
@ -61,6 +80,7 @@ async function guardAndLoad() {
job.value = found
await prepStore.fetchFor(jobId.value)
initQA()
}
onMounted(() => {
@ -198,6 +218,77 @@ async function onGenerate() {
if (jobId.value === null) return
await prepStore.generateResearch(jobId.value)
}
// Q&A: seed from store on mount, then handle submissions
function initQA() {
qaMessages.value = [...prepStore.qaItems]
}
async function scrollQAToBottom() {
await nextTick()
if (qaChatEl.value) {
qaChatEl.value.scrollTop = qaChatEl.value.scrollHeight
}
}
async function onAskQuestion() {
const question = qaInput.value.trim()
if (!question || qaSubmitting.value || jobId.value === null) return
qaSubmitting.value = true
qaError.value = null
const { data, error: fetchError } = await useApiFetch<{ answer: string }>(
`/api/jobs/${jobId.value}/qa/suggest`,
{ method: 'POST', body: JSON.stringify({ question, context: 'mock_interview' }) }
)
if (fetchError || !data) {
qaError.value = 'Could not get an answer. Please try again.'
} else {
qaMessages.value = [...qaMessages.value, { question, answer: data.answer }]
qaInput.value = ''
scrollQAToBottom()
}
qaSubmitting.value = false
}
// Log Contact: POST then refresh contacts
async function onLogContact() {
if (!logSubject.value.trim() || logSubmitting.value || jobId.value === null) return
logSubmitting.value = true
logError.value = null
logSuccess.value = false
const { error: fetchError } = await useApiFetch(
`/api/jobs/${jobId.value}/contacts`,
{
method: 'POST',
body: JSON.stringify({
direction: logDirection.value,
subject: logSubject.value.trim(),
from_addr: logAddr.value.trim() || null,
body: logBody.value.trim() || null,
received_at: new Date().toISOString(),
}),
}
)
if (fetchError) {
logError.value = 'Could not log contact. Please try again.'
} else {
logSuccess.value = true
logSubject.value = ''
logAddr.value = ''
logBody.value = ''
logDirection.value = 'outbound'
await prepStore.fetchContacts(jobId.value)
}
logSubmitting.value = false
}
</script>
<template>
@ -303,7 +394,7 @@ async function onGenerate() {
<span aria-hidden="true">{{ sec.icon }}</span> {{ sec.title }}
</h2>
<p v-if="sec.caption" class="section-caption">{{ sec.caption }}</p>
<div class="section-body">{{ sec.content }}</div>
<MarkdownView :content="sec.content" class="section-body" />
</section>
</div>
@ -354,6 +445,17 @@ async function onGenerate() {
>
Cover Letter
</button>
<button
id="tab-qa"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'qa' }"
role="tab"
:aria-selected="activeTab === 'qa'"
aria-controls="tabpanel-qa"
@click="activeTab = 'qa'"
>
Practice Q&amp;A
</button>
</div>
<!-- JD tab -->
@ -379,7 +481,7 @@ async function onGenerate() {
</div>
<div v-if="prepStore.fullJob?.description" class="jd-body">
{{ prepStore.fullJob.description }}
<MarkdownView :content="prepStore.fullJob.description" />
</div>
<div v-else class="tab-empty">
<span class="empty-bird">🦅</span>
@ -421,6 +523,61 @@ async function onGenerate() {
<span class="empty-bird">🦅</span>
<p>No email history for this job.</p>
</div>
<!-- Log Contact form -->
<div class="log-contact-form">
<h3 class="log-contact-title">Log Contact</h3>
<div class="log-contact-fields">
<div class="log-field">
<label class="log-label" for="log-direction">Direction</label>
<select id="log-direction" v-model="logDirection" class="log-select">
<option value="outbound">Outbound</option>
<option value="inbound">Inbound</option>
</select>
</div>
<div class="log-field">
<label class="log-label" for="log-subject">Subject</label>
<input
id="log-subject"
v-model="logSubject"
class="log-input"
type="text"
placeholder="e.g. Following up on application"
/>
</div>
<div class="log-field">
<label class="log-label" for="log-addr">
{{ logDirection === 'inbound' ? 'From' : 'To' }}
</label>
<input
id="log-addr"
v-model="logAddr"
class="log-input"
type="email"
:placeholder="logDirection === 'inbound' ? 'sender@company.com' : 'recruiter@company.com'"
/>
</div>
<div class="log-field log-field--full">
<label class="log-label" for="log-body">Notes (optional)</label>
<textarea
id="log-body"
v-model="logBody"
class="log-textarea"
placeholder="Paste email body or add notes…"
rows="3"
></textarea>
</div>
</div>
<div v-if="logError" class="log-error" role="alert">{{ logError }}</div>
<div v-if="logSuccess" class="log-success" role="status">Contact logged.</div>
<button
class="btn-primary log-submit"
:disabled="!logSubject.trim() || logSubmitting"
@click="onLogContact"
>
{{ logSubmitting ? 'Logging…' : 'Log' }}
</button>
</div>
</div>
<!-- Cover letter tab -->
@ -432,7 +589,7 @@ async function onGenerate() {
aria-labelledby="tab-letter"
>
<div v-if="prepStore.fullJob?.cover_letter" class="letter-body">
{{ prepStore.fullJob.cover_letter }}
<MarkdownView :content="prepStore.fullJob.cover_letter" />
</div>
<div v-else class="tab-empty">
<span class="empty-bird">🦅</span>
@ -440,6 +597,62 @@ async function onGenerate() {
</div>
</div>
<!-- Practice Q&A tab -->
<div
v-show="activeTab === 'qa'"
id="tabpanel-qa"
class="tab-panel tab-panel--qa"
role="tabpanel"
aria-labelledby="tab-qa"
>
<!-- Error state -->
<div v-if="prepStore.qaError" class="error-state" role="alert">
{{ prepStore.qaError }}
</div>
<!-- Chat history -->
<div ref="qaChatEl" class="qa-chat">
<div
v-for="(item, idx) in qaMessages"
:key="idx"
class="qa-exchange"
>
<div class="qa-question">
<span class="qa-label">You</span>
<p class="qa-text">{{ item.question }}</p>
</div>
<div class="qa-answer">
<span class="qa-label">Coach</span>
<MarkdownView :content="item.answer" class="qa-text" />
</div>
</div>
<div v-if="qaMessages.length === 0 && !prepStore.qaError" class="qa-empty">
<span class="empty-bird">🦅</span>
<p>Ask a practice question to get started.</p>
</div>
</div>
<!-- Input area -->
<div class="qa-input-row">
<textarea
v-model="qaInput"
class="qa-textarea"
placeholder="Ask a practice question…"
rows="2"
:disabled="qaSubmitting"
@keydown.enter.exact.prevent="onAskQuestion"
></textarea>
<button
class="btn-primary qa-ask-btn"
:disabled="!qaInput.trim() || qaSubmitting"
@click="onAskQuestion"
>
{{ qaSubmitting ? '…' : 'Ask' }}
</button>
</div>
<div v-if="qaError" class="error-state qa-error" role="alert">{{ qaError }}</div>
</div>
<!-- Call notes -->
<section class="call-notes" aria-label="Call notes">
<h2 class="call-notes-title">Call Notes</h2>
@ -762,9 +975,6 @@ async function onGenerate() {
.section-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
}
/* ── Empty state ─────────────────────────────────────────────────────────── */
@ -866,9 +1076,6 @@ async function onGenerate() {
.jd-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.7;
white-space: pre-wrap;
max-height: 60vh;
overflow-y: auto;
}
@ -921,9 +1128,6 @@ async function onGenerate() {
/* Cover letter tab */
.letter-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.8;
white-space: pre-wrap;
}
/* ── Call notes ──────────────────────────────────────────────────────────── */
@ -971,4 +1175,227 @@ async function onGenerate() {
margin: 0;
font-style: italic;
}
/* ── Practice Q&A tab ────────────────────────────────────────────────────── */
.tab-panel--qa {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3);
}
.qa-chat {
flex: 1;
overflow-y: auto;
max-height: 55vh;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-right: var(--space-1);
}
.qa-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-8) var(--space-4);
color: var(--color-text-muted);
text-align: center;
}
.qa-empty p {
font-size: var(--text-sm);
margin: 0;
}
.qa-exchange {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.qa-question,
.qa-answer {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
}
.qa-question {
background: color-mix(in srgb, var(--app-primary) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--app-primary) 20%, transparent);
align-self: flex-end;
max-width: 90%;
}
.qa-answer {
background: var(--color-surface);
border: 1px solid var(--color-border-light);
align-self: flex-start;
max-width: 90%;
}
.qa-label {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: .04em;
}
.qa-text {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
}
.qa-input-row {
display: flex;
gap: var(--space-2);
align-items: flex-end;
}
.qa-textarea {
flex: 1;
min-width: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.5;
resize: none;
box-sizing: border-box;
}
.qa-textarea::placeholder { color: var(--color-text-muted); }
.qa-textarea:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
border-color: var(--app-primary);
}
.qa-textarea:disabled { opacity: 0.6; }
.qa-ask-btn {
flex-shrink: 0;
align-self: flex-end;
padding: var(--space-2) var(--space-4);
}
.qa-error {
margin-top: 0;
}
/* ── Log Contact form ────────────────────────────────────────────────────── */
.log-contact-form {
margin-top: var(--space-5);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-light);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.log-contact-title {
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.log-contact-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2) var(--space-3);
}
@media (max-width: 640px) {
.log-contact-fields {
grid-template-columns: 1fr;
}
}
.log-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.log-field--full {
grid-column: 1 / -1;
}
.log-label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
}
.log-input,
.log-select {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--color-text);
box-sizing: border-box;
width: 100%;
}
.log-input::placeholder { color: var(--color-text-muted); }
.log-input:focus-visible,
.log-select:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
border-color: var(--app-primary);
}
.log-textarea {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.5;
resize: vertical;
box-sizing: border-box;
width: 100%;
}
.log-textarea::placeholder { color: var(--color-text-muted); }
.log-textarea:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
border-color: var(--app-primary);
}
.log-error {
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
}
.log-success {
background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface));
color: var(--color-success);
border: 1px solid color-mix(in srgb, var(--color-success) 25%, transparent);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
}
.log-submit {
align-self: flex-start;
}
</style>

View file

@ -309,6 +309,40 @@ function daysSince(dateStr: string | null) {
if (!dateStr) return null
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000)
}
// Rejected analytics section
const rejectedExpanded = ref(false)
const REJECTION_STAGES = ['applied', 'phone_screen', 'interviewing', 'offer'] as const
type RejectionStage = typeof REJECTION_STAGES[number]
const REJECTION_STAGE_LABELS: Record<RejectionStage, string> = {
applied: 'Applied',
phone_screen: 'Phone Screen',
interviewing: 'Interviewing',
offer: 'Offer',
}
const rejectedByStage = computed(() => {
const counts: Record<string, number> = {}
for (const job of store.rejected) {
const stage = job.rejection_stage ?? 'applied'
counts[stage] = (counts[stage] ?? 0) + 1
}
return counts
})
function formatRejectionDate(job: PipelineJob): string {
// Use the most recent stage timestamp as rejection date
const candidates = [job.offer_at, job.interviewing_at, job.phone_screen_at, job.applied_at]
for (const ts of candidates) {
if (ts) {
const d = new Date(ts)
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
}
}
return '—'
}
</script>
<template>
@ -519,26 +553,56 @@ function daysSince(dateStr: string | null) {
</div>
</section>
<!-- Rejected accordion -->
<details class="rejected-accordion" v-if="store.rejected.length > 0">
<summary class="rejected-summary">
Rejected ({{ store.rejected.length }})
<span class="rejected-hint"> expand for details</span>
</summary>
<div class="rejected-body">
<div class="rejected-stats">
<div class="stat-chip">
<span class="stat-num">{{ store.rejected.length }}</span>
<span class="stat-lbl">Total</span>
<!-- Rejected analytics section -->
<section v-if="store.rejected.length > 0" class="rejected-section" aria-label="Rejected jobs">
<button
class="rejected-toggle"
:aria-expanded="rejectedExpanded"
aria-controls="rejected-body"
@click="rejectedExpanded = !rejectedExpanded"
>
<span class="rejected-chevron" :class="{ 'is-expanded': rejectedExpanded }"></span>
<span class="rejected-toggle-label">Rejected ({{ store.rejected.length }})</span>
</button>
<div
id="rejected-body"
class="rejected-body"
:class="{ 'is-expanded': rejectedExpanded }"
>
<!-- Stage breakdown stats bar -->
<div class="rejected-stats-bar" role="list" aria-label="Rejections by stage">
<div
v-for="stage in REJECTION_STAGES"
:key="stage"
class="rejected-stat-chip"
:class="{ 'rejected-stat-chip--active': (rejectedByStage[stage] ?? 0) > 0 }"
role="listitem"
>
<span class="rejected-stat-num">{{ rejectedByStage[stage] ?? 0 }}</span>
<span class="rejected-stat-lbl">{{ REJECTION_STAGE_LABELS[stage] }}</span>
</div>
</div>
<div v-for="job in store.rejected" :key="job.id" class="rejected-row">
<span class="rejected-title">{{ job.title }} {{ job.company }}</span>
<span class="rejected-stage">{{ job.rejection_stage ?? 'No response' }}</span>
<button class="btn-unrej" @click="openMove(job.id)">Move </button>
<!-- Flat list of rejected jobs -->
<div class="rejected-list">
<div v-for="job in store.rejected" :key="job.id" class="rejected-row">
<div class="rejected-row-info">
<span class="rejected-job-title">{{ job.title }}</span>
<span class="rejected-job-company">{{ job.company }}</span>
</div>
<div class="rejected-row-meta">
<span
class="rejected-stage-badge"
:class="`rejected-stage-badge--${job.rejection_stage ?? 'applied'}`"
>{{ REJECTION_STAGE_LABELS[job.rejection_stage as RejectionStage] ?? job.rejection_stage ?? 'Applied' }}</span>
<span class="rejected-date">{{ formatRejectionDate(job) }}</span>
<button class="btn-unrej" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move </button>
</div>
</div>
</div>
</div>
</details>
</section>
<MoveToSheet
v-if="moveTarget"
@ -561,8 +625,8 @@ function daysSince(dateStr: string | null) {
<style scoped>
.interviews-view {
padding: var(--space-4) var(--space-4) var(--space-12);
max-width: 1100px; margin: 0 auto; position: relative;
padding: var(--space-4) var(--space-6) var(--space-12);
max-width: 1400px; margin: 0 auto; position: relative;
}
.confetti-canvas { position: fixed; inset: 0; z-index: 300; pointer-events: none; display: none; }
.hired-toast {
@ -704,14 +768,17 @@ function daysSince(dateStr: string | null) {
}
.kanban {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: var(--space-4); margin-bottom: var(--space-6);
display: grid;
grid-template-columns: repeat(3, minmax(280px, 1fr));
gap: var(--space-5);
margin-bottom: var(--space-6);
}
@media (max-width: 720px) { .kanban { grid-template-columns: 1fr; } }
@media (max-width: 768px) { .kanban { grid-template-columns: 1fr; } }
.kanban-col {
background: var(--color-surface); border-radius: 10px;
padding: var(--space-3); display: flex; flex-direction: column; gap: var(--space-3);
padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3);
transition: box-shadow 150ms;
min-height: 200px;
}
.kanban-col--focused { box-shadow: 0 0 0 2px var(--color-primary); }
.col-header {
@ -727,24 +794,230 @@ function daysSince(dateStr: string | null) {
.empty-bird-float { font-size: 1.75rem; animation: float 3s ease-in-out infinite; }
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
.empty-msg { font-size: 0.8rem; color: var(--color-text-muted); line-height: 1.5; }
.rejected-accordion { border: 1px solid var(--color-border-light); border-radius: 10px; overflow: hidden; }
.rejected-summary {
list-style: none; padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface));
cursor: pointer; font-weight: 700; font-size: 0.85rem; color: var(--color-error);
display: flex; align-items: center; gap: var(--space-2);
/* Rejected analytics section */
.rejected-section {
border: 1px solid color-mix(in srgb, var(--color-error) 25%, var(--color-border-light));
border-radius: 10px;
overflow: hidden;
margin-bottom: var(--space-4);
}
.rejected-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
border: none;
cursor: pointer;
font-weight: 700;
font-size: 0.85rem;
color: var(--color-error);
text-align: left;
}
.rejected-toggle:hover {
background: color-mix(in srgb, var(--color-error) 12%, var(--color-surface));
}
.rejected-chevron {
font-size: 0.75em;
display: inline-block;
transition: transform 200ms;
color: var(--color-error);
}
.rejected-chevron.is-expanded {
transform: rotate(90deg);
}
.rejected-toggle-label {
flex: 1;
}
.rejected-body {
max-height: 0;
overflow: hidden;
transition: max-height 300ms ease;
}
.rejected-body.is-expanded {
max-height: 2000px;
}
@media (prefers-reduced-motion: reduce) {
.rejected-body, .rejected-chevron { transition: none; }
}
/* Stats bar */
.rejected-stats-bar {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-3) var(--space-4) 0;
background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised));
}
.rejected-stat-chip {
display: flex;
flex-direction: column;
align-items: center;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: 8px;
padding: var(--space-2) var(--space-3);
min-width: 72px;
opacity: 0.5;
transition: opacity 150ms;
}
.rejected-stat-chip--active {
opacity: 1;
border-color: color-mix(in srgb, var(--color-error) 40%, var(--color-border-light));
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface-raised));
}
.rejected-stat-num {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-error);
line-height: 1.1;
}
.rejected-stat-lbl {
font-size: 0.65rem;
color: var(--color-text-muted);
text-align: center;
margin-top: 2px;
}
/* Flat rejected list */
.rejected-list {
padding: var(--space-3) var(--space-4) var(--space-4);
background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised));
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-3);
}
.rejected-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
background: var(--color-surface-raised);
border-radius: 6px;
padding: var(--space-2) var(--space-3);
border-left: 3px solid color-mix(in srgb, var(--color-error) 60%, transparent);
flex-wrap: wrap;
}
.rejected-row-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.rejected-job-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rejected-job-company {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.rejected-row-meta {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
flex-wrap: wrap;
}
.rejected-stage-badge {
font-size: 0.68rem;
font-weight: 700;
border-radius: 99px;
padding: 2px 8px;
background: color-mix(in srgb, var(--color-error) 14%, var(--color-surface-raised));
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
white-space: nowrap;
}
/* Tone down badges for earlier stages */
.rejected-stage-badge--applied {
background: color-mix(in srgb, var(--color-text-muted) 10%, var(--color-surface-raised));
color: var(--color-text-muted);
border-color: var(--color-border-light);
}
.rejected-stage-badge--phone_screen {
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface-raised));
color: var(--color-warning);
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
}
.rejected-stage-badge--interviewing {
background: color-mix(in srgb, var(--color-info) 12%, var(--color-surface-raised));
color: var(--color-info);
border-color: color-mix(in srgb, var(--color-info) 30%, transparent);
}
.rejected-stage-badge--offer {
background: color-mix(in srgb, var(--color-error) 14%, var(--color-surface-raised));
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 30%, transparent);
}
.rejected-date {
font-size: 0.72rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.btn-unrej {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 2px 8px;
font-size: 0.75rem;
font-weight: 700;
color: var(--color-info);
cursor: pointer;
}
.btn-unrej:hover {
background: var(--color-surface-alt);
}
@media (max-width: 540px) {
.rejected-row {
flex-direction: column;
align-items: flex-start;
}
.rejected-row-meta {
width: 100%;
justify-content: flex-start;
}
.rejected-stats-bar {
gap: var(--space-1);
}
.rejected-stat-chip {
min-width: 60px;
padding: var(--space-1) var(--space-2);
}
}
.rejected-summary::-webkit-details-marker { display: none; }
.rejected-hint { font-weight: 400; color: var(--color-text-muted); font-size: 0.75rem; }
.rejected-body { padding: var(--space-3) var(--space-4); background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised)); display: flex; flex-direction: column; gap: var(--space-2); }
.rejected-stats { display: flex; gap: var(--space-3); margin-bottom: var(--space-2); }
.stat-chip { background: var(--color-surface-raised); border-radius: 6px; padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border-light); text-align: center; }
.stat-num { display: block; font-size: 1.25rem; font-weight: 700; color: var(--color-error); }
.stat-lbl { font-size: 0.7rem; color: var(--color-text-muted); }
.rejected-row { display: flex; align-items: center; gap: var(--space-3); background: var(--color-surface-raised); border-radius: 6px; padding: var(--space-2) var(--space-3); border-left: 3px solid var(--color-error); }
.rejected-title { flex: 1; font-weight: 600; font-size: 0.875rem; }
.rejected-stage { font-size: 0.75rem; color: var(--color-text-muted); }
.btn-unrej { background: none; border: 1px solid var(--color-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-info); cursor: pointer; }
.empty-bird { font-size: 1.25rem; }
.pre-list-pagination {
display: flex; align-items: center; justify-content: center; gap: var(--space-2);

View file

@ -278,8 +278,9 @@ onMounted(loadList)
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
font-family: monospace; font-size: var(--font-sm, 0.875rem); resize: vertical;
background: var(--color-surface-alt, #f8fafc);
color: var(--color-text);
}
.rv__textarea:not([readonly]) { background: var(--color-surface, #fff); }
.rv__textarea:not([readonly]) { background: var(--color-surface); }
.rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
.rv__error { color: var(--color-error, #dc2626); font-size: var(--font-sm, 0.875rem); }
@ -299,6 +300,39 @@ onMounted(loadList)
.rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--font-sm, 0.875rem); }
/* Button styles — defined locally since no global button sheet exists yet */
.btn-secondary {
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 0.5rem);
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--font-sm, 0.875rem);
white-space: nowrap;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-surface-alt);
color: var(--color-text);
}
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-generate {
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
background: var(--color-accent);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md, 0.5rem);
cursor: pointer;
font-size: var(--font-sm, 0.875rem);
font-weight: 600;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: var(--space-1, 0.25rem);
}
.btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 640px) {
.rv__layout { grid-template-columns: 1fr; }
.rv__list { max-height: 200px; }

View file

@ -454,11 +454,14 @@ function toggleHistoryEntry(id: number) {
border: 2px dashed var(--color-border, #e2e8f0);
margin: var(--space-4);
border-radius: var(--radius-md, 8px);
outline: none;
}
.screenshot-zone:focus {
border-color: var(--color-accent, #3182ce);
border-color: var(--color-accent);
}
.screenshot-zone:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.drop-hint {

View file

@ -0,0 +1,264 @@
<template>
<div class="connections-settings">
<h2>Connections</h2>
<p class="tab-note">Configure email and external service integrations.</p>
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
<!-- Email section -->
<section class="form-section">
<h3>Email (IMAP)</h3>
<p class="section-note">Used for email sync in the Interviews pipeline.</p>
<div class="field-row">
<label>IMAP Host</label>
<input v-model="(store.emailConfig as any).host" placeholder="imap.gmail.com" />
</div>
<div class="field-row">
<label>Port</label>
<input v-model.number="(store.emailConfig as any).port" type="number" placeholder="993" />
</div>
<label class="checkbox-row">
<input type="checkbox" v-model="(store.emailConfig as any).ssl" /> Use SSL
</label>
<div class="field-row">
<label>Username</label>
<input v-model="(store.emailConfig as any).username" type="email" />
</div>
<div class="field-row">
<label>Password / App Password</label>
<input
v-model="emailPasswordInput"
type="password"
:placeholder="(store.emailConfig as any).password_set ? '••••••• (saved — enter new to change)' : 'Password'"
/>
<span class="field-hint">Gmail: use an App Password. Tip: type ${ENV_VAR_NAME} to use an environment variable.</span>
</div>
<div class="field-row">
<label>Sent Folder</label>
<input v-model="(store.emailConfig as any).sent_folder" placeholder="[Gmail]/Sent Mail" />
</div>
<div class="field-row">
<label>Lookback Days</label>
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
</div>
<div class="form-actions">
<button @click="handleSaveEmail()" :disabled="store.emailSaving" class="btn-primary">
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
</button>
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
<span v-if="emailTestResult !== null" :class="emailTestResult ? 'test-ok' : 'test-fail'">
{{ emailTestResult ? '✓ Connected' : '✗ Failed' }}
</span>
<p v-if="store.emailError" class="error">{{ store.emailError }}</p>
</div>
</section>
<!-- Integrations -->
<section class="form-section">
<h3>Integrations</h3>
<div v-if="store.integrations.length === 0" class="empty-note">No integrations registered.</div>
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
<div class="integration-header">
<span class="integration-name">{{ integration.name }}</span>
<div class="integration-badges">
<span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
Requires {{ integration.tier_required }}
</span>
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
{{ integration.connected ? 'Connected' : 'Disconnected' }}
</span>
</div>
</div>
<div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
</div>
<template v-else>
<div v-if="!integration.connected" class="integration-form">
<div v-for="field in integration.fields" :key="field.key" class="field-row">
<label>{{ field.label }}</label>
<input v-model="integrationInputs[integration.id + ':' + field.key]"
:type="field.type === 'password' ? 'password' : 'text'" />
</div>
<div class="form-actions">
<button @click="handleConnect(integration.id)" class="btn-primary">Connect</button>
<button @click="handleTest(integration.id)" class="btn-secondary">Test</button>
<span v-if="store.integrationResults[integration.id]" :class="store.integrationResults[integration.id].ok ? 'test-ok' : 'test-fail'">
{{ store.integrationResults[integration.id].ok ? '✓ OK' : '✗ ' + store.integrationResults[integration.id].error }}
</span>
</div>
</div>
<div v-else>
<button @click="store.disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
</div>
</template>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useSystemStore } from '../../stores/settings/system'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useSystemStore()
const config = useAppConfigStore()
const { tier } = storeToRefs(config)
const emailPasswordInput = ref('')
const emailTestResult = ref<boolean | null>(null)
const integrationInputs = ref<Record<string, string>>({})
const tierOrder = ['free', 'paid', 'premium', 'ultra']
function meetsRequiredTier(required: string): boolean {
return tierOrder.indexOf(tier.value) >= tierOrder.indexOf(required || 'free')
}
async function handleTestEmail() {
const result = await store.testEmail()
emailTestResult.value = result?.ok ?? false
}
async function handleSaveEmail() {
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
await store.saveEmailWithPassword(payload)
}
async function handleConnect(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
await store.connectIntegration(id, credentials)
}
async function handleTest(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
await store.testIntegration(id, credentials)
}
onMounted(async () => {
await Promise.all([store.loadEmail(), store.loadIntegrations()])
})
</script>
<style scoped>
.connections-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.tab-note { font-size: 0.82rem; color: var(--color-text-muted); margin-bottom: var(--space-6); }
.error-banner {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
border-radius: 6px;
color: var(--color-error);
padding: 10px 14px;
margin-bottom: 20px;
font-size: 0.85rem;
}
.form-section {
margin-bottom: var(--space-8);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--color-border);
}
.section-note { font-size: 0.78rem; color: var(--color-text-muted); margin-bottom: 14px; }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
.field-row input {
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
padding: 7px 10px;
font-size: 0.88rem;
}
.field-hint { font-size: 0.72rem; color: var(--color-text-muted); margin-top: 3px; }
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 0.85rem;
color: var(--color-text);
cursor: pointer;
margin: 0 0 14px;
}
.form-actions { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
.btn-primary {
padding: 9px 24px;
background: var(--color-accent);
color: var(--color-text-inverse);
border: none;
border-radius: 7px;
font-size: 0.9rem;
cursor: pointer;
font-weight: 600;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
padding: 9px 18px;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 7px;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.88rem;
}
.btn-danger {
padding: 6px 14px;
border-radius: 6px;
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
cursor: pointer;
font-size: 0.82rem;
}
.error { color: var(--color-error); font-size: 0.82rem; }
.test-ok { color: var(--color-success); font-size: 0.85rem; }
.test-fail { color: var(--color-error); font-size: 0.85rem; }
.integration-card {
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.integration-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.integration-name { font-weight: 600; font-size: 0.9rem; color: var(--color-text); }
.integration-badges { display: flex; align-items: center; gap: 4px; }
.status-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; }
.badge-connected {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent);
}
.badge-disconnected {
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
border: 1px solid color-mix(in srgb, var(--color-text-muted) 20%, transparent);
}
.tier-badge {
font-size: 0.68rem;
padding: 2px 7px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
margin-right: 6px;
}
.tier-locked { padding: 12px 0; font-size: 0.85rem; color: var(--color-text-muted); }
.empty-note { font-size: 0.85rem; color: var(--color-text-muted); padding: 16px 0; }
.integration-form .field-row { margin-bottom: 10px; }
</style>

View file

@ -196,30 +196,54 @@
<section class="form-section">
<h3>Skills & Keywords</h3>
<div class="tag-section">
<label>Skills</label>
<div class="tag-section-header">
<label>Skills</label>
<button @click="store.suggestTags('skills')" :disabled="store.suggestingField === 'skills'" class="btn-suggest">
{{ store.suggestingField === 'skills' ? 'Thinking…' : '✦ Suggest' }}
</button>
</div>
<div class="tags">
<span v-for="skill in store.skills" :key="skill" class="tag">
{{ skill }} <button @click="store.removeTag('skills', skill)">×</button>
</span>
</div>
<div v-if="store.skillSuggestions.length > 0" class="suggestions">
<span v-for="s in store.skillSuggestions" :key="s" class="suggestion-chip" @click="store.acceptTagSuggestion('skills', s)" title="Click to add">+ {{ s }}</span>
</div>
<input v-model="skillInput" @keydown.enter.prevent="store.addTag('skills', skillInput); skillInput = ''" placeholder="Add skill, press Enter" />
</div>
<div class="tag-section">
<label>Domains</label>
<div class="tag-section-header">
<label>Domains</label>
<button @click="store.suggestTags('domains')" :disabled="store.suggestingField === 'domains'" class="btn-suggest">
{{ store.suggestingField === 'domains' ? 'Thinking…' : '✦ Suggest' }}
</button>
</div>
<div class="tags">
<span v-for="domain in store.domains" :key="domain" class="tag">
{{ domain }} <button @click="store.removeTag('domains', domain)">×</button>
</span>
</div>
<div v-if="store.domainSuggestions.length > 0" class="suggestions">
<span v-for="s in store.domainSuggestions" :key="s" class="suggestion-chip" @click="store.acceptTagSuggestion('domains', s)" title="Click to add">+ {{ s }}</span>
</div>
<input v-model="domainInput" @keydown.enter.prevent="store.addTag('domains', domainInput); domainInput = ''" placeholder="Add domain, press Enter" />
</div>
<div class="tag-section">
<label>Keywords</label>
<div class="tag-section-header">
<label>Keywords</label>
<button @click="store.suggestTags('keywords')" :disabled="store.suggestingField === 'keywords'" class="btn-suggest">
{{ store.suggestingField === 'keywords' ? 'Thinking…' : '✦ Suggest' }}
</button>
</div>
<div class="tags">
<span v-for="kw in store.keywords" :key="kw" class="tag">
{{ kw }} <button @click="store.removeTag('keywords', kw)">×</button>
</span>
</div>
<div v-if="store.keywordSuggestions.length > 0" class="suggestions">
<span v-for="s in store.keywordSuggestions" :key="s" class="suggestion-chip" @click="store.acceptTagSuggestion('keywords', s)" title="Click to add">+ {{ s }}</span>
</div>
<input v-model="kwInput" @keydown.enter.prevent="store.addTag('keywords', kwInput); kwInput = ''" placeholder="Add keyword, press Enter" />
</div>
</section>
@ -304,45 +328,83 @@ async function handleUpload() {
</script>
<style scoped>
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--space-3, 16px); }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--space-3); }
.field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
.field-row input, .field-row textarea, .field-row select {
background: var(--color-surface-2, rgba(255,255,255,0.05));
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text-primary, #e2e8f0);
color: var(--color-text);
padding: 7px 10px;
font-size: 0.88rem;
width: 100%;
box-sizing: border-box;
}
.sync-label { font-size: 0.72rem; color: var(--color-accent, #7c3aed); margin-left: 6px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
.experience-card { border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: var(--space-4, 24px); margin-bottom: var(--space-4, 24px); }
.remove-btn { margin-top: 8px; padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); cursor: pointer; font-size: 0.82rem; }
.empty-state { text-align: center; padding: var(--space-8, 48px) 0; }
.empty-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4, 24px); margin-top: var(--space-6, 32px); }
.empty-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 10px; padding: var(--space-4, 24px); text-align: left; }
.sync-label { font-size: 0.72rem; color: var(--color-accent); margin-left: 6px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 0.88rem; color: var(--color-text); cursor: pointer; }
.experience-card { border: 1px solid var(--color-border); border-radius: 8px; padding: var(--space-4); margin-bottom: var(--space-4); }
.remove-btn {
margin-top: 8px; padding: 4px 12px; border-radius: 4px;
background: color-mix(in srgb, var(--color-error) 15%, transparent);
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
cursor: pointer; font-size: 0.82rem;
}
.empty-state { text-align: center; padding: var(--space-8) 0; }
.empty-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4); margin-top: var(--space-6); }
.empty-card { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 10px; padding: var(--space-4); text-align: left; }
.empty-card h3 { margin-bottom: 8px; }
.empty-card p { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
.empty-card button, .empty-card a { padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; text-decoration: none; display: inline-block; background: var(--color-accent, #7c3aed); color: #fff; border: none; }
.tag-section { margin-bottom: var(--space-4, 24px); }
.tag-section label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
.empty-card p { font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 16px; }
.empty-card button, .empty-card a { padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; text-decoration: none; display: inline-block; background: var(--color-accent); color: var(--color-text-inverse); border: none; }
.tag-section { margin-bottom: var(--space-4); }
.tag-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.tag-section-header label { font-size: 0.82rem; color: var(--color-text-muted); margin: 0; }
.tag-section label { font-size: 0.82rem; color: var(--color-text-muted); display: block; margin-bottom: 6px; }
.btn-suggest {
padding: 4px 12px; border-radius: 6px;
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
color: var(--color-accent); cursor: pointer; font-size: 0.78rem; white-space: nowrap; transition: background 0.15s;
}
.btn-suggest:hover:not(:disabled) { background: color-mix(in srgb, var(--color-accent) 28%, transparent); }
.btn-suggest:disabled { opacity: 0.55; cursor: default; }
.suggestions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.suggestion-chip {
padding: 3px 10px; border-radius: 12px; font-size: 0.78rem;
background: var(--color-surface-alt);
border: 1px dashed var(--color-border);
color: var(--color-text-muted); cursor: pointer; transition: all 0.15s;
}
.suggestion-chip:hover {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-color: color-mix(in srgb, var(--color-accent) 30%, transparent);
color: var(--color-accent);
}
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
.tag {
padding: 3px 10px;
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
border-radius: 12px; font-size: 0.78rem; color: var(--color-accent);
display: flex; align-items: center; gap: 5px;
}
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
.tag-section input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 6px 10px; font-size: 0.85rem; width: 100%; box-sizing: border-box; }
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.tag-section input { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 6px; color: var(--color-text); padding: 6px 10px; font-size: 0.85rem; width: 100%; box-sizing: border-box; }
.form-actions { margin-top: var(--space-6); display: flex; align-items: center; gap: var(--space-4); }
.btn-primary { padding: 9px 24px; background: var(--color-accent); color: var(--color-text-inverse); border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error { color: #ef4444; font-size: 0.82rem; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; font-size: 0.85rem; padding: 10px 14px; margin-bottom: var(--space-4, 24px); }
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
.replace-section { background: var(--color-surface-2, rgba(255,255,255,0.03)); border-radius: 8px; padding: var(--space-4, 24px); }
.error { color: var(--color-error); font-size: 0.82rem; }
.error-banner {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
border-radius: 6px; color: var(--color-error); font-size: 0.85rem; padding: 10px 14px; margin-bottom: var(--space-4);
}
.section-note { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 16px; }
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border); border-radius: 4px; color: var(--color-text-muted); cursor: pointer; font-size: 0.78rem; }
.loading { text-align: center; padding: var(--space-8); color: var(--color-text-muted); }
.replace-section { background: var(--color-surface-alt); border-radius: 8px; padding: var(--space-4); }
</style>

View file

@ -86,10 +86,16 @@
<!-- Job Boards -->
<section class="form-section">
<h3>Job Boards</h3>
<div v-for="board in store.job_boards" :key="board.name" class="board-row">
<div v-for="board in store.job_boards" :key="board.name" class="board-row" :class="{ 'board-row--unsupported': board.supported === false }">
<label class="checkbox-row">
<input type="checkbox" :checked="board.enabled" @change="store.toggleBoard(board.name)" />
<input
type="checkbox"
:checked="board.enabled"
:disabled="board.supported === false"
@change="store.toggleBoard(board.name)"
/>
{{ board.name }}
<span v-if="board.supported === false" class="board-badge board-badge--pending" title="Not yet implemented — tracked in backlog">coming soon</span>
</label>
</div>
<div class="field-row" style="margin-top: 12px">
@ -179,37 +185,78 @@ onMounted(() => store.load())
</script>
<style scoped>
.search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); }
.remote-options { display: flex; gap: 8px; margin-bottom: 10px; }
.remote-btn { padding: 8px 18px; border-radius: 6px; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); background: transparent; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; transition: all 0.15s; }
.remote-btn.active { background: var(--color-accent, #7c3aed); border-color: var(--color-accent, #7c3aed); color: #fff; }
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-top: 8px; }
.remote-btn { padding: 8px 18px; border-radius: 6px; border: 1px solid var(--color-border); background: transparent; color: var(--color-text-muted); cursor: pointer; font-size: 0.88rem; transition: all 0.15s; }
.remote-btn.active { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-text-inverse); }
.section-note { font-size: 0.78rem; color: var(--color-text-muted); margin-top: 8px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
.tag {
padding: 3px 10px;
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
border-radius: 12px; font-size: 0.78rem; color: var(--color-accent);
display: flex; align-items: center; gap: 5px;
}
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
.tag-input-row { display: flex; gap: 8px; }
.tag-input-row input, input[type="text"], input:not([type]) {
background: var(--color-surface-2, rgba(255,255,255,0.05));
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
border-radius: 6px; color: var(--color-text-primary, #e2e8f0);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: 6px; color: var(--color-text);
padding: 7px 10px; font-size: 0.85rem; flex: 1; box-sizing: border-box;
}
.btn-suggest { padding: 7px 14px; border-radius: 6px; background: rgba(124,58,237,0.2); border: 1px solid rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); cursor: pointer; font-size: 0.82rem; white-space: nowrap; }
.btn-suggest {
padding: 7px 14px; border-radius: 6px;
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
color: var(--color-accent); cursor: pointer; font-size: 0.82rem; white-space: nowrap;
}
.suggestions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.suggestion-chip { padding: 4px 12px; border-radius: 12px; font-size: 0.78rem; background: rgba(255,255,255,0.05); border: 1px dashed rgba(255,255,255,0.2); color: var(--color-text-secondary, #94a3b8); cursor: pointer; transition: all 0.15s; }
.suggestion-chip:hover { background: rgba(124,58,237,0.15); border-color: rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); }
.suggestion-chip {
padding: 4px 12px; border-radius: 12px; font-size: 0.78rem;
background: var(--color-surface-alt);
border: 1px dashed var(--color-border);
color: var(--color-text-muted); cursor: pointer; transition: all 0.15s;
}
.suggestion-chip:hover {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-color: color-mix(in srgb, var(--color-accent) 30%, transparent);
color: var(--color-accent);
}
.board-row { margin-bottom: 8px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
.board-row--unsupported { opacity: 0.5; }
.board-row--unsupported input[type="checkbox"] { cursor: not-allowed; }
.board-badge {
display: inline-block;
margin-left: var(--space-2);
padding: 1px 6px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
vertical-align: middle;
}
.board-badge--pending {
background: var(--color-surface-alt);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--color-text); cursor: pointer; }
.field-row { display: flex; flex-direction: column; gap: 6px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.blocklist-group { margin-bottom: var(--space-4, 24px); }
.blocklist-group label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
.blocklist-group { margin-bottom: var(--space-4); }
.blocklist-group label { font-size: 0.82rem; color: var(--color-text-muted); display: block; margin-bottom: 6px; }
.form-actions { margin-top: var(--space-6); display: flex; align-items: center; gap: var(--space-4); }
.btn-primary { padding: 9px 24px; background: var(--color-accent); color: var(--color-text-inverse); border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
.error { color: #ef4444; font-size: 0.82rem; }
.error-banner {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
border-radius: 6px; color: var(--color-error); padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem;
}
.error { color: var(--color-error); font-size: 0.82rem; }
</style>

View file

@ -61,6 +61,7 @@ const allGroups = [
{ key: 'search', path: '/settings/search', label: 'Search Prefs', show: true },
]},
{ label: 'App', items: [
{ key: 'connections', path: '/settings/connections', label: 'Connections', show: true },
{ key: 'system', path: '/settings/system', label: 'System', show: showSystem },
{ key: 'fine-tune', path: '/settings/fine-tune', label: 'Fine-Tune', show: showFineTune },
]},

View file

@ -44,6 +44,30 @@
</div>
</section>
<!-- Custom cover letter model (paid+, cloud) -->
<section v-if="config.isCloud && meetsRequiredTier('paid')" class="form-section">
<h3>Custom Cover Letter Model</h3>
<p class="section-note">
Select your fine-tuned Ollama model for cover letter generation.
Leave blank to use the cloud default.
</p>
<div class="field-row">
<label>Model</label>
<select v-model="coverLetterModel" class="field-select">
<option value="">(cloud default)</option>
<option v-for="m in ollamaModels" :key="m" :value="m">{{ m }}</option>
</select>
<button @click="saveCoverLetterModel" :disabled="clmSaving" class="btn-save-inline">
{{ clmSaving ? 'Saving…' : 'Save' }}
</button>
</div>
<p v-if="clmError" class="error">{{ clmError }}</p>
<p v-if="clmSaved" class="success">Saved.</p>
<p v-if="ollamaModels.length === 0" class="section-note">
No Ollama models found make sure Ollama is running and has models pulled.
</p>
</section>
<!-- Services section -->
<section class="form-section">
<h3>Services</h3>
@ -65,97 +89,6 @@
</div>
</section>
<!-- Email section -->
<section class="form-section">
<h3>Email (IMAP)</h3>
<p class="section-note">Used for email sync in the Interviews pipeline.</p>
<div class="field-row">
<label>IMAP Host</label>
<input v-model="(store.emailConfig as any).host" placeholder="imap.gmail.com" />
</div>
<div class="field-row">
<label>Port</label>
<input v-model.number="(store.emailConfig as any).port" type="number" placeholder="993" />
</div>
<label class="checkbox-row">
<input type="checkbox" v-model="(store.emailConfig as any).ssl" /> Use SSL
</label>
<div class="field-row">
<label>Username</label>
<input v-model="(store.emailConfig as any).username" type="email" />
</div>
<div class="field-row">
<label>Password / App Password</label>
<input
v-model="emailPasswordInput"
type="password"
:placeholder="(store.emailConfig as any).password_set ? '••••••• (saved — enter new to change)' : 'Password'"
/>
<span class="field-hint">Gmail: use an App Password. Tip: type ${ENV_VAR_NAME} to use an environment variable.</span>
</div>
<div class="field-row">
<label>Sent Folder</label>
<input v-model="(store.emailConfig as any).sent_folder" placeholder="[Gmail]/Sent Mail" />
</div>
<div class="field-row">
<label>Lookback Days</label>
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
</div>
<div class="form-actions">
<button @click="handleSaveEmail()" :disabled="store.emailSaving" class="btn-primary">
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
</button>
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
<span v-if="emailTestResult !== null" :class="emailTestResult ? 'test-ok' : 'test-fail'">
{{ emailTestResult ? '✓ Connected' : '✗ Failed' }}
</span>
<p v-if="store.emailError" class="error">{{ store.emailError }}</p>
</div>
</section>
<!-- Integrations -->
<section class="form-section">
<h3>Integrations</h3>
<div v-if="store.integrations.length === 0" class="empty-note">No integrations registered.</div>
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
<div class="integration-header">
<span class="integration-name">{{ integration.name }}</span>
<div class="integration-badges">
<span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
Requires {{ integration.tier_required }}
</span>
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
{{ integration.connected ? 'Connected' : 'Disconnected' }}
</span>
</div>
</div>
<!-- Locked state for insufficient tier -->
<div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
</div>
<!-- Normal state for sufficient tier -->
<template v-else>
<div v-if="!integration.connected" class="integration-form">
<div v-for="field in integration.fields" :key="field.key" class="field-row">
<label>{{ field.label }}</label>
<input v-model="integrationInputs[integration.id + ':' + field.key]"
:type="field.type === 'password' ? 'password' : 'text'" />
</div>
<div class="form-actions">
<button @click="handleConnect(integration.id)" class="btn-primary">Connect</button>
<button @click="handleTest(integration.id)" class="btn-secondary">Test</button>
<span v-if="store.integrationResults[integration.id]" :class="store.integrationResults[integration.id].ok ? 'test-ok' : 'test-fail'">
{{ store.integrationResults[integration.id].ok ? '✓ OK' : '✗ ' + store.integrationResults[integration.id].error }}
</span>
</div>
</div>
<div v-else>
<button @click="store.disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
</div>
</template>
</div>
</section>
<!-- File Paths -->
<section class="form-section">
<h3>File Paths</h3>
@ -239,6 +172,7 @@ import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useSystemStore } from '../../stores/settings/system'
import { useAppConfigStore } from '../../stores/appConfig'
import { useApiFetch } from '../../composables/useApi'
const store = useSystemStore()
const config = useAppConfigStore()
@ -287,108 +221,158 @@ async function handleConfirmByok() {
byokConfirmed.value = false
}
const emailTestResult = ref<boolean | null>(null)
const emailPasswordInput = ref('')
const integrationInputs = ref<Record<string, string>>({})
async function handleTestEmail() {
const result = await store.testEmail()
emailTestResult.value = result?.ok ?? false
// Custom cover letter model
const coverLetterModel = ref('')
const ollamaModels = ref<string[]>([])
const clmSaving = ref(false)
const clmError = ref<string | null>(null)
const clmSaved = ref(false)
async function loadCoverLetterModel() {
const { data } = await useApiFetch<{ model: string }>('/api/settings/llm/cover-letter-model')
if (data) coverLetterModel.value = data.model ?? ''
const { data: mData } = await useApiFetch<{ models: string[] }>('/api/settings/llm/ollama-models')
if (mData) ollamaModels.value = mData.models ?? []
}
async function handleSaveEmail() {
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
await store.saveEmailWithPassword(payload)
}
async function handleConnect(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
await store.connectIntegration(id, credentials)
}
async function handleTest(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
await store.testIntegration(id, credentials)
async function saveCoverLetterModel() {
clmSaving.value = true
clmError.value = null
clmSaved.value = false
const { error } = await useApiFetch('/api/settings/llm/cover-letter-model', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: coverLetterModel.value }),
})
clmSaving.value = false
if (error) { clmError.value = 'Failed to save model.'; return }
clmSaved.value = true
setTimeout(() => { clmSaved.value = false }, 3000)
}
onMounted(async () => {
await store.loadLlm()
await Promise.all([
const tasks = [
store.loadServices(),
store.loadEmail(),
store.loadIntegrations(),
store.loadFilePaths(),
store.loadDeployConfig(),
])
]
if (config.isCloud && tierOrder.indexOf(tier.value) >= tierOrder.indexOf('paid')) {
tasks.push(loadCoverLetterModel())
}
await Promise.all(tasks)
})
</script>
<style scoped>
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.tab-note { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: var(--space-6, 32px); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 14px; }
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.tab-note { font-size: 0.82rem; color: var(--color-text-muted); margin-bottom: var(--space-6); }
.form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); }
.section-note { font-size: 0.78rem; color: var(--color-text-muted); margin-bottom: 14px; }
.backend-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
.backend-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; cursor: grab; user-select: none; }
.backend-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 8px; cursor: grab; user-select: none; }
.backend-card:active { cursor: grabbing; }
.drag-handle { font-size: 1.1rem; color: var(--color-text-secondary, #64748b); }
.priority-badge { width: 22px; height: 22px; border-radius: 50%; background: rgba(124,58,237,0.2); color: var(--color-accent, #a78bfa); font-size: 0.72rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.backend-id { flex: 1; font-size: 0.9rem; font-family: monospace; color: var(--color-text-primary, #e2e8f0); }
.toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.form-actions { display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.drag-handle { font-size: 1.1rem; color: var(--color-text-muted); }
.priority-badge { width: 22px; height: 22px; border-radius: 50%; background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent); font-size: 0.72rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.backend-id { flex: 1; font-size: 0.9rem; font-family: monospace; color: var(--color-text); }
.toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--color-text-muted); }
.form-actions { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
.btn-primary { padding: 9px 24px; background: var(--color-accent); color: var(--color-text-inverse); border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-cancel { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.9rem; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
.error { color: #ef4444; font-size: 0.82rem; }
.btn-cancel { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-text-muted); cursor: pointer; font-size: 0.9rem; }
.error-banner {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
border-radius: 6px; color: var(--color-error); padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem;
}
.error { color: var(--color-error); font-size: 0.82rem; }
/* BYOK Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
.modal-card { background: var(--color-surface-1, #1e293b); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
.modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; color: var(--color-text-primary, #e2e8f0); }
.modal-card p { font-size: 0.88rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 12px; }
.modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
.byok-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; padding: 10px 12px; color: #fbbf24 !important; }
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; margin: 16px 0; }
.modal-card { background: var(--color-surface-raised); border: 1px solid var(--color-border); border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
.modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; }
.modal-card p { font-size: 0.88rem; color: var(--color-text-muted); margin-bottom: 12px; }
.modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text); }
.byok-warning {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
border-radius: 6px; padding: 10px 12px; color: var(--color-warning) !important;
}
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text); cursor: pointer; margin: 16px 0; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; }
.service-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 14px; }
.service-card { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 8px; padding: 14px; }
.service-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.service-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-running { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
.dot-stopped { background: #64748b; }
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
.service-port { font-size: 0.75rem; color: var(--color-text-secondary, #64748b); font-family: monospace; }
.service-note { font-size: 0.75rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 10px; }
.dot-running { background: var(--color-success); box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 50%, transparent); }
.dot-stopped { background: var(--color-text-muted); }
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text); }
.service-port { font-size: 0.75rem; color: var(--color-text-muted); font-family: monospace; }
.service-note { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 10px; }
.service-actions { display: flex; gap: 6px; }
.btn-start { padding: 4px 12px; border-radius: 4px; background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); cursor: pointer; font-size: 0.78rem; }
.btn-stop { padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2); cursor: pointer; font-size: 0.78rem; }
.btn-start {
padding: 4px 12px; border-radius: 4px;
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent);
cursor: pointer; font-size: 0.78rem;
}
.btn-stop {
padding: 4px 12px; border-radius: 4px;
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 20%, transparent);
cursor: pointer; font-size: 0.78rem;
}
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.field-row input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 7px 10px; font-size: 0.88rem; }
.field-hint { font-size: 0.72rem; color: var(--color-text-secondary, #64748b); margin-top: 3px; }
.btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; }
.btn-danger { padding: 6px 14px; border-radius: 6px; background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.25); cursor: pointer; font-size: 0.82rem; }
.test-ok { color: #22c55e; font-size: 0.85rem; }
.test-fail { color: #ef4444; font-size: 0.85rem; }
.integration-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 16px; margin-bottom: 12px; }
.integration-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.integration-name { font-weight: 600; font-size: 0.9rem; color: var(--color-text-primary, #e2e8f0); }
.status-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; }
.badge-connected { background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
.badge-disconnected { background: rgba(100,116,139,0.15); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); }
.empty-note { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); padding: 16px 0; }
.tier-badge { font-size: 0.68rem; padding: 2px 7px; border-radius: 8px; background: rgba(245,158,11,0.15); color: #f59e0b; border: 1px solid rgba(245,158,11,0.3); margin-right: 6px; }
.tier-locked { padding: 12px 0; font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); }
.integration-badges { display: flex; align-items: center; gap: 4px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
.field-row input { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 6px; color: var(--color-text); padding: 7px 10px; font-size: 0.88rem; }
.field-hint { font-size: 0.72rem; color: var(--color-text-muted); margin-top: 3px; }
.btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-text-muted); cursor: pointer; font-size: 0.88rem; }
.btn-danger {
padding: 6px 14px; border-radius: 6px;
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
cursor: pointer; font-size: 0.82rem;
}
.test-ok { color: var(--color-success); font-size: 0.85rem; }
.test-fail { color: var(--color-error); font-size: 0.85rem; }
.field-select {
flex: 1;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-2);
font-size: var(--text-sm);
color: var(--color-text);
min-width: 0;
}
.field-select:focus-visible {
outline: 2px solid var(--app-primary);
border-color: var(--app-primary);
}
.btn-save-inline {
background: var(--app-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.btn-save-inline:disabled { opacity: 0.6; cursor: default; }
.btn-save-inline:hover:not(:disabled) { background: var(--app-primary-hover); }
.success {
color: var(--color-success);
font-size: var(--text-sm);
margin: var(--space-1) 0 0;
}
</style>

View file

@ -5,15 +5,15 @@
Peregrine uses your hardware profile to choose the right inference setup.
</p>
<div v-if="wizard.loading" class="step__info">Detecting hardware</div>
<div v-if="detecting" 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):
Detected {{ wizard.hardware.gpus.length }} local GPU(s):
{{ wizard.hardware.gpus.join(', ') }}
</div>
<div v-else class="step__info">
No NVIDIA GPUs detected. "Remote" or "CPU" mode recommended.
No local NVIDIA GPUs detected. "Remote", "CPU", or "cf-orch" mode recommended.
</div>
<div class="step__field">
@ -23,15 +23,59 @@
<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>
<option value="cf-orch">
cf-orch CircuitForge GPU cluster
{{ orchAvailable ? `(${orchGpus.length} GPU(s) available)` : '(configure endpoint below)' }}
</option>
</select>
</div>
<!-- cf-orch cluster summary -->
<template v-if="selectedProfile === 'cf-orch'">
<div v-if="orchAvailable" class="step__orch-nodes">
<p class="step__orch-label">Available nodes:</p>
<div
v-for="gpu in orchGpus"
:key="`${gpu.node}-${gpu.name}`"
class="step__orch-row"
>
<span class="step__orch-node">{{ gpu.node }}</span>
<span class="step__orch-name">{{ gpu.name }}</span>
<span class="step__orch-vram">
{{ Math.round(gpu.vram_free_mb / 1024 * 10) / 10 }} /
{{ Math.round(gpu.vram_total_mb / 1024 * 10) / 10 }} GB free
</span>
</div>
</div>
<div class="step__field">
<label class="step__label" for="orch-url">cf-orch coordinator URL</label>
<input
id="orch-url"
v-model="orchUrl"
type="url"
class="step__input"
placeholder="http://10.1.10.71:7700"
/>
<p class="step__field-hint">
The coordinator serves public inference endpoints for paid+ users.
Leave blank to use the default cluster URL from Settings.
</p>
</div>
<div class="step__tier-note">
<span aria-hidden="true">🔒</span>
cf-orch inference requires a <strong>Paid</strong> license or higher.
You can select this profile now; it will activate once your license is verified.
</div>
</template>
<div
v-if="selectedProfile !== 'remote' && !wizard.hardware.gpus.length"
v-else-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.
No local GPUs detected a GPU profile may not work. Choose CPU, Remote,
or cf-orch if you have access to the cluster.
</div>
</template>
@ -47,17 +91,52 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import { useApiFetch } from '../../composables/useApi'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const selectedProfile = ref(wizard.hardware.selectedProfile)
onMounted(() => wizard.detectHardware())
// Local loading flag does NOT touch wizard.loading, avoiding the
// WizardLayout unmount loop that was causing the infinite spinner.
const detecting = ref(false)
// cf-orch cluster state
const orchAvailable = ref(false)
const orchGpus = ref<Array<{ node: string; name: string; vram_total_mb: number; vram_free_mb: number }>>([])
const orchUrl = ref('')
onMounted(async () => {
detecting.value = true
const { data } = await useApiFetch<{
gpus: string[]
suggested_profile: string
profiles: string[]
cf_orch_available: boolean
cf_orch_gpus: Array<{ node: string; name: string; vram_total_mb: number; vram_free_mb: number }>
}>('/api/wizard/hardware')
detecting.value = false
if (!data) return
wizard.hardware.gpus = data.gpus
wizard.hardware.suggestedProfile = data.suggested_profile as typeof wizard.hardware.suggestedProfile
if (!wizard.hardware.selectedProfile || wizard.hardware.selectedProfile === 'remote') {
wizard.hardware.selectedProfile = data.suggested_profile as typeof wizard.hardware.selectedProfile
selectedProfile.value = wizard.hardware.selectedProfile
}
orchAvailable.value = data.cf_orch_available ?? false
orchGpus.value = data.cf_orch_gpus ?? []
})
async function next() {
wizard.hardware.selectedProfile = selectedProfile.value
const ok = await wizard.saveStep(1, { inference_profile: selectedProfile.value })
wizard.hardware.selectedProfile = selectedProfile.value as typeof wizard.hardware.selectedProfile
const stepData: Record<string, unknown> = { inference_profile: selectedProfile.value }
if (selectedProfile.value === 'cf-orch' && orchUrl.value) {
stepData.cf_orch_url = orchUrl.value
}
const ok = await wizard.saveStep(1, stepData)
if (ok) router.push('/setup/tier')
}
</script>

View file

@ -28,7 +28,7 @@
<!-- Step content -->
<div class="wizard__body">
<div v-if="wizard.loading" class="wizard__loading" aria-live="polite">
<div v-if="!layoutReady" class="wizard__loading" aria-live="polite">
<span class="wizard__spinner" aria-hidden="true" />
Loading
</div>
@ -44,7 +44,7 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import { useAppConfigStore } from '../../stores/appConfig'
@ -56,9 +56,15 @@ const router = useRouter()
// Peregrine logo served from the static assets directory
const logoSrc = '/static/peregrine_logo_circle.png'
// layoutReady gates the RouterView separate from wizard.loading so child
// steps that do their own async work (detectHardware, etc.) don't unmount
// themselves by setting the shared loading flag.
const layoutReady = ref(false)
onMounted(async () => {
if (!config.loaded) await config.load()
const target = await wizard.loadStatus(config.isCloud)
layoutReady.value = true
if (router.currentRoute.value.path === '/setup') {
router.replace(target)
}

View file

@ -327,3 +327,66 @@
opacity: 0.5;
cursor: not-allowed;
}
/* ── cf-orch hardware step ─────────────────────────────────────── */
.step__orch-nodes {
display: flex;
flex-direction: column;
gap: var(--space-1, 0.25rem);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.step__orch-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin-bottom: var(--space-1);
}
.step__orch-row {
display: grid;
grid-template-columns: 6rem 1fr auto;
gap: var(--space-2);
font-size: 0.875rem;
align-items: center;
}
.step__orch-node {
font-weight: 600;
color: var(--color-text);
}
.step__orch-name {
color: var(--color-text-muted);
}
.step__orch-vram {
font-size: 0.75rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.step__field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: var(--space-1);
}
.step__tier-note {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-primary, #6366f1) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary, #6366f1) 25%, transparent);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--color-text);
}