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/ COPY circuitforge-core/ /circuitforge-core/
RUN pip install --no-cache-dir /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 . COPY peregrine/requirements.txt .
# Skip the cfcore line — already installed above from the local copy # 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 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/ . 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 EXPOSE 8501
CMD ["streamlit", "run", "app/app.py", \ 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 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, \ from scripts.db import init_db, get_job_counts, purge_jobs, purge_email_data, \
purge_non_remote, archive_jobs, kill_stuck_tasks, cancel_task, \ purge_non_remote, archive_jobs, kill_stuck_tasks, cancel_task, \
get_task_for_job, get_active_tasks, insert_job, get_existing_urls get_task_for_job, get_active_tasks, insert_job, get_existing_urls
from scripts.task_runner import submit_task from scripts.task_runner import submit_task
from app.cloud_session import resolve_session, get_db_path from app.cloud_session import resolve_session, get_db_path, get_config_dir
_CONFIG_DIR = Path(__file__).parent.parent / "config"
resolve_session("peregrine") resolve_session("peregrine")
init_db(get_db_path()) 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: def _email_configured() -> bool:
_e = Path(__file__).parent.parent / "config" / "email.yaml" _e = get_config_dir() / "email.yaml"
if not _e.exists(): if not _e.exists():
return False return False
import yaml as _yaml 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")) return bool(_cfg.get("username") or _cfg.get("user") or _cfg.get("imap_host"))
def _notion_configured() -> bool: def _notion_configured() -> bool:
_n = Path(__file__).parent.parent / "config" / "notion.yaml" _n = get_config_dir() / "notion.yaml"
if not _n.exists(): if not _n.exists():
return False return False
import yaml as _yaml import yaml as _yaml
@ -46,7 +45,7 @@ def _notion_configured() -> bool:
return bool(_cfg.get("token")) return bool(_cfg.get("token"))
def _keywords_configured() -> bool: def _keywords_configured() -> bool:
_k = Path(__file__).parent.parent / "config" / "resume_keywords.yaml" _k = get_config_dir() / "resume_keywords.yaml"
if not _k.exists(): if not _k.exists():
return False return False
import yaml as _yaml import yaml as _yaml

View file

@ -203,8 +203,16 @@ def get_config_dir() -> Path:
isolated and never shared across tenants. isolated and never shared across tenants.
Local: repo-level config/ directory. Local: repo-level config/ directory.
""" """
if CLOUD_MODE and st.session_state.get("db_path"): if CLOUD_MODE:
return Path(st.session_state["db_path"]).parent / "config" 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" 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 dockerfile: peregrine/Dockerfile.cfcore
command: > command: >
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601" 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: volumes:
- /devl/menagerie-data:/devl/menagerie-data - /devl/menagerie-data:/devl/menagerie-data
- ./config/llm.cloud.yaml:/app/config/llm.yaml:ro - ./config/llm.cloud.yaml:/app/config/llm.yaml:ro
@ -65,6 +67,7 @@ services:
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN} - HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-} - FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
- CF_ORCH_URL=http://host.docker.internal:7700
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
@ -81,6 +84,9 @@ services:
- api - api
restart: unless-stopped 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: searxng:
image: searxng/searxng:latest image: searxng/searxng:latest
volumes: volumes:

View file

@ -61,6 +61,7 @@ services:
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-} - OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0} - PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-} - PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
- CF_ORCH_URL=${CF_ORCH_URL:-http://host.docker.internal:7700}
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
@ -129,6 +130,31 @@ services:
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed] profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
restart: unless-stopped 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: finetune:
build: build:
context: . 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__ model: __auto__
supports_images: false supports_images: false
type: openai_compat type: openai_compat
cf_orch:
service: vllm
model_candidates:
- Qwen2.5-3B-Instruct
ttl_s: 300
vllm_research: vllm_research:
api_key: '' api_key: ''
base_url: http://host.docker.internal:8000/v1 base_url: http://host.docker.internal:8000/v1
@ -52,6 +57,11 @@ backends:
model: __auto__ model: __auto__
supports_images: false supports_images: false
type: openai_compat type: openai_compat
cf_orch:
service: vllm
model_candidates:
- Qwen2.5-3B-Instruct
ttl_s: 300
fallback_order: fallback_order:
- vllm - vllm
- ollama - 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 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 ## 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 # 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. 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 # 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. 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 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. 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 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"] research_order = router.config.get("research_fallback_order") or router.config["fallback_order"]
company = job.get("company") or "the company" company = job.get("company") or "the company"
title = job.get("title") or "this role" 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") sql = path.read_text(encoding="utf-8")
log.info("Applying migration %s to %s", version, db_path.name) log.info("Applying migration %s to %s", version, db_path.name)
try: 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( con.execute(
"INSERT INTO schema_migrations (version) VALUES (?)", (version,) "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]: def load_config(config_dir: Path | None = None) -> tuple[dict, dict]:
cfg = config_dir or CONFIG_DIR cfg = config_dir or CONFIG_DIR
profiles_path = cfg / "search_profiles.yaml" profiles_path = cfg / "search_profiles.yaml"
notion_path = cfg / "notion.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} 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 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") _rp = profile.get("remote_preference", "both")
_is_remote: bool | None = True if _rp == "remote" else (False if _rp == "onsite" else None) _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"]: for location in profile["locations"]:
# ── JobSpy boards ────────────────────────────────────────────────── # ── JobSpy boards ──────────────────────────────────────────────────
if 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: try:
jobspy_kwargs: dict = dict( 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", []))), search_term=" OR ".join(f'"{t}"' for t in (profile.get("titles") or profile.get("job_titles", []))),
location=location, location=location,
results_wanted=results_per_board, results_wanted=results_per_board,

View file

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

View file

@ -301,7 +301,7 @@ def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str)
elif section == "experience": elif section == "experience":
# For experience, we keep the structured entries but replace the bullets. # 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. # 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 return updated
@ -345,6 +345,198 @@ def _reparse_experience_bullets(
return result 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 ─────────────────────────────────────────────────────── # ── Hallucination guard ───────────────────────────────────────────────────────
def hallucination_check(original: dict[str, Any], rewritten: dict[str, Any]) -> bool: 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("") lines.append("")
return "\n".join(lines) 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__) 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 ( from scripts.db import (
DEFAULT_DB, DEFAULT_DB,
insert_task, 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": elif task_type == "company_research":
from scripts.company_research import research_company from scripts.company_research import research_company
_cfg_dir = Path(db_path).parent / "config"
_user_llm_cfg = _cfg_dir / "llm.yaml"
result = research_company( result = research_company(
job, job,
on_stage=lambda s: update_task_stage(db_path, task_id, s), 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) 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 from scripts.user_profile import load_user_profile
_user_yaml = Path(db_path).parent / "config" / "user.yaml"
description = job.get("description", "") 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 # Parse the candidate's resume
update_task_stage(db_path, task_id, "parsing resume") update_task_stage(db_path, task_id, "parsing resume")
resume_text = Path(resume_path).read_text(errors="replace") if resume_path else "" _plain_yaml = Path(db_path).parent / "config" / "plain_text_resume.yaml"
resume_struct, parse_err = structure_resume(resume_text) 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) # Extract keyword gaps and build gap report (free tier)
update_task_stage(db_path, task_id, "extracting keyword gaps") 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) prioritized = prioritize_gaps(gaps, resume_struct)
gap_report = _json.dumps(prioritized, indent=2) gap_report = _json.dumps(prioritized, indent=2)
# Full rewrite (paid tier only) # Full rewrite (paid tier only) → enters awaiting_review, not completed
rewritten_text = ""
p = _json.loads(params or "{}") 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): if p.get("full_rewrite", False):
update_task_stage(db_path, task_id, "rewriting resume sections") 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) rewritten = rewrite_for_ats(resume_struct, prioritized, job, candidate_voice)
if hallucination_check(resume_struct, rewritten): 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: else:
log.warning("[task_runner] resume_optimize hallucination check failed for job %d", job_id) log.warning("[task_runner] resume_optimize hallucination check failed for job %d", job_id)
save_optimized_resume(db_path, job_id=job_id,
save_optimized_resume(db_path, job_id=job_id, text="", gap_report=gap_report)
text=rewritten_text, else:
gap_report=gap_report) # 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": elif task_type == "prepare_training":
from scripts.prepare_training_data import build_records, write_jsonl, DEFAULT_OUTPUT 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 unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
_WORKTREE = "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa" # credential_store.py was merged to main repo — no worktree path manipulation needed
# ── 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()
@pytest.fixture(scope="module") @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 = tmp_path / "config" / "search_profiles.yaml"
fake_path.parent.mkdir(parents=True, exist_ok=True) fake_path.parent.mkdir(parents=True, exist_ok=True)
with open(fake_path, "w") as f: 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) monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path)
from dev_api import app from dev_api import app

View file

@ -104,7 +104,7 @@ class TestWizardHardware:
r = client.get("/api/wizard/hardware") r = client.get("/api/wizard/hardware")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() 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 "gpus" in body
assert "suggested_profile" in body assert "suggested_profile" in body
@ -245,8 +245,10 @@ class TestWizardStep:
assert r.status_code == 200 assert r.status_code == 200
assert search_path.exists() assert search_path.exists()
prefs = yaml.safe_load(search_path.read_text()) prefs = yaml.safe_load(search_path.read_text())
assert prefs["default"]["job_titles"] == ["Software Engineer", "Backend Developer"] # Step 6 writes canonical {profiles: [{name, titles, locations, ...}]} format
assert "Remote" in prefs["default"]["location"] 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): def test_step7_only_advances_counter(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml" 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/fraunces": "^5.2.9",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1", "@vueuse/integrations": "^14.2.1",
"animejs": "^4.3.6", "animejs": "^4.3.6",
"dompurify": "^3.4.0",
"marked": "^18.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-router": "^5.0.3" "vue-router": "^5.0.3"
@ -1718,6 +1721,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1735,6 +1747,12 @@
"undici-types": "~7.16.0" "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": { "node_modules/@types/web-bluetooth": {
"version": "0.0.21", "version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@ -2944,6 +2962,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/duplexer": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
@ -3472,6 +3499,18 @@
"url": "https://github.com/sponsors/sxzz" "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": { "node_modules/mdn-data": {
"version": "2.27.1", "version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "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/fraunces": "^5.2.9",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1", "@vueuse/integrations": "^14.2.1",
"animejs": "^4.3.6", "animejs": "^4.3.6",
"dompurify": "^3.4.0",
"marked": "^18.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-router": "^5.0.3" "vue-router": "^5.0.3"

View file

@ -77,6 +77,7 @@ body {
} }
/* ── Dark mode ─────────────────────────────────────── */ /* ── Dark mode ─────────────────────────────────────── */
/* Covers both: OS-level dark preference AND explicit dark theme selection in UI */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) { :root:not([data-theme="hacker"]) {
--app-primary: #68A8D8; /* Falcon Blue (dark) — 6.54:1 on #16202e ✅ AA */ --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) ──────────────── */ /* ── Hacker mode (Konami easter egg) ──────────────── */
[data-theme="hacker"] { [data-theme="hacker"] {
--app-primary: #00ff41; --app-primary: #00ff41;

View file

@ -28,7 +28,7 @@
<span v-if="job.is_remote" class="remote-badge">Remote</span> <span v-if="job.is_remote" class="remote-badge">Remote</span>
</div> </div>
<h1 class="job-details__title">{{ job.title }}</h1> <h2 class="job-details__title">{{ job.title }}</h2>
<div class="job-details__company"> <div class="job-details__company">
{{ job.company }} {{ job.company }}
<span v-if="job.location" aria-hidden="true"> · </span> <span v-if="job.location" aria-hidden="true"> · </span>
@ -38,7 +38,7 @@
<!-- Description --> <!-- Description -->
<div class="job-details__desc" :class="{ 'job-details__desc--clamped': !descExpanded }"> <div class="job-details__desc" :class="{ 'job-details__desc--clamped': !descExpanded }">
{{ job.description ?? 'No description available.' }} <MarkdownView :content="job.description ?? 'No description available.'" />
</div> </div>
<button <button
v-if="(job.description?.length ?? 0) > 300" v-if="(job.description?.length ?? 0) > 300"
@ -199,7 +199,7 @@
<!-- Application Q&A --> <!-- Application Q&A -->
<div class="qa-section"> <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 class="section-toggle__label">Application Q&amp;A</span>
<span v-if="qaItems.length" class="qa-count">{{ qaItems.length }}</span> <span v-if="qaItems.length" class="qa-count">{{ qaItems.length }}</span>
<span class="section-toggle__icon" aria-hidden="true">{{ qaExpanded ? '▲' : '▼' }}</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 type { Job } from '../stores/review'
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue' import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
import ResumeLibraryCard from './ResumeLibraryCard.vue' import ResumeLibraryCard from './ResumeLibraryCard.vue'
import MarkdownView from './MarkdownView.vue'
const config = useAppConfigStore() const config = useAppConfigStore()
@ -458,6 +459,10 @@ async function markApplied() {
async function rejectListing() { async function rejectListing() {
if (actioning.value) return 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' actioning.value = 'reject'
await useApiFetch(`/api/jobs/${props.jobId}/reject`, { method: 'POST' }) await useApiFetch(`/api/jobs/${props.jobId}/reject`, { method: 'POST' })
actioning.value = null actioning.value = null
@ -706,7 +711,6 @@ declare module '../stores/review' {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--color-text); color: var(--color-text);
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
@ -860,7 +864,7 @@ declare module '../stores/review' {
overflow: hidden; overflow: hidden;
} }
.cl-editor__textarea:focus { outline: none; } .cl-editor__textarea:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
.cl-regen { .cl-regen {
align-self: flex-end; align-self: flex-end;
@ -1209,9 +1213,12 @@ declare module '../stores/review' {
} }
.qa-item__answer:focus { .qa-item__answer:focus {
outline: none;
border-color: var(--app-primary); 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; } .qa-suggest-btn { align-self: flex-end; }
@ -1234,9 +1241,12 @@ declare module '../stores/review' {
} }
.qa-add__input:focus { .qa-add__input:focus {
outline: none;
border-color: var(--app-primary); 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); } .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 type { StageSignal, PipelineStage } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi' 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<{ const props = defineProps<{
job: PipelineJob job: PipelineJob
focused?: boolean focused?: boolean
@ -178,6 +216,17 @@ const columnColor = computed(() => {
<div v-if="interviewDateLabel" class="date-chip"> <div v-if="interviewDateLabel" class="date-chip">
{{ dateChipIcon }} {{ interviewDateLabel }} {{ dateChipIcon }} {{ interviewDateLabel }}
</div> </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> </div>
<footer class="card-footer"> <footer class="card-footer">
<button class="card-action" @click.stop="emit('move', job.id)">Move to </button> <button class="card-action" @click.stop="emit('move', job.id)">Move to </button>
@ -188,6 +237,20 @@ const columnColor = computed(() => {
class="card-action" class="card-action"
@click.stop="emit('survey', job.id)" @click.stop="emit('survey', job.id)"
>Survey </button> >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> </footer>
<!-- Signal banners --> <!-- Signal banners -->
<template v-if="job.stage_signals?.length"> <template v-if="job.stage_signals?.length">
@ -338,6 +401,31 @@ const columnColor = computed(() => {
align-self: flex-start; 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 { .card-footer {
border-top: 1px solid var(--color-border-light); border-top: 1px solid var(--color-border-light);
@ -363,6 +451,26 @@ const columnColor = computed(() => {
background: var(--color-surface); 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 { .signal-banner {
border-top: 1px solid transparent; /* color set inline */ border-top: 1px solid transparent; /* color set inline */
padding: 8px 12px; 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: 'resume', component: () => import('../views/settings/ResumeProfileView.vue') },
{ path: 'search', component: () => import('../views/settings/SearchPrefsView.vue') }, { path: 'search', component: () => import('../views/settings/SearchPrefsView.vue') },
{ path: 'system', component: () => import('../views/settings/SystemSettingsView.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: 'fine-tune', component: () => import('../views/settings/FineTuneView.vue') },
{ path: 'license', component: () => import('../views/settings/LicenseView.vue') }, { path: 'license', component: () => import('../views/settings/LicenseView.vue') },
{ path: 'data', component: () => import('../views/settings/DataView.vue') }, { path: 'data', component: () => import('../views/settings/DataView.vue') },

View file

@ -22,6 +22,11 @@ export interface Contact {
received_at: string | null received_at: string | null
} }
export interface QAItem {
question: string
answer: string
}
export interface TaskStatus { export interface TaskStatus {
status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null
stage: string | null stage: string | null
@ -43,6 +48,8 @@ export const usePrepStore = defineStore('prep', () => {
const research = ref<ResearchBrief | null>(null) const research = ref<ResearchBrief | null>(null)
const contacts = ref<Contact[]>([]) const contacts = ref<Contact[]>([])
const contactsError = ref<string | null>(null) 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 taskStatus = ref<TaskStatus>({ status: null, stage: null, message: null })
const fullJob = ref<FullJobDetail | null>(null) const fullJob = ref<FullJobDetail | null>(null)
const loading = ref(false) const loading = ref(false)
@ -64,6 +71,8 @@ export const usePrepStore = defineStore('prep', () => {
research.value = null research.value = null
contacts.value = [] contacts.value = []
contactsError.value = null contactsError.value = null
qaItems.value = []
qaError.value = null
taskStatus.value = { status: null, stage: null, message: null } taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null fullJob.value = null
error.value = null error.value = null
@ -72,9 +81,10 @@ export const usePrepStore = defineStore('prep', () => {
loading.value = true loading.value = true
try { 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<ResearchBrief>(`/api/jobs/${jobId}/research`),
useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`), useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`),
useApiFetch<QAItem[]>(`/api/jobs/${jobId}/qa`),
useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`), useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`),
useApiFetch<FullJobDetail>(`/api/jobs/${jobId}`), useApiFetch<FullJobDetail>(`/api/jobs/${jobId}`),
]) ])
@ -100,6 +110,15 @@ export const usePrepStore = defineStore('prep', () => {
contactsError.value = null 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 } taskStatus.value = taskResult.data ?? { status: null, stage: null, message: null }
fullJob.value = jobResult.data ?? null fullJob.value = jobResult.data ?? null
@ -144,11 +163,23 @@ export const usePrepStore = defineStore('prep', () => {
}, 3000) }, 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() { function clear() {
_clearInterval() _clearInterval()
research.value = null research.value = null
contacts.value = [] contacts.value = []
contactsError.value = null contactsError.value = null
qaItems.value = []
qaError.value = null
taskStatus.value = { status: null, stage: null, message: null } taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null fullJob.value = null
loading.value = false loading.value = false
@ -160,12 +191,15 @@ export const usePrepStore = defineStore('prep', () => {
research, research,
contacts, contacts,
contactsError, contactsError,
qaItems,
qaError,
taskStatus, taskStatus,
fullJob, fullJob,
loading, loading,
error, error,
currentJobId, currentJobId,
fetchFor, fetchFor,
fetchContacts,
generateResearch, generateResearch,
pollTask, pollTask,
clear, clear,

View file

@ -31,6 +31,11 @@ export const useResumeStore = defineStore('settings/resume', () => {
const veteran_status = ref(''); const disability = ref('') const veteran_status = ref(''); const disability = ref('')
// Keywords // Keywords
const skills = ref<string[]>([]); const domains = ref<string[]>([]); const keywords = ref<string[]>([]) 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 }) { function syncFromProfile(p: { name: string; email: string; phone: string; linkedin_url: string }) {
name.value = p.name; email.value = p.email name.value = p.name; email.value = p.email
@ -100,6 +105,30 @@ export const useResumeStore = defineStore('settings/resume', () => {
experience.value.splice(idx, 1) 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) { function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
const trimmed = value.trim() 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, experience, salary_min, salary_max, notice_period, remote, relocation, assessment, background_check,
gender, pronouns, ethnicity, veteran_status, disability, gender, pronouns, ethnicity, veteran_status, disability,
skills, domains, keywords, skills, domains, keywords,
skillSuggestions, domainSuggestions, keywordSuggestions, suggestingField,
syncFromProfile, load, save, createBlank, syncFromProfile, load, save, createBlank,
addExperience, removeExperience, addTag, removeTag, addExperience, removeExperience, addTag, removeTag, suggestTags, acceptTagSuggestion,
} }
}) })

View file

@ -1,10 +1,13 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { usePrepStore } from '../stores/prep' import { usePrepStore } from '../stores/prep'
import { useInterviewsStore } from '../stores/interviews' import { useInterviewsStore } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'
import type { PipelineJob } from '../stores/interviews' import type { PipelineJob } from '../stores/interviews'
import type { QAItem } from '../stores/prep'
import MarkdownView from '../components/MarkdownView.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -26,9 +29,25 @@ const PREP_VALID_STATUSES = ['phone_screen', 'interviewing', 'offer'] as const
const job = ref<PipelineJob | null>(null) const job = ref<PipelineJob | null>(null)
// Tabs // Tabs
type TabId = 'jd' | 'email' | 'letter' type TabId = 'jd' | 'email' | 'letter' | 'qa'
const activeTab = ref<TabId>('jd') 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) // Call notes (localStorage via @vueuse/core)
const notesKey = computed(() => `cf-prep-notes-${jobId.value ?? 'none'}`) const notesKey = computed(() => `cf-prep-notes-${jobId.value ?? 'none'}`)
const callNotes = useStorage(notesKey, '') const callNotes = useStorage(notesKey, '')
@ -61,6 +80,7 @@ async function guardAndLoad() {
job.value = found job.value = found
await prepStore.fetchFor(jobId.value) await prepStore.fetchFor(jobId.value)
initQA()
} }
onMounted(() => { onMounted(() => {
@ -198,6 +218,77 @@ async function onGenerate() {
if (jobId.value === null) return if (jobId.value === null) return
await prepStore.generateResearch(jobId.value) 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> </script>
<template> <template>
@ -303,7 +394,7 @@ async function onGenerate() {
<span aria-hidden="true">{{ sec.icon }}</span> {{ sec.title }} <span aria-hidden="true">{{ sec.icon }}</span> {{ sec.title }}
</h2> </h2>
<p v-if="sec.caption" class="section-caption">{{ sec.caption }}</p> <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> </section>
</div> </div>
@ -354,6 +445,17 @@ async function onGenerate() {
> >
Cover Letter Cover Letter
</button> </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> </div>
<!-- JD tab --> <!-- JD tab -->
@ -379,7 +481,7 @@ async function onGenerate() {
</div> </div>
<div v-if="prepStore.fullJob?.description" class="jd-body"> <div v-if="prepStore.fullJob?.description" class="jd-body">
{{ prepStore.fullJob.description }} <MarkdownView :content="prepStore.fullJob.description" />
</div> </div>
<div v-else class="tab-empty"> <div v-else class="tab-empty">
<span class="empty-bird">🦅</span> <span class="empty-bird">🦅</span>
@ -421,6 +523,61 @@ async function onGenerate() {
<span class="empty-bird">🦅</span> <span class="empty-bird">🦅</span>
<p>No email history for this job.</p> <p>No email history for this job.</p>
</div> </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> </div>
<!-- Cover letter tab --> <!-- Cover letter tab -->
@ -432,7 +589,7 @@ async function onGenerate() {
aria-labelledby="tab-letter" aria-labelledby="tab-letter"
> >
<div v-if="prepStore.fullJob?.cover_letter" class="letter-body"> <div v-if="prepStore.fullJob?.cover_letter" class="letter-body">
{{ prepStore.fullJob.cover_letter }} <MarkdownView :content="prepStore.fullJob.cover_letter" />
</div> </div>
<div v-else class="tab-empty"> <div v-else class="tab-empty">
<span class="empty-bird">🦅</span> <span class="empty-bird">🦅</span>
@ -440,6 +597,62 @@ async function onGenerate() {
</div> </div>
</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 --> <!-- Call notes -->
<section class="call-notes" aria-label="Call notes"> <section class="call-notes" aria-label="Call notes">
<h2 class="call-notes-title">Call Notes</h2> <h2 class="call-notes-title">Call Notes</h2>
@ -762,9 +975,6 @@ async function onGenerate() {
.section-body { .section-body {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
} }
/* ── Empty state ─────────────────────────────────────────────────────────── */ /* ── Empty state ─────────────────────────────────────────────────────────── */
@ -866,9 +1076,6 @@ async function onGenerate() {
.jd-body { .jd-body {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.7;
white-space: pre-wrap;
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
} }
@ -921,9 +1128,6 @@ async function onGenerate() {
/* Cover letter tab */ /* Cover letter tab */
.letter-body { .letter-body {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.8;
white-space: pre-wrap;
} }
/* ── Call notes ──────────────────────────────────────────────────────────── */ /* ── Call notes ──────────────────────────────────────────────────────────── */
@ -971,4 +1175,227 @@ async function onGenerate() {
margin: 0; margin: 0;
font-style: italic; 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> </style>

View file

@ -309,6 +309,40 @@ function daysSince(dateStr: string | null) {
if (!dateStr) return null if (!dateStr) return null
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000) 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> </script>
<template> <template>
@ -519,26 +553,56 @@ function daysSince(dateStr: string | null) {
</div> </div>
</section> </section>
<!-- Rejected accordion --> <!-- Rejected analytics section -->
<details class="rejected-accordion" v-if="store.rejected.length > 0"> <section v-if="store.rejected.length > 0" class="rejected-section" aria-label="Rejected jobs">
<summary class="rejected-summary"> <button
Rejected ({{ store.rejected.length }}) class="rejected-toggle"
<span class="rejected-hint"> expand for details</span> :aria-expanded="rejectedExpanded"
</summary> aria-controls="rejected-body"
<div class="rejected-body"> @click="rejectedExpanded = !rejectedExpanded"
<div class="rejected-stats"> >
<div class="stat-chip"> <span class="rejected-chevron" :class="{ 'is-expanded': rejectedExpanded }"></span>
<span class="stat-num">{{ store.rejected.length }}</span> <span class="rejected-toggle-label">Rejected ({{ store.rejected.length }})</span>
<span class="stat-lbl">Total</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> </div>
<div v-for="job in store.rejected" :key="job.id" class="rejected-row">
<span class="rejected-title">{{ job.title }} {{ job.company }}</span> <!-- Flat list of rejected jobs -->
<span class="rejected-stage">{{ job.rejection_stage ?? 'No response' }}</span> <div class="rejected-list">
<button class="btn-unrej" @click="openMove(job.id)">Move </button> <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>
</div> </div>
</details> </section>
<MoveToSheet <MoveToSheet
v-if="moveTarget" v-if="moveTarget"
@ -561,8 +625,8 @@ function daysSince(dateStr: string | null) {
<style scoped> <style scoped>
.interviews-view { .interviews-view {
padding: var(--space-4) var(--space-4) var(--space-12); padding: var(--space-4) var(--space-6) var(--space-12);
max-width: 1100px; margin: 0 auto; position: relative; max-width: 1400px; margin: 0 auto; position: relative;
} }
.confetti-canvas { position: fixed; inset: 0; z-index: 300; pointer-events: none; display: none; } .confetti-canvas { position: fixed; inset: 0; z-index: 300; pointer-events: none; display: none; }
.hired-toast { .hired-toast {
@ -704,14 +768,17 @@ function daysSince(dateStr: string | null) {
} }
.kanban { .kanban {
display: grid; grid-template-columns: repeat(3, 1fr); display: grid;
gap: var(--space-4); margin-bottom: var(--space-6); 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 { .kanban-col {
background: var(--color-surface); border-radius: 10px; 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; transition: box-shadow 150ms;
min-height: 200px;
} }
.kanban-col--focused { box-shadow: 0 0 0 2px var(--color-primary); } .kanban-col--focused { box-shadow: 0 0 0 2px var(--color-primary); }
.col-header { .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; } .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)} } @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; } .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 analytics section */
.rejected-summary { .rejected-section {
list-style: none; padding: var(--space-3) var(--space-4); border: 1px solid color-mix(in srgb, var(--color-error) 25%, var(--color-border-light));
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); border-radius: 10px;
cursor: pointer; font-weight: 700; font-size: 0.85rem; color: var(--color-error); overflow: hidden;
display: flex; align-items: center; gap: var(--space-2); 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; } .empty-bird { font-size: 1.25rem; }
.pre-list-pagination { .pre-list-pagination {
display: flex; align-items: center; justify-content: center; gap: var(--space-2); 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); 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; font-family: monospace; font-size: var(--font-sm, 0.875rem); resize: vertical;
background: var(--color-surface-alt, #f8fafc); 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__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
.rv__error { color: var(--color-error, #dc2626); font-size: var(--font-sm, 0.875rem); } .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); } .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) { @media (max-width: 640px) {
.rv__layout { grid-template-columns: 1fr; } .rv__layout { grid-template-columns: 1fr; }
.rv__list { max-height: 200px; } .rv__list { max-height: 200px; }

View file

@ -454,11 +454,14 @@ function toggleHistoryEntry(id: number) {
border: 2px dashed var(--color-border, #e2e8f0); border: 2px dashed var(--color-border, #e2e8f0);
margin: var(--space-4); margin: var(--space-4);
border-radius: var(--radius-md, 8px); border-radius: var(--radius-md, 8px);
outline: none;
} }
.screenshot-zone:focus { .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 { .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"> <section class="form-section">
<h3>Skills & Keywords</h3> <h3>Skills & Keywords</h3>
<div class="tag-section"> <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"> <div class="tags">
<span v-for="skill in store.skills" :key="skill" class="tag"> <span v-for="skill in store.skills" :key="skill" class="tag">
{{ skill }} <button @click="store.removeTag('skills', skill)">×</button> {{ skill }} <button @click="store.removeTag('skills', skill)">×</button>
</span> </span>
</div> </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" /> <input v-model="skillInput" @keydown.enter.prevent="store.addTag('skills', skillInput); skillInput = ''" placeholder="Add skill, press Enter" />
</div> </div>
<div class="tag-section"> <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"> <div class="tags">
<span v-for="domain in store.domains" :key="domain" class="tag"> <span v-for="domain in store.domains" :key="domain" class="tag">
{{ domain }} <button @click="store.removeTag('domains', domain)">×</button> {{ domain }} <button @click="store.removeTag('domains', domain)">×</button>
</span> </span>
</div> </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" /> <input v-model="domainInput" @keydown.enter.prevent="store.addTag('domains', domainInput); domainInput = ''" placeholder="Add domain, press Enter" />
</div> </div>
<div class="tag-section"> <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"> <div class="tags">
<span v-for="kw in store.keywords" :key="kw" class="tag"> <span v-for="kw in store.keywords" :key="kw" class="tag">
{{ kw }} <button @click="store.removeTag('keywords', kw)">×</button> {{ kw }} <button @click="store.removeTag('keywords', kw)">×</button>
</span> </span>
</div> </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" /> <input v-model="kwInput" @keydown.enter.prevent="store.addTag('keywords', kwInput); kwInput = ''" placeholder="Add keyword, press Enter" />
</div> </div>
</section> </section>
@ -304,45 +328,83 @@ async function handleUpload() {
</script> </script>
<style scoped> <style scoped>
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); } .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, 32px); color: var(--color-text-primary, #e2e8f0); } 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, 16px); color: var(--color-text-primary, #e2e8f0); } h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.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)); } .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, 16px); } .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-secondary, #94a3b8); } .field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
.field-row input, .field-row textarea, .field-row select { .field-row input, .field-row textarea, .field-row select {
background: var(--color-surface-2, rgba(255,255,255,0.05)); background: var(--color-surface-alt);
border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
color: var(--color-text-primary, #e2e8f0); color: var(--color-text);
padding: 7px 10px; padding: 7px 10px;
font-size: 0.88rem; font-size: 0.88rem;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.sync-label { font-size: 0.72rem; color: var(--color-accent, #7c3aed); margin-left: 6px; } .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-primary, #e2e8f0); cursor: pointer; } .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, rgba(255,255,255,0.08)); border-radius: 8px; padding: var(--space-4, 24px); margin-bottom: var(--space-4, 24px); } .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: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); cursor: pointer; font-size: 0.82rem; } .remove-btn {
.empty-state { text-align: center; padding: var(--space-8, 48px) 0; } margin-top: 8px; padding: 4px 12px; border-radius: 4px;
.empty-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4, 24px); margin-top: var(--space-6, 32px); } background: color-mix(in srgb, var(--color-error) 15%, transparent);
.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; } 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 h3 { margin-bottom: 8px; }
.empty-card p { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; } .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, #7c3aed); color: #fff; border: none; } .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, 24px); } .tag-section { margin-bottom: var(--space-4); }
.tag-section label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; } .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; } .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 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; } .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, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); } .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, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; } .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-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error { color: #ef4444; font-size: 0.82rem; } .error { color: var(--color-error); 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); } .error-banner {
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; } background: color-mix(in srgb, var(--color-error) 10%, transparent);
.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; } border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); } border-radius: 6px; color: var(--color-error); font-size: 0.85rem; padding: 10px 14px; margin-bottom: var(--space-4);
.replace-section { background: var(--color-surface-2, rgba(255,255,255,0.03)); border-radius: 8px; padding: var(--space-4, 24px); } }
.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> </style>

View file

@ -86,10 +86,16 @@
<!-- Job Boards --> <!-- Job Boards -->
<section class="form-section"> <section class="form-section">
<h3>Job Boards</h3> <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"> <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 }} {{ 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> </label>
</div> </div>
<div class="field-row" style="margin-top: 12px"> <div class="field-row" style="margin-top: 12px">
@ -179,37 +185,78 @@ onMounted(() => store.load())
</script> </script>
<style scoped> <style scoped>
.search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); } .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, 32px); color: var(--color-text-primary, #e2e8f0); } 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, 16px); color: var(--color-text-primary, #e2e8f0); } h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.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)); } .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-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 { 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, #7c3aed); border-color: var(--color-accent, #7c3aed); color: #fff; } .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-secondary, #94a3b8); margin-top: 8px; } .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; } .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 button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
.tag-input-row { display: flex; gap: 8px; } .tag-input-row { display: flex; gap: 8px; }
.tag-input-row input, input[type="text"], input:not([type]) { .tag-input-row input, input[type="text"], input:not([type]) {
background: var(--color-surface-2, rgba(255,255,255,0.05)); background: var(--color-surface-alt);
border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border: 1px solid var(--color-border);
border-radius: 6px; color: var(--color-text-primary, #e2e8f0); border-radius: 6px; color: var(--color-text);
padding: 7px 10px; font-size: 0.85rem; flex: 1; box-sizing: border-box; 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; } .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 {
.suggestion-chip:hover { background: rgba(124,58,237,0.15); border-color: rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); } 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; } .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 { display: flex; flex-direction: column; gap: 6px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); } .field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
.blocklist-group { margin-bottom: var(--space-4, 24px); } .blocklist-group { margin-bottom: var(--space-4); }
.blocklist-group label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; } .blocklist-group label { font-size: 0.82rem; color: var(--color-text-muted); display: block; margin-bottom: 6px; }
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); } .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, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; } .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-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-banner {
.error { color: #ef4444; font-size: 0.82rem; } 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> </style>

View file

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

View file

@ -44,6 +44,30 @@
</div> </div>
</section> </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 --> <!-- Services section -->
<section class="form-section"> <section class="form-section">
<h3>Services</h3> <h3>Services</h3>
@ -65,97 +89,6 @@
</div> </div>
</section> </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 --> <!-- File Paths -->
<section class="form-section"> <section class="form-section">
<h3>File Paths</h3> <h3>File Paths</h3>
@ -239,6 +172,7 @@ import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useSystemStore } from '../../stores/settings/system' import { useSystemStore } from '../../stores/settings/system'
import { useAppConfigStore } from '../../stores/appConfig' import { useAppConfigStore } from '../../stores/appConfig'
import { useApiFetch } from '../../composables/useApi'
const store = useSystemStore() const store = useSystemStore()
const config = useAppConfigStore() const config = useAppConfigStore()
@ -287,108 +221,158 @@ async function handleConfirmByok() {
byokConfirmed.value = false byokConfirmed.value = false
} }
const emailTestResult = ref<boolean | null>(null) // Custom cover letter model
const emailPasswordInput = ref('') const coverLetterModel = ref('')
const integrationInputs = ref<Record<string, string>>({}) const ollamaModels = ref<string[]>([])
async function handleTestEmail() { const clmSaving = ref(false)
const result = await store.testEmail() const clmError = ref<string | null>(null)
emailTestResult.value = result?.ok ?? false 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() { async function saveCoverLetterModel() {
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined } clmSaving.value = true
await store.saveEmailWithPassword(payload) clmError.value = null
} clmSaved.value = false
const { error } = await useApiFetch('/api/settings/llm/cover-letter-model', {
async function handleConnect(id: string) { method: 'PUT',
const integration = store.integrations.find(i => i.id === id) headers: { 'Content-Type': 'application/json' },
if (!integration) return body: JSON.stringify({ model: coverLetterModel.value }),
const credentials: Record<string, string> = {} })
for (const field of integration.fields) { clmSaving.value = false
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? '' if (error) { clmError.value = 'Failed to save model.'; return }
} clmSaved.value = true
await store.connectIntegration(id, credentials) setTimeout(() => { clmSaved.value = false }, 3000)
}
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 () => { onMounted(async () => {
await store.loadLlm() await store.loadLlm()
await Promise.all([ const tasks = [
store.loadServices(), store.loadServices(),
store.loadEmail(),
store.loadIntegrations(),
store.loadFilePaths(), store.loadFilePaths(),
store.loadDeployConfig(), store.loadDeployConfig(),
]) ]
if (config.isCloud && tierOrder.indexOf(tier.value) >= tierOrder.indexOf('paid')) {
tasks.push(loadCoverLetterModel())
}
await Promise.all(tasks)
}) })
</script> </script>
<style scoped> <style scoped>
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); } .system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; color: var(--color-text-primary, #e2e8f0); } h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); } h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
.tab-note { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: var(--space-6, 32px); } .tab-note { font-size: 0.82rem; color: var(--color-text-muted); margin-bottom: var(--space-6); }
.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)); } .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-secondary, #94a3b8); margin-bottom: 14px; } .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-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; } .backend-card:active { cursor: grabbing; }
.drag-handle { font-size: 1.1rem; color: var(--color-text-secondary, #64748b); } .drag-handle { font-size: 1.1rem; color: var(--color-text-muted); }
.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; } .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-primary, #e2e8f0); } .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-secondary, #94a3b8); } .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, 24px); } .form-actions { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
.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; } .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-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; } .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: 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-banner {
.error { color: #ef4444; font-size: 0.82rem; } 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 */ /* 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-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 { 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; color: var(--color-text-primary, #e2e8f0); } .modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; }
.modal-card p { font-size: 0.88rem; color: var(--color-text-secondary, #94a3b8); 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-primary, #e2e8f0); } .modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text); }
.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; } .byok-warning {
.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; } 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; } .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-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-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.service-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .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-running { background: var(--color-success); box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 50%, transparent); }
.dot-stopped { background: #64748b; } .dot-stopped { background: var(--color-text-muted); }
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); } .service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text); }
.service-port { font-size: 0.75rem; color: var(--color-text-secondary, #64748b); font-family: monospace; } .service-port { font-size: 0.75rem; color: var(--color-text-muted); font-family: monospace; }
.service-note { font-size: 0.75rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 10px; } .service-note { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 10px; }
.service-actions { display: flex; gap: 6px; } .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-start {
.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; } 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 { 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 label { font-size: 0.82rem; color: var(--color-text-muted); }
.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-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-secondary, #64748b); margin-top: 3px; } .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, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; } .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: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.25); cursor: pointer; font-size: 0.82rem; } .btn-danger {
.test-ok { color: #22c55e; font-size: 0.85rem; } padding: 6px 14px; border-radius: 6px;
.test-fail { color: #ef4444; font-size: 0.85rem; } background: color-mix(in srgb, var(--color-error) 10%, transparent);
.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; } color: var(--color-error);
.integration-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
.integration-name { font-weight: 600; font-size: 0.9rem; color: var(--color-text-primary, #e2e8f0); } cursor: pointer; font-size: 0.82rem;
.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); } .test-ok { color: var(--color-success); font-size: 0.85rem; }
.badge-disconnected { background: rgba(100,116,139,0.15); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); } .test-fail { color: var(--color-error); font-size: 0.85rem; }
.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; } .field-select {
.tier-locked { padding: 12px 0; font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); } flex: 1;
.integration-badges { display: flex; align-items: center; gap: 4px; } 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> </style>

View file

@ -5,15 +5,15 @@
Peregrine uses your hardware profile to choose the right inference setup. Peregrine uses your hardware profile to choose the right inference setup.
</p> </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> <template v-else>
<div v-if="wizard.hardware.gpus.length" class="step__success"> <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(', ') }} {{ wizard.hardware.gpus.join(', ') }}
</div> </div>
<div v-else class="step__info"> <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>
<div class="step__field"> <div class="step__field">
@ -23,15 +23,59 @@
<option value="cpu">CPU local Ollama, no GPU</option> <option value="cpu">CPU local Ollama, no GPU</option>
<option value="single-gpu">Single GPU local Ollama + one 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="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> </select>
</div> </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 <div
v-if="selectedProfile !== 'remote' && !wizard.hardware.gpus.length" v-else-if="selectedProfile !== 'remote' && !wizard.hardware.gpus.length"
class="step__warning" class="step__warning"
> >
No GPUs detected a GPU profile may not work. Choose CPU or Remote No local GPUs detected a GPU profile may not work. Choose CPU, Remote,
if you don't have a local NVIDIA GPU. or cf-orch if you have access to the cluster.
</div> </div>
</template> </template>
@ -47,17 +91,52 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard' import { useWizardStore } from '../../stores/wizard'
import { useApiFetch } from '../../composables/useApi'
import './wizard.css' import './wizard.css'
const wizard = useWizardStore() const wizard = useWizardStore()
const router = useRouter() const router = useRouter()
const selectedProfile = ref(wizard.hardware.selectedProfile) 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() { async function next() {
wizard.hardware.selectedProfile = selectedProfile.value wizard.hardware.selectedProfile = selectedProfile.value as typeof wizard.hardware.selectedProfile
const ok = await wizard.saveStep(1, { inference_profile: selectedProfile.value }) 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') if (ok) router.push('/setup/tier')
} }
</script> </script>

View file

@ -28,7 +28,7 @@
<!-- Step content --> <!-- Step content -->
<div class="wizard__body"> <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" /> <span class="wizard__spinner" aria-hidden="true" />
Loading Loading
</div> </div>
@ -44,7 +44,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard' import { useWizardStore } from '../../stores/wizard'
import { useAppConfigStore } from '../../stores/appConfig' import { useAppConfigStore } from '../../stores/appConfig'
@ -56,9 +56,15 @@ const router = useRouter()
// Peregrine logo served from the static assets directory // Peregrine logo served from the static assets directory
const logoSrc = '/static/peregrine_logo_circle.png' 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 () => { onMounted(async () => {
if (!config.loaded) await config.load() if (!config.loaded) await config.load()
const target = await wizard.loadStatus(config.isCloud) const target = await wizard.loadStatus(config.isCloud)
layoutReady.value = true
if (router.currentRoute.value.path === '/setup') { if (router.currentRoute.value.path === '/setup') {
router.replace(target) router.replace(target)
} }

View file

@ -327,3 +327,66 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; 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);
}