feat: Interview prep Q&A, cf-orch hardware profile, a11y fixes, dark theme
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:
parent
91943022a8
commit
8e36863a49
51 changed files with 3823 additions and 385 deletions
|
|
@ -26,6 +26,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
COPY circuitforge-core/ /circuitforge-core/
|
||||
RUN pip install --no-cache-dir /circuitforge-core
|
||||
|
||||
# circuitforge-orch client — needed for LLMRouter cf_orch allocation.
|
||||
# Optional: if the directory doesn't exist the COPY will fail at build time; keep
|
||||
# cf-orch as a sibling of peregrine in the build context.
|
||||
COPY circuitforge-orch/ /circuitforge-orch/
|
||||
RUN pip install --no-cache-dir /circuitforge-orch
|
||||
|
||||
COPY peregrine/requirements.txt .
|
||||
# Skip the cfcore line — already installed above from the local copy
|
||||
RUN grep -v 'circuitforge-core' requirements.txt | pip install --no-cache-dir -r /dev/stdin
|
||||
|
|
@ -39,6 +45,13 @@ COPY peregrine/scrapers/ /app/scrapers/
|
|||
|
||||
COPY peregrine/ .
|
||||
|
||||
# Remove per-user config files that are gitignored but may exist locally.
|
||||
# Defense-in-depth: the parent .dockerignore should already exclude these,
|
||||
# but an explicit rm guarantees they never end up in the cloud image.
|
||||
RUN rm -f config/user.yaml config/plain_text_resume.yaml config/notion.yaml \
|
||||
config/email.yaml config/tokens.yaml config/craigslist.yaml \
|
||||
config/adzuna.yaml .env
|
||||
|
||||
EXPOSE 8501
|
||||
|
||||
CMD ["streamlit", "run", "app/app.py", \
|
||||
|
|
|
|||
153
HANDOFF-xanderland.md
Normal file
153
HANDOFF-xanderland.md
Normal 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.
|
||||
19
app/Home.py
19
app/Home.py
|
|
@ -14,23 +14,22 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||
|
||||
from scripts.user_profile import UserProfile
|
||||
|
||||
_USER_YAML = Path(__file__).parent.parent / "config" / "user.yaml"
|
||||
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
|
||||
_name = _profile.name if _profile else "Job Seeker"
|
||||
|
||||
from scripts.db import init_db, get_job_counts, purge_jobs, purge_email_data, \
|
||||
purge_non_remote, archive_jobs, kill_stuck_tasks, cancel_task, \
|
||||
get_task_for_job, get_active_tasks, insert_job, get_existing_urls
|
||||
from scripts.task_runner import submit_task
|
||||
from app.cloud_session import resolve_session, get_db_path
|
||||
|
||||
_CONFIG_DIR = Path(__file__).parent.parent / "config"
|
||||
from app.cloud_session import resolve_session, get_db_path, get_config_dir
|
||||
|
||||
resolve_session("peregrine")
|
||||
init_db(get_db_path())
|
||||
|
||||
_CONFIG_DIR = get_config_dir()
|
||||
_USER_YAML = _CONFIG_DIR / "user.yaml"
|
||||
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
|
||||
_name = _profile.name if _profile else "Job Seeker"
|
||||
|
||||
def _email_configured() -> bool:
|
||||
_e = Path(__file__).parent.parent / "config" / "email.yaml"
|
||||
_e = get_config_dir() / "email.yaml"
|
||||
if not _e.exists():
|
||||
return False
|
||||
import yaml as _yaml
|
||||
|
|
@ -38,7 +37,7 @@ def _email_configured() -> bool:
|
|||
return bool(_cfg.get("username") or _cfg.get("user") or _cfg.get("imap_host"))
|
||||
|
||||
def _notion_configured() -> bool:
|
||||
_n = Path(__file__).parent.parent / "config" / "notion.yaml"
|
||||
_n = get_config_dir() / "notion.yaml"
|
||||
if not _n.exists():
|
||||
return False
|
||||
import yaml as _yaml
|
||||
|
|
@ -46,7 +45,7 @@ def _notion_configured() -> bool:
|
|||
return bool(_cfg.get("token"))
|
||||
|
||||
def _keywords_configured() -> bool:
|
||||
_k = Path(__file__).parent.parent / "config" / "resume_keywords.yaml"
|
||||
_k = get_config_dir() / "resume_keywords.yaml"
|
||||
if not _k.exists():
|
||||
return False
|
||||
import yaml as _yaml
|
||||
|
|
|
|||
|
|
@ -203,8 +203,16 @@ def get_config_dir() -> Path:
|
|||
isolated and never shared across tenants.
|
||||
Local: repo-level config/ directory.
|
||||
"""
|
||||
if CLOUD_MODE and st.session_state.get("db_path"):
|
||||
return Path(st.session_state["db_path"]).parent / "config"
|
||||
if CLOUD_MODE:
|
||||
db_path = st.session_state.get("db_path")
|
||||
if db_path:
|
||||
return Path(db_path).parent / "config"
|
||||
# Session not resolved yet (resolve_session() should have called st.stop() already).
|
||||
# Return an isolated empty temp dir rather than the repo config, which may contain
|
||||
# another user's data baked into the image.
|
||||
_safe = Path("/tmp/peregrine-cloud-noconfig")
|
||||
_safe.mkdir(exist_ok=True)
|
||||
return _safe
|
||||
return Path(__file__).parent.parent / "config"
|
||||
|
||||
|
||||
|
|
|
|||
BIN
app/static/peregrine_logo.png
Normal file
BIN
app/static/peregrine_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
BIN
app/static/peregrine_logo_circle.png
Normal file
BIN
app/static/peregrine_logo_circle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
|
|
@ -51,6 +51,8 @@ services:
|
|||
dockerfile: peregrine/Dockerfile.cfcore
|
||||
command: >
|
||||
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
|
||||
ports:
|
||||
- "127.0.0.1:8601:8601" # localhost-only — Caddy + avocet imitate tab
|
||||
volumes:
|
||||
- /devl/menagerie-data:/devl/menagerie-data
|
||||
- ./config/llm.cloud.yaml:/app/config/llm.yaml:ro
|
||||
|
|
@ -65,6 +67,7 @@ services:
|
|||
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
||||
- CF_ORCH_URL=http://host.docker.internal:7700
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
|
@ -81,6 +84,9 @@ services:
|
|||
- api
|
||||
restart: unless-stopped
|
||||
|
||||
# cf-orch-agent: not needed in cloud — a host-native agent already runs on :7701
|
||||
# and is registered with the coordinator. app/api reach it via CF_ORCH_URL.
|
||||
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
volumes:
|
||||
|
|
|
|||
26
compose.yml
26
compose.yml
|
|
@ -61,6 +61,7 @@ services:
|
|||
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
|
||||
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
|
||||
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
|
||||
- CF_ORCH_URL=${CF_ORCH_URL:-http://host.docker.internal:7700}
|
||||
- PYTHONUNBUFFERED=1
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
|
@ -129,6 +130,31 @@ services:
|
|||
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
|
||||
restart: unless-stopped
|
||||
|
||||
cf-orch-agent:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: peregrine/Dockerfile.cfcore
|
||||
command: ["/bin/sh", "/app/docker/cf-orch-agent/start.sh"]
|
||||
ports:
|
||||
- "${CF_ORCH_AGENT_PORT:-7701}:7701"
|
||||
environment:
|
||||
- CF_ORCH_COORDINATOR_URL=${CF_ORCH_COORDINATOR_URL:-http://host.docker.internal:7700}
|
||||
- CF_ORCH_NODE_ID=${CF_ORCH_NODE_ID:-peregrine}
|
||||
- CF_ORCH_AGENT_PORT=${CF_ORCH_AGENT_PORT:-7701}
|
||||
- CF_ORCH_ADVERTISE_HOST=${CF_ORCH_ADVERTISE_HOST:-}
|
||||
- PYTHONUNBUFFERED=1
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
|
||||
restart: unless-stopped
|
||||
|
||||
finetune:
|
||||
build:
|
||||
context: .
|
||||
|
|
|
|||
23
config/label_tool.yaml.example
Normal file
23
config/label_tool.yaml.example
Normal 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
|
||||
|
|
@ -45,6 +45,11 @@ backends:
|
|||
model: __auto__
|
||||
supports_images: false
|
||||
type: openai_compat
|
||||
cf_orch:
|
||||
service: vllm
|
||||
model_candidates:
|
||||
- Qwen2.5-3B-Instruct
|
||||
ttl_s: 300
|
||||
vllm_research:
|
||||
api_key: ''
|
||||
base_url: http://host.docker.internal:8000/v1
|
||||
|
|
@ -52,6 +57,11 @@ backends:
|
|||
model: __auto__
|
||||
supports_images: false
|
||||
type: openai_compat
|
||||
cf_orch:
|
||||
service: vllm
|
||||
model_candidates:
|
||||
- Qwen2.5-3B-Instruct
|
||||
ttl_s: 300
|
||||
fallback_order:
|
||||
- vllm
|
||||
- ollama
|
||||
|
|
|
|||
978
dev-api.py
978
dev-api.py
File diff suppressed because it is too large
Load diff
14
docker/cf-orch-agent/start.sh
Normal file
14
docker/cf-orch-agent/start.sh
Normal 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
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
|
|
|||
BIN
docs/screenshots/01-dashboard.png
Normal file
BIN
docs/screenshots/01-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/screenshots/02-review.png
Normal file
BIN
docs/screenshots/02-review.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
docs/screenshots/03-apply.png
Normal file
BIN
docs/screenshots/03-apply.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
|
|
@ -1,5 +1,7 @@
|
|||
# Apply Workspace
|
||||
|
||||

|
||||
|
||||
The Apply Workspace is where you generate cover letters, export application documents, and record that you have applied to a job.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Job Review
|
||||
|
||||

|
||||
|
||||
The Job Review page is where you approve or reject newly discovered jobs before they enter the application pipeline.
|
||||
|
||||
---
|
||||
|
|
|
|||
7
migrations/002_ats_resume_columns.sql
Normal file
7
migrations/002_ats_resume_columns.sql
Normal 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;
|
||||
3
migrations/003_resume_review.sql
Normal file
3
migrations/003_resume_review.sql
Normal 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;
|
||||
5
migrations/004_resume_final_struct.sql
Normal file
5
migrations/004_resume_final_struct.sql
Normal 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
92
podman-standalone.sh
Executable 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"
|
||||
|
|
@ -277,7 +277,8 @@ def _load_resume_and_keywords() -> tuple[dict, list[str]]:
|
|||
return resume, keywords
|
||||
|
||||
|
||||
def research_company(job: dict, use_scraper: bool = True, on_stage=None) -> dict:
|
||||
def research_company(job: dict, use_scraper: bool = True, on_stage=None,
|
||||
config_path: "Path | None" = None) -> dict:
|
||||
"""
|
||||
Generate a pre-interview research brief for a job.
|
||||
|
||||
|
|
@ -295,7 +296,7 @@ def research_company(job: dict, use_scraper: bool = True, on_stage=None) -> dict
|
|||
"""
|
||||
from scripts.llm_router import LLMRouter
|
||||
|
||||
router = LLMRouter()
|
||||
router = LLMRouter(config_path=config_path) if config_path else LLMRouter()
|
||||
research_order = router.config.get("research_fallback_order") or router.config["fallback_order"]
|
||||
company = job.get("company") or "the company"
|
||||
title = job.get("title") or "this role"
|
||||
|
|
|
|||
|
|
@ -56,7 +56,56 @@ def migrate_db(db_path: Path) -> list[str]:
|
|||
sql = path.read_text(encoding="utf-8")
|
||||
log.info("Applying migration %s to %s", version, db_path.name)
|
||||
try:
|
||||
con.executescript(sql)
|
||||
# Execute statements individually so that ALTER TABLE ADD COLUMN
|
||||
# errors caused by already-existing columns (pre-migration DBs
|
||||
# created from a newer schema) are treated as no-ops rather than
|
||||
# fatal failures.
|
||||
statements = [s.strip() for s in sql.split(";") if s.strip()]
|
||||
for stmt in statements:
|
||||
# Strip leading SQL comment lines (-- ...) before processing.
|
||||
# Checking startswith("--") on the raw chunk would skip entire
|
||||
# multi-line statements whose first line is a comment.
|
||||
stripped_lines = [
|
||||
ln for ln in stmt.splitlines()
|
||||
if not ln.strip().startswith("--")
|
||||
]
|
||||
stmt = "\n".join(stripped_lines).strip()
|
||||
if not stmt:
|
||||
continue
|
||||
# Pre-check: if this is ADD COLUMN and the column already exists, skip.
|
||||
# This guards against schema_migrations being ahead of the actual schema
|
||||
# (e.g. DB reset after migrations were recorded).
|
||||
stmt_upper = stmt.upper()
|
||||
if "ALTER TABLE" in stmt_upper and "ADD COLUMN" in stmt_upper:
|
||||
# Extract table name and column name from the statement
|
||||
import re as _re
|
||||
m = _re.match(
|
||||
r"ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)",
|
||||
stmt, _re.IGNORECASE
|
||||
)
|
||||
if m:
|
||||
tbl, col = m.group(1), m.group(2)
|
||||
existing = {
|
||||
row[1]
|
||||
for row in con.execute(f"PRAGMA table_info({tbl})")
|
||||
}
|
||||
if col in existing:
|
||||
log.info(
|
||||
"Migration %s: column %s.%s already exists, skipping",
|
||||
version, tbl, col,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
con.execute(stmt)
|
||||
except sqlite3.OperationalError as stmt_exc:
|
||||
msg = str(stmt_exc).lower()
|
||||
if "duplicate column name" in msg or "already exists" in msg:
|
||||
log.info(
|
||||
"Migration %s: statement already applied, skipping: %s",
|
||||
version, stmt_exc,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
con.execute(
|
||||
"INSERT INTO schema_migrations (version) VALUES (?)", (version,)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,11 +34,38 @@ CUSTOM_SCRAPERS: dict[str, object] = {
|
|||
}
|
||||
|
||||
|
||||
def _normalize_profiles(raw: dict) -> dict:
|
||||
"""Normalize search_profiles.yaml to the canonical {profiles: [...]} format.
|
||||
|
||||
The onboarding wizard (pre-fix) wrote a flat `default: {...}` structure.
|
||||
Canonical format is `profiles: [{name, titles/job_titles, boards, ...}]`.
|
||||
This converts on load so both formats work without a migration.
|
||||
"""
|
||||
if "profiles" in raw:
|
||||
return raw
|
||||
# Wizard-written format: top-level keys are profile names (usually "default")
|
||||
profiles = []
|
||||
for name, body in raw.items():
|
||||
if not isinstance(body, dict):
|
||||
continue
|
||||
# job_boards: [{name, enabled}] → boards: [name] (enabled only)
|
||||
job_boards = body.pop("job_boards", None)
|
||||
if job_boards and "boards" not in body:
|
||||
body["boards"] = [b["name"] for b in job_boards if b.get("enabled", True)]
|
||||
# blocklist_* keys live in load_blocklist, not per-profile — drop them
|
||||
body.pop("blocklist_companies", None)
|
||||
body.pop("blocklist_industries", None)
|
||||
body.pop("blocklist_locations", None)
|
||||
profiles.append({"name": name, **body})
|
||||
return {"profiles": profiles}
|
||||
|
||||
|
||||
def load_config(config_dir: Path | None = None) -> tuple[dict, dict]:
|
||||
cfg = config_dir or CONFIG_DIR
|
||||
profiles_path = cfg / "search_profiles.yaml"
|
||||
notion_path = cfg / "notion.yaml"
|
||||
profiles = yaml.safe_load(profiles_path.read_text())
|
||||
raw = yaml.safe_load(profiles_path.read_text()) or {}
|
||||
profiles = _normalize_profiles(raw)
|
||||
notion_cfg = yaml.safe_load(notion_path.read_text()) if notion_path.exists() else {"field_map": {}, "token": None, "database_id": None}
|
||||
return profiles, notion_cfg
|
||||
|
||||
|
|
@ -212,14 +239,43 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
|
|||
_rp = profile.get("remote_preference", "both")
|
||||
_is_remote: bool | None = True if _rp == "remote" else (False if _rp == "onsite" else None)
|
||||
|
||||
# When filtering for remote-only, also drop hybrid roles at the description level.
|
||||
# Job boards (especially LinkedIn) tag hybrid listings as is_remote=True, so the
|
||||
# board-side filter alone is not reliable. We match specific work-arrangement
|
||||
# phrases to avoid false positives like "hybrid cloud" or "hybrid architecture".
|
||||
_HYBRID_PHRASES = [
|
||||
"hybrid role", "hybrid position", "hybrid work", "hybrid schedule",
|
||||
"hybrid model", "hybrid arrangement", "hybrid opportunity",
|
||||
"in-office/remote", "in office/remote", "remote/in-office",
|
||||
"remote/office", "office/remote",
|
||||
"days in office", "days per week in", "days onsite", "days on-site",
|
||||
"required to be in office", "required in office",
|
||||
]
|
||||
if _rp == "remote":
|
||||
exclude_kw = exclude_kw + _HYBRID_PHRASES
|
||||
|
||||
for location in profile["locations"]:
|
||||
|
||||
# ── JobSpy boards ──────────────────────────────────────────────────
|
||||
if boards:
|
||||
print(f" [jobspy] {location} — boards: {', '.join(boards)}")
|
||||
# Validate boards against the installed JobSpy Site enum.
|
||||
# One unsupported name in the list aborts the entire scrape_jobs() call.
|
||||
try:
|
||||
from jobspy import Site as _Site
|
||||
_valid = {s.value for s in _Site}
|
||||
_filtered = [b for b in boards if b in _valid]
|
||||
_dropped = [b for b in boards if b not in _valid]
|
||||
if _dropped:
|
||||
print(f" [jobspy] Skipping unsupported boards: {', '.join(_dropped)}")
|
||||
except ImportError:
|
||||
_filtered = boards # fallback: pass through unchanged
|
||||
if not _filtered:
|
||||
print(f" [jobspy] No valid boards for {location} — skipping")
|
||||
continue
|
||||
print(f" [jobspy] {location} — boards: {', '.join(_filtered)}")
|
||||
try:
|
||||
jobspy_kwargs: dict = dict(
|
||||
site_name=boards,
|
||||
site_name=_filtered,
|
||||
search_term=" OR ".join(f'"{t}"' for t in (profile.get("titles") or profile.get("job_titles", []))),
|
||||
location=location,
|
||||
results_wanted=results_per_board,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ def _build_system_context(profile=None) -> str:
|
|||
return " ".join(parts)
|
||||
|
||||
SYSTEM_CONTEXT = _build_system_context()
|
||||
_candidate = _profile.name if _profile else "the candidate"
|
||||
|
||||
|
||||
# ── Mission-alignment detection ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str)
|
|||
elif section == "experience":
|
||||
# For experience, we keep the structured entries but replace the bullets.
|
||||
# The LLM rewrites the whole section as plain text; we re-parse the bullets.
|
||||
updated["experience"] = _reparse_experience_bullets(resume["experience"], rewritten)
|
||||
updated["experience"] = _reparse_experience_bullets(resume.get("experience", []), rewritten)
|
||||
return updated
|
||||
|
||||
|
||||
|
|
@ -345,6 +345,198 @@ def _reparse_experience_bullets(
|
|||
return result
|
||||
|
||||
|
||||
# ── Gap framing ───────────────────────────────────────────────────────────────
|
||||
|
||||
def frame_skill_gaps(
|
||||
struct: dict[str, Any],
|
||||
gap_framings: list[dict],
|
||||
job: dict[str, Any],
|
||||
candidate_voice: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Inject honest framing language for skills the candidate doesn't have directly.
|
||||
|
||||
For each gap framing decision the user provided:
|
||||
- mode "adjacent": user has related experience → injects one bridging sentence
|
||||
into the most relevant experience entry's bullets
|
||||
- mode "learning": actively developing the skill → prepends a structured
|
||||
"Developing: X (context)" note to the skills list
|
||||
- mode "skip": no connection at all → no change
|
||||
|
||||
The user-supplied context text is the source of truth. The LLM's job is only
|
||||
to phrase it naturally in resume style — not to invent new claims.
|
||||
|
||||
Args:
|
||||
struct: Resume dict (already processed by apply_review_decisions).
|
||||
gap_framings: List of dicts with keys:
|
||||
skill — the ATS term the candidate lacks
|
||||
mode — "adjacent" | "learning" | "skip"
|
||||
context — candidate's own words describing their related background
|
||||
job: Job dict for role context in prompts.
|
||||
candidate_voice: Free-text style note from user.yaml.
|
||||
|
||||
Returns:
|
||||
New resume dict with framing language injected.
|
||||
"""
|
||||
from scripts.llm_router import LLMRouter
|
||||
router = LLMRouter()
|
||||
|
||||
updated = dict(struct)
|
||||
updated["experience"] = [dict(e) for e in (struct.get("experience") or [])]
|
||||
|
||||
adjacent_framings = [f for f in gap_framings if f.get("mode") == "adjacent" and f.get("context")]
|
||||
learning_framings = [f for f in gap_framings if f.get("mode") == "learning" and f.get("context")]
|
||||
|
||||
# ── Adjacent experience: inject bridging sentence into most relevant entry ─
|
||||
for framing in adjacent_framings:
|
||||
skill = framing["skill"]
|
||||
context = framing["context"]
|
||||
|
||||
# Find the experience entry most likely to be relevant (simple keyword match)
|
||||
best_entry_idx = _find_most_relevant_entry(updated["experience"], skill)
|
||||
if best_entry_idx is None:
|
||||
continue
|
||||
|
||||
entry = updated["experience"][best_entry_idx]
|
||||
bullets = list(entry.get("bullets") or [])
|
||||
|
||||
voice_note = (
|
||||
f'\n\nCandidate voice/style: "{candidate_voice}". Match this tone.'
|
||||
) if candidate_voice else ""
|
||||
|
||||
prompt = (
|
||||
f"You are adding one honest framing sentence to a resume bullet list.\n\n"
|
||||
f"The candidate does not have direct experience with '{skill}', "
|
||||
f"but they have relevant background they described as:\n"
|
||||
f' "{context}"\n\n'
|
||||
f"Job context: {job.get('title', '')} at {job.get('company', '')}.\n\n"
|
||||
f"RULES:\n"
|
||||
f"1. Add exactly ONE new bullet point that bridges their background to '{skill}'.\n"
|
||||
f"2. Do NOT fabricate anything beyond what their context description says.\n"
|
||||
f"3. Use honest language: 'adjacent experience in', 'strong foundation applicable to', "
|
||||
f" 'directly transferable background in', etc.\n"
|
||||
f"4. Return ONLY the single new bullet text — no prefix, no explanation."
|
||||
f"{voice_note}\n\n"
|
||||
f"Existing bullets for context:\n"
|
||||
+ "\n".join(f" • {b}" for b in bullets[:3])
|
||||
)
|
||||
|
||||
try:
|
||||
new_bullet = router.complete(prompt).strip()
|
||||
new_bullet = re.sub(r"^[•\-–—*◦▪▸►]\s*", "", new_bullet).strip()
|
||||
if new_bullet:
|
||||
bullets.append(new_bullet)
|
||||
new_entry = dict(entry)
|
||||
new_entry["bullets"] = bullets
|
||||
updated["experience"][best_entry_idx] = new_entry
|
||||
except Exception:
|
||||
log.warning(
|
||||
"[resume_optimizer] frame_skill_gaps adjacent failed for skill %r", skill,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# ── Learning framing: add structured note to skills list ──────────────────
|
||||
if learning_framings:
|
||||
skills = list(updated.get("skills") or [])
|
||||
for framing in learning_framings:
|
||||
skill = framing["skill"]
|
||||
context = framing["context"].strip()
|
||||
# Format: "Developing: Kubernetes (strong Docker/container orchestration background)"
|
||||
note = f"Developing: {skill} ({context})" if context else f"Developing: {skill}"
|
||||
if note not in skills:
|
||||
skills.append(note)
|
||||
updated["skills"] = skills
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def _find_most_relevant_entry(
|
||||
experience: list[dict],
|
||||
skill: str,
|
||||
) -> int | None:
|
||||
"""Return the index of the experience entry most relevant to a skill term.
|
||||
|
||||
Uses simple keyword overlap between the skill and entry title/bullets.
|
||||
Falls back to the most recent (first) entry if no match found.
|
||||
"""
|
||||
if not experience:
|
||||
return None
|
||||
|
||||
skill_words = set(skill.lower().split())
|
||||
best_idx = 0
|
||||
best_score = -1
|
||||
|
||||
for i, entry in enumerate(experience):
|
||||
entry_text = (
|
||||
(entry.get("title") or "") + " " +
|
||||
" ".join(entry.get("bullets") or [])
|
||||
).lower()
|
||||
entry_words = set(entry_text.split())
|
||||
score = len(skill_words & entry_words)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_idx = i
|
||||
|
||||
return best_idx
|
||||
|
||||
|
||||
def apply_review_decisions(
|
||||
draft: dict[str, Any],
|
||||
decisions: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Apply user section-level review decisions to the rewritten struct.
|
||||
|
||||
Handles approved skills, summary accept/reject, and per-entry experience
|
||||
accept/reject. Returns the updated struct; does not call the LLM.
|
||||
|
||||
Args:
|
||||
draft: The review draft dict from build_review_diff (contains
|
||||
"sections" and "rewritten_struct").
|
||||
decisions: Dict of per-section decisions from the review UI:
|
||||
skills: {"approved_additions": [...]}
|
||||
summary: {"accepted": bool}
|
||||
experience: {"accepted_entries": [{"title", "company", "accepted"}]}
|
||||
|
||||
Returns:
|
||||
Updated resume struct ready for gap framing and final render.
|
||||
"""
|
||||
struct = dict(draft.get("rewritten_struct") or {})
|
||||
sections = draft.get("sections") or []
|
||||
|
||||
# ── Skills: keep original + only approved additions ────────────────────
|
||||
skills_decision = decisions.get("skills", {})
|
||||
approved_additions = set(skills_decision.get("approved_additions") or [])
|
||||
for sec in sections:
|
||||
if sec["section"] == "skills":
|
||||
original_kept = set(sec.get("kept") or [])
|
||||
struct["skills"] = sorted(original_kept | approved_additions)
|
||||
break
|
||||
|
||||
# ── Summary: accept proposed or revert to original ──────────────────────
|
||||
if not decisions.get("summary", {}).get("accepted", True):
|
||||
for sec in sections:
|
||||
if sec["section"] == "summary":
|
||||
struct["career_summary"] = sec.get("original", struct.get("career_summary", ""))
|
||||
break
|
||||
|
||||
# ── Experience: per-entry accept/reject ─────────────────────────────────
|
||||
exp_decisions: dict[str, bool] = {
|
||||
f"{ed.get('title', '')}|{ed.get('company', '')}": ed.get("accepted", True)
|
||||
for ed in (decisions.get("experience", {}).get("accepted_entries") or [])
|
||||
}
|
||||
for sec in sections:
|
||||
if sec["section"] == "experience":
|
||||
for entry_diff in (sec.get("entries") or []):
|
||||
key = f"{entry_diff['title']}|{entry_diff['company']}"
|
||||
if not exp_decisions.get(key, True):
|
||||
for exp_entry in (struct.get("experience") or []):
|
||||
if (exp_entry.get("title") == entry_diff["title"] and
|
||||
exp_entry.get("company") == entry_diff["company"]):
|
||||
exp_entry["bullets"] = entry_diff["original_bullets"]
|
||||
break
|
||||
|
||||
return struct
|
||||
|
||||
|
||||
# ── Hallucination guard ───────────────────────────────────────────────────────
|
||||
|
||||
def hallucination_check(original: dict[str, Any], rewritten: dict[str, Any]) -> bool:
|
||||
|
|
@ -437,3 +629,207 @@ def render_resume_text(resume: dict[str, Any]) -> str:
|
|||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Review diff builder ────────────────────────────────────────────────────────
|
||||
|
||||
def build_review_diff(
|
||||
original: dict[str, Any],
|
||||
rewritten: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Build a structured diff between original and rewritten resume for the review UI.
|
||||
|
||||
Returns a dict with:
|
||||
sections: list of per-section diffs
|
||||
rewritten_struct: the full rewritten resume dict (used by finalize endpoint)
|
||||
|
||||
Each section diff has:
|
||||
section: "skills" | "summary" | "experience"
|
||||
type: "skills_diff" | "text_diff" | "bullets_diff"
|
||||
For skills_diff:
|
||||
added: list of new skill strings (each requires user approval)
|
||||
removed: list of removed skill strings
|
||||
kept: list of unchanged skills
|
||||
For text_diff (summary):
|
||||
original: str
|
||||
proposed: str
|
||||
For bullets_diff (experience):
|
||||
entries: list of {title, company, original_bullets, proposed_bullets}
|
||||
"""
|
||||
sections = []
|
||||
|
||||
# ── Skills diff ────────────────────────────────────────────────────────
|
||||
orig_skills = set(s.strip() for s in (original.get("skills") or []))
|
||||
new_skills = set(s.strip() for s in (rewritten.get("skills") or []))
|
||||
|
||||
added = sorted(new_skills - orig_skills)
|
||||
removed = sorted(orig_skills - new_skills)
|
||||
kept = sorted(orig_skills & new_skills)
|
||||
|
||||
if added or removed:
|
||||
sections.append({
|
||||
"section": "skills",
|
||||
"type": "skills_diff",
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"kept": kept,
|
||||
})
|
||||
|
||||
# ── Summary diff ───────────────────────────────────────────────────────
|
||||
orig_summary = (original.get("career_summary") or "").strip()
|
||||
new_summary = (rewritten.get("career_summary") or "").strip()
|
||||
|
||||
if orig_summary != new_summary and new_summary:
|
||||
sections.append({
|
||||
"section": "summary",
|
||||
"type": "text_diff",
|
||||
"original": orig_summary,
|
||||
"proposed": new_summary,
|
||||
})
|
||||
|
||||
# ── Experience diff ────────────────────────────────────────────────────
|
||||
orig_exp = original.get("experience") or []
|
||||
new_exp = rewritten.get("experience") or []
|
||||
|
||||
entry_diffs = []
|
||||
for orig_entry, new_entry in zip(orig_exp, new_exp):
|
||||
orig_bullets = orig_entry.get("bullets") or []
|
||||
new_bullets = new_entry.get("bullets") or []
|
||||
if orig_bullets != new_bullets:
|
||||
entry_diffs.append({
|
||||
"title": orig_entry.get("title", ""),
|
||||
"company": orig_entry.get("company", ""),
|
||||
"original_bullets": orig_bullets,
|
||||
"proposed_bullets": new_bullets,
|
||||
})
|
||||
|
||||
if entry_diffs:
|
||||
sections.append({
|
||||
"section": "experience",
|
||||
"type": "bullets_diff",
|
||||
"entries": entry_diffs,
|
||||
})
|
||||
|
||||
return {
|
||||
"sections": sections,
|
||||
"rewritten_struct": rewritten,
|
||||
}
|
||||
|
||||
|
||||
# ── PDF export ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def export_pdf(resume: dict[str, Any], output_path: str) -> None:
|
||||
"""Render a structured resume dict to a clean PDF using reportlab.
|
||||
|
||||
Uses a single-column layout with section headers, consistent spacing,
|
||||
and a readable sans-serif body font suitable for ATS submission.
|
||||
|
||||
Args:
|
||||
resume: Structured resume dict (same format as resume_parser output).
|
||||
output_path: Absolute path for the output .pdf file.
|
||||
"""
|
||||
from reportlab.lib.pagesizes import LETTER
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
|
||||
from reportlab.lib import colors
|
||||
|
||||
MARGIN = 0.75 * inch
|
||||
|
||||
name_style = ParagraphStyle(
|
||||
"name", fontName="Helvetica-Bold", fontSize=16, leading=20,
|
||||
alignment=TA_CENTER, spaceAfter=2,
|
||||
)
|
||||
contact_style = ParagraphStyle(
|
||||
"contact", fontName="Helvetica", fontSize=9, leading=12,
|
||||
alignment=TA_CENTER, spaceAfter=6,
|
||||
textColor=colors.HexColor("#555555"),
|
||||
)
|
||||
section_style = ParagraphStyle(
|
||||
"section", fontName="Helvetica-Bold", fontSize=10, leading=14,
|
||||
spaceBefore=10, spaceAfter=2,
|
||||
textColor=colors.HexColor("#1a1a2e"),
|
||||
)
|
||||
body_style = ParagraphStyle(
|
||||
"body", fontName="Helvetica", fontSize=9, leading=13, alignment=TA_LEFT,
|
||||
)
|
||||
role_style = ParagraphStyle(
|
||||
"role", fontName="Helvetica-Bold", fontSize=9, leading=13,
|
||||
)
|
||||
meta_style = ParagraphStyle(
|
||||
"meta", fontName="Helvetica-Oblique", fontSize=8, leading=12,
|
||||
textColor=colors.HexColor("#555555"), spaceAfter=2,
|
||||
)
|
||||
bullet_style = ParagraphStyle(
|
||||
"bullet", fontName="Helvetica", fontSize=9, leading=13, leftIndent=12,
|
||||
)
|
||||
|
||||
def hr():
|
||||
return HRFlowable(width="100%", thickness=0.5,
|
||||
color=colors.HexColor("#cccccc"),
|
||||
spaceAfter=4, spaceBefore=2)
|
||||
|
||||
story = []
|
||||
|
||||
if resume.get("name"):
|
||||
story.append(Paragraph(resume["name"], name_style))
|
||||
|
||||
contact_parts = [p for p in (
|
||||
resume.get("email", ""), resume.get("phone", ""),
|
||||
resume.get("location", ""), resume.get("linkedin", ""),
|
||||
) if p]
|
||||
if contact_parts:
|
||||
story.append(Paragraph(" | ".join(contact_parts), contact_style))
|
||||
|
||||
story.append(hr())
|
||||
|
||||
summary = (resume.get("career_summary") or "").strip()
|
||||
if summary:
|
||||
story.append(Paragraph("SUMMARY", section_style))
|
||||
story.append(hr())
|
||||
story.append(Paragraph(summary, body_style))
|
||||
story.append(Spacer(1, 4))
|
||||
|
||||
if resume.get("experience"):
|
||||
story.append(Paragraph("EXPERIENCE", section_style))
|
||||
story.append(hr())
|
||||
for exp in resume["experience"]:
|
||||
dates = f"{exp.get('start_date', '')}–{exp.get('end_date', '')}"
|
||||
story.append(Paragraph(
|
||||
f"{exp.get('title', '')} | {exp.get('company', '')}", role_style
|
||||
))
|
||||
story.append(Paragraph(dates, meta_style))
|
||||
for bullet in (exp.get("bullets") or []):
|
||||
story.append(Paragraph(f"• {bullet}", bullet_style))
|
||||
story.append(Spacer(1, 4))
|
||||
|
||||
if resume.get("education"):
|
||||
story.append(Paragraph("EDUCATION", section_style))
|
||||
story.append(hr())
|
||||
for edu in resume["education"]:
|
||||
degree = f"{edu.get('degree', '')} {edu.get('field', '')}".strip()
|
||||
story.append(Paragraph(
|
||||
f"{degree} | {edu.get('institution', '')} {edu.get('graduation_year', '')}".strip(),
|
||||
body_style,
|
||||
))
|
||||
story.append(Spacer(1, 4))
|
||||
|
||||
if resume.get("skills"):
|
||||
story.append(Paragraph("SKILLS", section_style))
|
||||
story.append(hr())
|
||||
story.append(Paragraph(", ".join(resume["skills"]), body_style))
|
||||
story.append(Spacer(1, 4))
|
||||
|
||||
if resume.get("achievements"):
|
||||
story.append(Paragraph("ACHIEVEMENTS", section_style))
|
||||
story.append(hr())
|
||||
for a in resume["achievements"]:
|
||||
story.append(Paragraph(f"• {a}", bullet_style))
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
output_path, pagesize=LETTER,
|
||||
leftMargin=MARGIN, rightMargin=MARGIN,
|
||||
topMargin=MARGIN, bottomMargin=MARGIN,
|
||||
)
|
||||
doc.build(story)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,61 @@ from pathlib import Path
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_aihawk_resume(raw: dict) -> dict:
|
||||
"""Convert a plain_text_resume.yaml (AIHawk format) into the optimizer struct.
|
||||
|
||||
Handles two AIHawk variants:
|
||||
- Newer Peregrine wizard output: already uses bullets/start_date/end_date/career_summary
|
||||
- Older raw AIHawk format: uses responsibilities (str), period ("YYYY – Present")
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
def _split_responsibilities(text: str) -> list[str]:
|
||||
lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()]
|
||||
return lines if lines else [text.strip()]
|
||||
|
||||
def _parse_period(period: str) -> tuple[str, str]:
|
||||
parts = _re.split(r"\s*[–—-]\s*", period, maxsplit=1)
|
||||
start = parts[0].strip() if parts else ""
|
||||
end = parts[1].strip() if len(parts) > 1 else "Present"
|
||||
return start, end
|
||||
|
||||
experience = []
|
||||
for entry in raw.get("experience", []):
|
||||
if "responsibilities" in entry:
|
||||
bullets = _split_responsibilities(entry["responsibilities"])
|
||||
else:
|
||||
bullets = entry.get("bullets", [])
|
||||
|
||||
if "period" in entry:
|
||||
start_date, end_date = _parse_period(entry["period"])
|
||||
else:
|
||||
start_date = entry.get("start_date", "")
|
||||
end_date = entry.get("end_date", "Present")
|
||||
|
||||
experience.append({
|
||||
"title": entry.get("title", ""),
|
||||
"company": entry.get("company", ""),
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"bullets": bullets,
|
||||
})
|
||||
|
||||
# career_summary may be a string or absent; assessment field is a legacy bool in some profiles
|
||||
career_summary = raw.get("career_summary", "")
|
||||
if not isinstance(career_summary, str):
|
||||
career_summary = ""
|
||||
|
||||
return {
|
||||
"career_summary": career_summary,
|
||||
"experience": experience,
|
||||
"education": raw.get("education", []),
|
||||
"skills": raw.get("skills", []),
|
||||
"achievements": raw.get("achievements", []),
|
||||
}
|
||||
|
||||
|
||||
from scripts.db import (
|
||||
DEFAULT_DB,
|
||||
insert_task,
|
||||
|
|
@ -196,9 +251,12 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
|||
|
||||
elif task_type == "company_research":
|
||||
from scripts.company_research import research_company
|
||||
_cfg_dir = Path(db_path).parent / "config"
|
||||
_user_llm_cfg = _cfg_dir / "llm.yaml"
|
||||
result = research_company(
|
||||
job,
|
||||
on_stage=lambda s: update_task_stage(db_path, task_id, s),
|
||||
config_path=_user_llm_cfg if _user_llm_cfg.exists() else None,
|
||||
)
|
||||
save_research(db_path, job_id=job_id, **result)
|
||||
|
||||
|
|
@ -287,13 +345,25 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
|||
)
|
||||
from scripts.user_profile import load_user_profile
|
||||
|
||||
_user_yaml = Path(db_path).parent / "config" / "user.yaml"
|
||||
description = job.get("description", "")
|
||||
resume_path = load_user_profile().get("resume_path", "")
|
||||
resume_path = load_user_profile(str(_user_yaml)).get("resume_path", "")
|
||||
|
||||
# Parse the candidate's resume
|
||||
update_task_stage(db_path, task_id, "parsing resume")
|
||||
resume_text = Path(resume_path).read_text(errors="replace") if resume_path else ""
|
||||
resume_struct, parse_err = structure_resume(resume_text)
|
||||
_plain_yaml = Path(db_path).parent / "config" / "plain_text_resume.yaml"
|
||||
if resume_path and Path(resume_path).exists():
|
||||
resume_text = Path(resume_path).read_text(errors="replace")
|
||||
resume_struct, parse_err = structure_resume(resume_text)
|
||||
elif _plain_yaml.exists():
|
||||
import yaml as _yaml
|
||||
_raw = _yaml.safe_load(_plain_yaml.read_text(encoding="utf-8")) or {}
|
||||
resume_struct = _normalize_aihawk_resume(_raw)
|
||||
resume_text = resume_struct.get("career_summary", "")
|
||||
parse_err = ""
|
||||
else:
|
||||
resume_text = ""
|
||||
resume_struct, parse_err = structure_resume("")
|
||||
|
||||
# Extract keyword gaps and build gap report (free tier)
|
||||
update_task_stage(db_path, task_id, "extracting keyword gaps")
|
||||
|
|
@ -301,21 +371,38 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
|||
prioritized = prioritize_gaps(gaps, resume_struct)
|
||||
gap_report = _json.dumps(prioritized, indent=2)
|
||||
|
||||
# Full rewrite (paid tier only)
|
||||
rewritten_text = ""
|
||||
# Full rewrite (paid tier only) → enters awaiting_review, not completed
|
||||
p = _json.loads(params or "{}")
|
||||
selected_gaps = p.get("selected_gaps", None)
|
||||
if selected_gaps is not None:
|
||||
selected_set = set(selected_gaps)
|
||||
prioritized = [g for g in prioritized if g.get("term") in selected_set]
|
||||
if p.get("full_rewrite", False):
|
||||
update_task_stage(db_path, task_id, "rewriting resume sections")
|
||||
candidate_voice = load_user_profile().get("candidate_voice", "")
|
||||
candidate_voice = load_user_profile(str(_user_yaml)).get("candidate_voice", "")
|
||||
rewritten = rewrite_for_ats(resume_struct, prioritized, job, candidate_voice)
|
||||
if hallucination_check(resume_struct, rewritten):
|
||||
rewritten_text = render_resume_text(rewritten)
|
||||
from scripts.resume_optimizer import build_review_diff
|
||||
from scripts.db import save_resume_draft
|
||||
draft = build_review_diff(resume_struct, rewritten)
|
||||
# Attach gap report to draft for reference in the review UI
|
||||
draft["gap_report"] = prioritized
|
||||
save_resume_draft(db_path, job_id=job_id,
|
||||
draft_json=_json.dumps(draft))
|
||||
# Save gap report now; final text written after user review
|
||||
save_optimized_resume(db_path, job_id=job_id,
|
||||
text="", gap_report=gap_report)
|
||||
# Park task in awaiting_review — finalize endpoint resolves it
|
||||
update_task_status(db_path, task_id, "awaiting_review")
|
||||
return
|
||||
else:
|
||||
log.warning("[task_runner] resume_optimize hallucination check failed for job %d", job_id)
|
||||
|
||||
save_optimized_resume(db_path, job_id=job_id,
|
||||
text=rewritten_text,
|
||||
gap_report=gap_report)
|
||||
save_optimized_resume(db_path, job_id=job_id,
|
||||
text="", gap_report=gap_report)
|
||||
else:
|
||||
# Gap-only run (free tier): save report, no draft
|
||||
save_optimized_resume(db_path, job_id=job_id,
|
||||
text="", gap_report=gap_report)
|
||||
|
||||
elif task_type == "prepare_training":
|
||||
from scripts.prepare_training_data import build_records, write_jsonl, DEFAULT_OUTPUT
|
||||
|
|
|
|||
|
|
@ -7,35 +7,7 @@ from pathlib import Path
|
|||
from unittest.mock import patch, MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_WORKTREE = "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa"
|
||||
|
||||
# ── Path bootstrap ────────────────────────────────────────────────────────────
|
||||
# dev_api.py inserts /Library/Development/CircuitForge/peregrine into sys.path
|
||||
# at import time; the worktree has credential_store but the main repo doesn't.
|
||||
# Insert the worktree first so 'scripts' resolves to the worktree version, then
|
||||
# pre-cache it in sys.modules so Python won't re-look-up when dev_api adds the
|
||||
# main peregrine root.
|
||||
if _WORKTREE not in sys.path:
|
||||
sys.path.insert(0, _WORKTREE)
|
||||
# Pre-cache the worktree scripts package and submodules before dev_api import
|
||||
import importlib, types
|
||||
|
||||
def _ensure_worktree_scripts():
|
||||
import importlib.util as _ilu
|
||||
_wt = _WORKTREE
|
||||
# Only load if not already loaded from the worktree
|
||||
_spec = _ilu.spec_from_file_location("scripts", f"{_wt}/scripts/__init__.py",
|
||||
submodule_search_locations=[f"{_wt}/scripts"])
|
||||
if _spec is None:
|
||||
return
|
||||
_mod = _ilu.module_from_spec(_spec)
|
||||
sys.modules.setdefault("scripts", _mod)
|
||||
try:
|
||||
_spec.loader.exec_module(_mod)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_ensure_worktree_scripts()
|
||||
# credential_store.py was merged to main repo — no worktree path manipulation needed
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
|
@ -211,7 +183,8 @@ def test_get_search_prefs_returns_dict(tmp_path, monkeypatch):
|
|||
fake_path = tmp_path / "config" / "search_profiles.yaml"
|
||||
fake_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(fake_path, "w") as f:
|
||||
yaml.dump({"default": {"remote_preference": "remote", "job_boards": []}}, f)
|
||||
yaml.dump({"default": {"remote_preference": "remote",
|
||||
"job_boards": [{"name": "linkedin", "enabled": True}]}}, f)
|
||||
monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path)
|
||||
|
||||
from dev_api import app
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ class TestWizardHardware:
|
|||
r = client.get("/api/wizard/hardware")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body["profiles"]) == {"remote", "cpu", "single-gpu", "dual-gpu"}
|
||||
assert {"remote", "cpu", "single-gpu", "dual-gpu"}.issubset(set(body["profiles"]))
|
||||
assert "gpus" in body
|
||||
assert "suggested_profile" in body
|
||||
|
||||
|
|
@ -245,8 +245,10 @@ class TestWizardStep:
|
|||
assert r.status_code == 200
|
||||
assert search_path.exists()
|
||||
prefs = yaml.safe_load(search_path.read_text())
|
||||
assert prefs["default"]["job_titles"] == ["Software Engineer", "Backend Developer"]
|
||||
assert "Remote" in prefs["default"]["location"]
|
||||
# Step 6 writes canonical {profiles: [{name, titles, locations, ...}]} format
|
||||
default = next(p for p in prefs["profiles"] if p["name"] == "default")
|
||||
assert default["titles"] == ["Software Engineer", "Backend Developer"]
|
||||
assert "Remote" in default["locations"]
|
||||
|
||||
def test_step7_only_advances_counter(self, client, tmp_path):
|
||||
yaml_path = tmp_path / "config" / "user.yaml"
|
||||
|
|
|
|||
39
web/package-lock.json
generated
39
web/package-lock.json
generated
|
|
@ -12,9 +12,12 @@
|
|||
"@fontsource/fraunces": "^5.2.9",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/integrations": "^14.2.1",
|
||||
"animejs": "^4.3.6",
|
||||
"dompurify": "^3.4.0",
|
||||
"marked": "^18.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
|
|
@ -1718,6 +1721,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -1735,6 +1747,12 @@
|
|||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
|
|
@ -2944,6 +2962,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
|
||||
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
|
|
@ -3472,6 +3499,18 @@
|
|||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
|
||||
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@
|
|||
"@fontsource/fraunces": "^5.2.9",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/integrations": "^14.2.1",
|
||||
"animejs": "^4.3.6",
|
||||
"dompurify": "^3.4.0",
|
||||
"marked": "^18.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ body {
|
|||
}
|
||||
|
||||
/* ── Dark mode ─────────────────────────────────────── */
|
||||
/* Covers both: OS-level dark preference AND explicit dark theme selection in UI */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="hacker"]) {
|
||||
--app-primary: #68A8D8; /* Falcon Blue (dark) — 6.54:1 on #16202e ✅ AA */
|
||||
|
|
@ -97,6 +98,26 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
/* Explicit [data-theme="dark"] — fires when user picks dark via theme picker
|
||||
on a light-OS machine (where prefers-color-scheme: dark won't match) */
|
||||
[data-theme="dark"]:not([data-theme="hacker"]) {
|
||||
--app-primary: #68A8D8;
|
||||
--app-primary-hover: #7BBDE6;
|
||||
--app-primary-light: #0D1F35;
|
||||
|
||||
--app-accent: #F6872A;
|
||||
--app-accent-hover: #FF9840;
|
||||
--app-accent-light: #2D1505;
|
||||
--app-accent-text: #1a2338;
|
||||
|
||||
--score-mid-high: #5ba3d9;
|
||||
|
||||
--status-synced: #9b8fea;
|
||||
--status-survey: #b08fea;
|
||||
--status-phone: #4ec9be;
|
||||
--status-offer: #f5a43a;
|
||||
}
|
||||
|
||||
/* ── Hacker mode (Konami easter egg) ──────────────── */
|
||||
[data-theme="hacker"] {
|
||||
--app-primary: #00ff41;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
<span v-if="job.is_remote" class="remote-badge">Remote</span>
|
||||
</div>
|
||||
|
||||
<h1 class="job-details__title">{{ job.title }}</h1>
|
||||
<h2 class="job-details__title">{{ job.title }}</h2>
|
||||
<div class="job-details__company">
|
||||
{{ job.company }}
|
||||
<span v-if="job.location" aria-hidden="true"> · </span>
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
<!-- Description -->
|
||||
<div class="job-details__desc" :class="{ 'job-details__desc--clamped': !descExpanded }">
|
||||
{{ job.description ?? 'No description available.' }}
|
||||
<MarkdownView :content="job.description ?? 'No description available.'" />
|
||||
</div>
|
||||
<button
|
||||
v-if="(job.description?.length ?? 0) > 300"
|
||||
|
|
@ -199,7 +199,7 @@
|
|||
|
||||
<!-- ── Application Q&A ───────────────────────────────────── -->
|
||||
<div class="qa-section">
|
||||
<button class="section-toggle" @click="qaExpanded = !qaExpanded">
|
||||
<button class="section-toggle" :aria-expanded="qaExpanded" @click="qaExpanded = !qaExpanded">
|
||||
<span class="section-toggle__label">Application Q&A</span>
|
||||
<span v-if="qaItems.length" class="qa-count">{{ qaItems.length }}</span>
|
||||
<span class="section-toggle__icon" aria-hidden="true">{{ qaExpanded ? '▲' : '▼' }}</span>
|
||||
|
|
@ -290,6 +290,7 @@ import { useAppConfigStore } from '../stores/appConfig'
|
|||
import type { Job } from '../stores/review'
|
||||
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
|
||||
import ResumeLibraryCard from './ResumeLibraryCard.vue'
|
||||
import MarkdownView from './MarkdownView.vue'
|
||||
|
||||
const config = useAppConfigStore()
|
||||
|
||||
|
|
@ -458,6 +459,10 @@ async function markApplied() {
|
|||
|
||||
async function rejectListing() {
|
||||
if (actioning.value) return
|
||||
const title = job.value?.title ?? 'this listing'
|
||||
const company = job.value?.company ?? ''
|
||||
const label = company ? `"${title}" at ${company}` : `"${title}"`
|
||||
if (!window.confirm(`Reject ${label}? This cannot be undone.`)) return
|
||||
actioning.value = 'reject'
|
||||
await useApiFetch(`/api/jobs/${props.jobId}/reject`, { method: 'POST' })
|
||||
actioning.value = null
|
||||
|
|
@ -706,7 +711,6 @@ declare module '../stores/review' {
|
|||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
|
|
@ -860,7 +864,7 @@ declare module '../stores/review' {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cl-editor__textarea:focus { outline: none; }
|
||||
.cl-editor__textarea:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
|
||||
|
||||
.cl-regen {
|
||||
align-self: flex-end;
|
||||
|
|
@ -1209,9 +1213,12 @@ declare module '../stores/review' {
|
|||
}
|
||||
|
||||
.qa-item__answer:focus {
|
||||
outline: none;
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
.qa-item__answer:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.qa-suggest-btn { align-self: flex-end; }
|
||||
|
||||
|
|
@ -1234,9 +1241,12 @@ declare module '../stores/review' {
|
|||
}
|
||||
|
||||
.qa-add__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
.qa-add__input:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.qa-add__input::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,44 @@ import type { PipelineJob } from '../stores/interviews'
|
|||
import type { StageSignal, PipelineStage } from '../stores/interviews'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
// ── Date picker ────────────────────────────────────────────────────────────────
|
||||
const DATE_STAGES = new Set(['phone_screen', 'interviewing'])
|
||||
|
||||
function toDatetimeLocal(iso: string | null | undefined): string {
|
||||
if (!iso) return ''
|
||||
// Trim seconds/ms so <input type="datetime-local"> accepts it
|
||||
const d = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
async function onDateChange(value: string) {
|
||||
if (!value) return
|
||||
const prev = props.job.interview_date
|
||||
// Optimistic update
|
||||
props.job.interview_date = new Date(value).toISOString()
|
||||
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/interview_date`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interview_date: value }),
|
||||
})
|
||||
if (error) props.job.interview_date = prev
|
||||
}
|
||||
|
||||
// ── Calendar push ──────────────────────────────────────────────────────────────
|
||||
type CalPushStatus = 'idle' | 'loading' | 'synced' | 'failed'
|
||||
const calPushStatus = ref<CalPushStatus>('idle')
|
||||
let calPushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function pushCalendar() {
|
||||
if (calPushStatus.value === 'loading') return
|
||||
calPushStatus.value = 'loading'
|
||||
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/calendar_push`, { method: 'POST' })
|
||||
calPushStatus.value = error ? 'failed' : 'synced'
|
||||
if (calPushTimer) clearTimeout(calPushTimer)
|
||||
calPushTimer = setTimeout(() => { calPushStatus.value = 'idle' }, 3000)
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
job: PipelineJob
|
||||
focused?: boolean
|
||||
|
|
@ -178,6 +216,17 @@ const columnColor = computed(() => {
|
|||
<div v-if="interviewDateLabel" class="date-chip">
|
||||
{{ dateChipIcon }} {{ interviewDateLabel }}
|
||||
</div>
|
||||
<!-- Inline date picker for phone_screen and interviewing -->
|
||||
<div v-if="DATE_STAGES.has(job.status)" class="date-picker-wrap">
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="date-picker"
|
||||
:value="toDatetimeLocal(job.interview_date)"
|
||||
:aria-label="`Interview date for ${job.title}`"
|
||||
@change="onDateChange(($event.target as HTMLInputElement).value)"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="card-footer">
|
||||
<button class="card-action" @click.stop="emit('move', job.id)">Move to… ›</button>
|
||||
|
|
@ -188,6 +237,20 @@ const columnColor = computed(() => {
|
|||
class="card-action"
|
||||
@click.stop="emit('survey', job.id)"
|
||||
>Survey →</button>
|
||||
<!-- Calendar push — phone_screen and interviewing only -->
|
||||
<button
|
||||
v-if="DATE_STAGES.has(job.status)"
|
||||
class="card-action card-action--cal"
|
||||
:class="`card-action--cal-${calPushStatus}`"
|
||||
:disabled="calPushStatus === 'loading'"
|
||||
@click.stop="pushCalendar"
|
||||
:aria-label="`Push ${job.title} to calendar`"
|
||||
>
|
||||
<span v-if="calPushStatus === 'loading'">⏳</span>
|
||||
<span v-else-if="calPushStatus === 'synced'">Synced ✓</span>
|
||||
<span v-else-if="calPushStatus === 'failed'">Failed ✗</span>
|
||||
<span v-else>📅 Calendar</span>
|
||||
</button>
|
||||
</footer>
|
||||
<!-- Signal banners -->
|
||||
<template v-if="job.stage_signals?.length">
|
||||
|
|
@ -338,6 +401,31 @@ const columnColor = computed(() => {
|
|||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.date-picker-wrap {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
width: 100%;
|
||||
font-size: 0.72rem;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition, 150ms);
|
||||
}
|
||||
|
||||
.date-picker:hover,
|
||||
.date-picker:focus {
|
||||
border-color: var(--color-info);
|
||||
}
|
||||
.date-picker:focus-visible {
|
||||
outline: 2px solid var(--color-info);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
.card-footer {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
|
|
@ -363,6 +451,26 @@ const columnColor = computed(() => {
|
|||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.card-action--cal {
|
||||
margin-left: auto;
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
transition: background var(--transition, 150ms), color var(--transition, 150ms);
|
||||
}
|
||||
|
||||
.card-action--cal-synced {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.card-action--cal-failed {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.card-action--cal:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.signal-banner {
|
||||
border-top: 1px solid transparent; /* color set inline */
|
||||
padding: 8px 12px;
|
||||
|
|
|
|||
77
web/src/components/MarkdownView.vue
Normal file
77
web/src/components/MarkdownView.vue
Normal 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>
|
||||
|
|
@ -26,6 +26,7 @@ export const router = createRouter({
|
|||
{ path: 'resume', component: () => import('../views/settings/ResumeProfileView.vue') },
|
||||
{ path: 'search', component: () => import('../views/settings/SearchPrefsView.vue') },
|
||||
{ path: 'system', component: () => import('../views/settings/SystemSettingsView.vue') },
|
||||
{ path: 'connections', component: () => import('../views/settings/ConnectionsSettingsView.vue') },
|
||||
{ path: 'fine-tune', component: () => import('../views/settings/FineTuneView.vue') },
|
||||
{ path: 'license', component: () => import('../views/settings/LicenseView.vue') },
|
||||
{ path: 'data', component: () => import('../views/settings/DataView.vue') },
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ export interface Contact {
|
|||
received_at: string | null
|
||||
}
|
||||
|
||||
export interface QAItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export interface TaskStatus {
|
||||
status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null
|
||||
stage: string | null
|
||||
|
|
@ -43,6 +48,8 @@ export const usePrepStore = defineStore('prep', () => {
|
|||
const research = ref<ResearchBrief | null>(null)
|
||||
const contacts = ref<Contact[]>([])
|
||||
const contactsError = ref<string | null>(null)
|
||||
const qaItems = ref<QAItem[]>([])
|
||||
const qaError = ref<string | null>(null)
|
||||
const taskStatus = ref<TaskStatus>({ status: null, stage: null, message: null })
|
||||
const fullJob = ref<FullJobDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
|
|
@ -64,6 +71,8 @@ export const usePrepStore = defineStore('prep', () => {
|
|||
research.value = null
|
||||
contacts.value = []
|
||||
contactsError.value = null
|
||||
qaItems.value = []
|
||||
qaError.value = null
|
||||
taskStatus.value = { status: null, stage: null, message: null }
|
||||
fullJob.value = null
|
||||
error.value = null
|
||||
|
|
@ -72,9 +81,10 @@ export const usePrepStore = defineStore('prep', () => {
|
|||
|
||||
loading.value = true
|
||||
try {
|
||||
const [researchResult, contactsResult, taskResult, jobResult] = await Promise.all([
|
||||
const [researchResult, contactsResult, qaResult, taskResult, jobResult] = await Promise.all([
|
||||
useApiFetch<ResearchBrief>(`/api/jobs/${jobId}/research`),
|
||||
useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`),
|
||||
useApiFetch<QAItem[]>(`/api/jobs/${jobId}/qa`),
|
||||
useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`),
|
||||
useApiFetch<FullJobDetail>(`/api/jobs/${jobId}`),
|
||||
])
|
||||
|
|
@ -100,6 +110,15 @@ export const usePrepStore = defineStore('prep', () => {
|
|||
contactsError.value = null
|
||||
}
|
||||
|
||||
// Q&A failure is non-fatal — degrade the Practice Q&A tab only
|
||||
if (qaResult.error && !(qaResult.error.kind === 'http' && qaResult.error.status === 404)) {
|
||||
qaError.value = 'Could not load Q&A history.'
|
||||
qaItems.value = []
|
||||
} else {
|
||||
qaItems.value = qaResult.data ?? []
|
||||
qaError.value = null
|
||||
}
|
||||
|
||||
taskStatus.value = taskResult.data ?? { status: null, stage: null, message: null }
|
||||
fullJob.value = jobResult.data ?? null
|
||||
|
||||
|
|
@ -144,11 +163,23 @@ export const usePrepStore = defineStore('prep', () => {
|
|||
}, 3000)
|
||||
}
|
||||
|
||||
async function fetchContacts(jobId: number) {
|
||||
const { data, error: fetchError } = await useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`)
|
||||
if (fetchError) {
|
||||
contactsError.value = 'Could not load email history.'
|
||||
} else {
|
||||
contacts.value = data ?? []
|
||||
contactsError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
_clearInterval()
|
||||
research.value = null
|
||||
contacts.value = []
|
||||
contactsError.value = null
|
||||
qaItems.value = []
|
||||
qaError.value = null
|
||||
taskStatus.value = { status: null, stage: null, message: null }
|
||||
fullJob.value = null
|
||||
loading.value = false
|
||||
|
|
@ -160,12 +191,15 @@ export const usePrepStore = defineStore('prep', () => {
|
|||
research,
|
||||
contacts,
|
||||
contactsError,
|
||||
qaItems,
|
||||
qaError,
|
||||
taskStatus,
|
||||
fullJob,
|
||||
loading,
|
||||
error,
|
||||
currentJobId,
|
||||
fetchFor,
|
||||
fetchContacts,
|
||||
generateResearch,
|
||||
pollTask,
|
||||
clear,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
|||
const veteran_status = ref(''); const disability = ref('')
|
||||
// Keywords
|
||||
const skills = ref<string[]>([]); const domains = ref<string[]>([]); const keywords = ref<string[]>([])
|
||||
// LLM suggestions (pending, not yet accepted)
|
||||
const skillSuggestions = ref<string[]>([])
|
||||
const domainSuggestions = ref<string[]>([])
|
||||
const keywordSuggestions = ref<string[]>([])
|
||||
const suggestingField = ref<'skills' | 'domains' | 'keywords' | null>(null)
|
||||
|
||||
function syncFromProfile(p: { name: string; email: string; phone: string; linkedin_url: string }) {
|
||||
name.value = p.name; email.value = p.email
|
||||
|
|
@ -100,6 +105,30 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
|||
experience.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function suggestTags(field: 'skills' | 'domains' | 'keywords') {
|
||||
suggestingField.value = field
|
||||
const current = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
|
||||
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/resume/suggest-tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: field, current }),
|
||||
})
|
||||
suggestingField.value = null
|
||||
if (!data?.suggestions) return
|
||||
const existing = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
|
||||
const fresh = data.suggestions.filter(s => !existing.includes(s))
|
||||
if (field === 'skills') skillSuggestions.value = fresh
|
||||
else if (field === 'domains') domainSuggestions.value = fresh
|
||||
else keywordSuggestions.value = fresh
|
||||
}
|
||||
|
||||
function acceptTagSuggestion(field: 'skills' | 'domains' | 'keywords', value: string) {
|
||||
addTag(field, value)
|
||||
if (field === 'skills') skillSuggestions.value = skillSuggestions.value.filter(s => s !== value)
|
||||
else if (field === 'domains') domainSuggestions.value = domainSuggestions.value.filter(s => s !== value)
|
||||
else keywordSuggestions.value = keywordSuggestions.value.filter(s => s !== value)
|
||||
}
|
||||
|
||||
function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
|
||||
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
|
||||
const trimmed = value.trim()
|
||||
|
|
@ -119,7 +148,8 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
|||
experience, salary_min, salary_max, notice_period, remote, relocation, assessment, background_check,
|
||||
gender, pronouns, ethnicity, veteran_status, disability,
|
||||
skills, domains, keywords,
|
||||
skillSuggestions, domainSuggestions, keywordSuggestions, suggestingField,
|
||||
syncFromProfile, load, save, createBlank,
|
||||
addExperience, removeExperience, addTag, removeTag,
|
||||
addExperience, removeExperience, addTag, removeTag, suggestTags, acceptTagSuggestion,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { usePrepStore } from '../stores/prep'
|
||||
import { useInterviewsStore } from '../stores/interviews'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
import type { PipelineJob } from '../stores/interviews'
|
||||
import type { QAItem } from '../stores/prep'
|
||||
import MarkdownView from '../components/MarkdownView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -26,9 +29,25 @@ const PREP_VALID_STATUSES = ['phone_screen', 'interviewing', 'offer'] as const
|
|||
const job = ref<PipelineJob | null>(null)
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
type TabId = 'jd' | 'email' | 'letter'
|
||||
type TabId = 'jd' | 'email' | 'letter' | 'qa'
|
||||
const activeTab = ref<TabId>('jd')
|
||||
|
||||
// ── Q&A tab state ─────────────────────────────────────────────────────────────
|
||||
const qaMessages = ref<QAItem[]>([])
|
||||
const qaInput = ref('')
|
||||
const qaSubmitting = ref(false)
|
||||
const qaError = ref<string | null>(null)
|
||||
const qaChatEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// ── Log Contact form state ────────────────────────────────────────────────────
|
||||
const logDirection = ref<'inbound' | 'outbound'>('outbound')
|
||||
const logSubject = ref('')
|
||||
const logAddr = ref('')
|
||||
const logBody = ref('')
|
||||
const logSubmitting = ref(false)
|
||||
const logError = ref<string | null>(null)
|
||||
const logSuccess = ref(false)
|
||||
|
||||
// ── Call notes (localStorage via @vueuse/core) ────────────────────────────────
|
||||
const notesKey = computed(() => `cf-prep-notes-${jobId.value ?? 'none'}`)
|
||||
const callNotes = useStorage(notesKey, '')
|
||||
|
|
@ -61,6 +80,7 @@ async function guardAndLoad() {
|
|||
|
||||
job.value = found
|
||||
await prepStore.fetchFor(jobId.value)
|
||||
initQA()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -198,6 +218,77 @@ async function onGenerate() {
|
|||
if (jobId.value === null) return
|
||||
await prepStore.generateResearch(jobId.value)
|
||||
}
|
||||
|
||||
// ── Q&A: seed from store on mount, then handle submissions ───────────────────
|
||||
function initQA() {
|
||||
qaMessages.value = [...prepStore.qaItems]
|
||||
}
|
||||
|
||||
async function scrollQAToBottom() {
|
||||
await nextTick()
|
||||
if (qaChatEl.value) {
|
||||
qaChatEl.value.scrollTop = qaChatEl.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
async function onAskQuestion() {
|
||||
const question = qaInput.value.trim()
|
||||
if (!question || qaSubmitting.value || jobId.value === null) return
|
||||
|
||||
qaSubmitting.value = true
|
||||
qaError.value = null
|
||||
|
||||
const { data, error: fetchError } = await useApiFetch<{ answer: string }>(
|
||||
`/api/jobs/${jobId.value}/qa/suggest`,
|
||||
{ method: 'POST', body: JSON.stringify({ question, context: 'mock_interview' }) }
|
||||
)
|
||||
|
||||
if (fetchError || !data) {
|
||||
qaError.value = 'Could not get an answer. Please try again.'
|
||||
} else {
|
||||
qaMessages.value = [...qaMessages.value, { question, answer: data.answer }]
|
||||
qaInput.value = ''
|
||||
scrollQAToBottom()
|
||||
}
|
||||
|
||||
qaSubmitting.value = false
|
||||
}
|
||||
|
||||
// ── Log Contact: POST then refresh contacts ───────────────────────────────────
|
||||
async function onLogContact() {
|
||||
if (!logSubject.value.trim() || logSubmitting.value || jobId.value === null) return
|
||||
|
||||
logSubmitting.value = true
|
||||
logError.value = null
|
||||
logSuccess.value = false
|
||||
|
||||
const { error: fetchError } = await useApiFetch(
|
||||
`/api/jobs/${jobId.value}/contacts`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
direction: logDirection.value,
|
||||
subject: logSubject.value.trim(),
|
||||
from_addr: logAddr.value.trim() || null,
|
||||
body: logBody.value.trim() || null,
|
||||
received_at: new Date().toISOString(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (fetchError) {
|
||||
logError.value = 'Could not log contact. Please try again.'
|
||||
} else {
|
||||
logSuccess.value = true
|
||||
logSubject.value = ''
|
||||
logAddr.value = ''
|
||||
logBody.value = ''
|
||||
logDirection.value = 'outbound'
|
||||
await prepStore.fetchContacts(jobId.value)
|
||||
}
|
||||
|
||||
logSubmitting.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -303,7 +394,7 @@ async function onGenerate() {
|
|||
<span aria-hidden="true">{{ sec.icon }}</span> {{ sec.title }}
|
||||
</h2>
|
||||
<p v-if="sec.caption" class="section-caption">{{ sec.caption }}</p>
|
||||
<div class="section-body">{{ sec.content }}</div>
|
||||
<MarkdownView :content="sec.content" class="section-body" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
|
@ -354,6 +445,17 @@ async function onGenerate() {
|
|||
>
|
||||
Cover Letter
|
||||
</button>
|
||||
<button
|
||||
id="tab-qa"
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-btn--active': activeTab === 'qa' }"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'qa'"
|
||||
aria-controls="tabpanel-qa"
|
||||
@click="activeTab = 'qa'"
|
||||
>
|
||||
Practice Q&A
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── JD tab ── -->
|
||||
|
|
@ -379,7 +481,7 @@ async function onGenerate() {
|
|||
</div>
|
||||
|
||||
<div v-if="prepStore.fullJob?.description" class="jd-body">
|
||||
{{ prepStore.fullJob.description }}
|
||||
<MarkdownView :content="prepStore.fullJob.description" />
|
||||
</div>
|
||||
<div v-else class="tab-empty">
|
||||
<span class="empty-bird">🦅</span>
|
||||
|
|
@ -421,6 +523,61 @@ async function onGenerate() {
|
|||
<span class="empty-bird">🦅</span>
|
||||
<p>No email history for this job.</p>
|
||||
</div>
|
||||
|
||||
<!-- Log Contact form -->
|
||||
<div class="log-contact-form">
|
||||
<h3 class="log-contact-title">Log Contact</h3>
|
||||
<div class="log-contact-fields">
|
||||
<div class="log-field">
|
||||
<label class="log-label" for="log-direction">Direction</label>
|
||||
<select id="log-direction" v-model="logDirection" class="log-select">
|
||||
<option value="outbound">Outbound</option>
|
||||
<option value="inbound">Inbound</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="log-field">
|
||||
<label class="log-label" for="log-subject">Subject</label>
|
||||
<input
|
||||
id="log-subject"
|
||||
v-model="logSubject"
|
||||
class="log-input"
|
||||
type="text"
|
||||
placeholder="e.g. Following up on application"
|
||||
/>
|
||||
</div>
|
||||
<div class="log-field">
|
||||
<label class="log-label" for="log-addr">
|
||||
{{ logDirection === 'inbound' ? 'From' : 'To' }}
|
||||
</label>
|
||||
<input
|
||||
id="log-addr"
|
||||
v-model="logAddr"
|
||||
class="log-input"
|
||||
type="email"
|
||||
:placeholder="logDirection === 'inbound' ? 'sender@company.com' : 'recruiter@company.com'"
|
||||
/>
|
||||
</div>
|
||||
<div class="log-field log-field--full">
|
||||
<label class="log-label" for="log-body">Notes (optional)</label>
|
||||
<textarea
|
||||
id="log-body"
|
||||
v-model="logBody"
|
||||
class="log-textarea"
|
||||
placeholder="Paste email body or add notes…"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="logError" class="log-error" role="alert">{{ logError }}</div>
|
||||
<div v-if="logSuccess" class="log-success" role="status">Contact logged.</div>
|
||||
<button
|
||||
class="btn-primary log-submit"
|
||||
:disabled="!logSubject.trim() || logSubmitting"
|
||||
@click="onLogContact"
|
||||
>
|
||||
{{ logSubmitting ? 'Logging…' : 'Log' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Cover letter tab ── -->
|
||||
|
|
@ -432,7 +589,7 @@ async function onGenerate() {
|
|||
aria-labelledby="tab-letter"
|
||||
>
|
||||
<div v-if="prepStore.fullJob?.cover_letter" class="letter-body">
|
||||
{{ prepStore.fullJob.cover_letter }}
|
||||
<MarkdownView :content="prepStore.fullJob.cover_letter" />
|
||||
</div>
|
||||
<div v-else class="tab-empty">
|
||||
<span class="empty-bird">🦅</span>
|
||||
|
|
@ -440,6 +597,62 @@ async function onGenerate() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Practice Q&A tab ── -->
|
||||
<div
|
||||
v-show="activeTab === 'qa'"
|
||||
id="tabpanel-qa"
|
||||
class="tab-panel tab-panel--qa"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-qa"
|
||||
>
|
||||
<!-- Error state -->
|
||||
<div v-if="prepStore.qaError" class="error-state" role="alert">
|
||||
{{ prepStore.qaError }}
|
||||
</div>
|
||||
|
||||
<!-- Chat history -->
|
||||
<div ref="qaChatEl" class="qa-chat">
|
||||
<div
|
||||
v-for="(item, idx) in qaMessages"
|
||||
:key="idx"
|
||||
class="qa-exchange"
|
||||
>
|
||||
<div class="qa-question">
|
||||
<span class="qa-label">You</span>
|
||||
<p class="qa-text">{{ item.question }}</p>
|
||||
</div>
|
||||
<div class="qa-answer">
|
||||
<span class="qa-label">Coach</span>
|
||||
<MarkdownView :content="item.answer" class="qa-text" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="qaMessages.length === 0 && !prepStore.qaError" class="qa-empty">
|
||||
<span class="empty-bird">🦅</span>
|
||||
<p>Ask a practice question to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<div class="qa-input-row">
|
||||
<textarea
|
||||
v-model="qaInput"
|
||||
class="qa-textarea"
|
||||
placeholder="Ask a practice question…"
|
||||
rows="2"
|
||||
:disabled="qaSubmitting"
|
||||
@keydown.enter.exact.prevent="onAskQuestion"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn-primary qa-ask-btn"
|
||||
:disabled="!qaInput.trim() || qaSubmitting"
|
||||
@click="onAskQuestion"
|
||||
>
|
||||
{{ qaSubmitting ? '…' : 'Ask' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="qaError" class="error-state qa-error" role="alert">{{ qaError }}</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Call notes ── -->
|
||||
<section class="call-notes" aria-label="Call notes">
|
||||
<h2 class="call-notes-title">Call Notes</h2>
|
||||
|
|
@ -762,9 +975,6 @@ async function onGenerate() {
|
|||
|
||||
.section-body {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
|
|
@ -866,9 +1076,6 @@ async function onGenerate() {
|
|||
|
||||
.jd-body {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
@ -921,9 +1128,6 @@ async function onGenerate() {
|
|||
/* Cover letter tab */
|
||||
.letter-body {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── Call notes ──────────────────────────────────────────────────────────── */
|
||||
|
|
@ -971,4 +1175,227 @@ async function onGenerate() {
|
|||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Practice Q&A tab ────────────────────────────────────────────────────── */
|
||||
.tab-panel--qa {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.qa-chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 55vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.qa-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.qa-empty p {
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qa-exchange {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.qa-question,
|
||||
.qa-answer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.qa-question {
|
||||
background: color-mix(in srgb, var(--app-primary) 8%, var(--color-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--app-primary) 20%, transparent);
|
||||
align-self: flex-end;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.qa-answer {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
align-self: flex-start;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.qa-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.qa-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.qa-input-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.qa-textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.qa-textarea::placeholder { color: var(--color-text-muted); }
|
||||
.qa-textarea:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
.qa-textarea:disabled { opacity: 0.6; }
|
||||
|
||||
.qa-ask-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.qa-error {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ── Log Contact form ────────────────────────────────────────────────────── */
|
||||
.log-contact-form {
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.log-contact-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.log-contact-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.log-contact-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.log-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.log-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.log-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.log-input,
|
||||
.log-select {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.log-input::placeholder { color: var(--color-text-muted); }
|
||||
.log-input:focus-visible,
|
||||
.log-select:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
.log-textarea {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.log-textarea::placeholder { color: var(--color-text-muted); }
|
||||
.log-textarea:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
.log-error {
|
||||
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
|
||||
color: var(--color-error);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.log-success {
|
||||
background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface));
|
||||
color: var(--color-success);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 25%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.log-submit {
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -309,6 +309,40 @@ function daysSince(dateStr: string | null) {
|
|||
if (!dateStr) return null
|
||||
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000)
|
||||
}
|
||||
|
||||
// ── Rejected analytics section ─────────────────────────────────────────────────
|
||||
const rejectedExpanded = ref(false)
|
||||
|
||||
const REJECTION_STAGES = ['applied', 'phone_screen', 'interviewing', 'offer'] as const
|
||||
type RejectionStage = typeof REJECTION_STAGES[number]
|
||||
|
||||
const REJECTION_STAGE_LABELS: Record<RejectionStage, string> = {
|
||||
applied: 'Applied',
|
||||
phone_screen: 'Phone Screen',
|
||||
interviewing: 'Interviewing',
|
||||
offer: 'Offer',
|
||||
}
|
||||
|
||||
const rejectedByStage = computed(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const job of store.rejected) {
|
||||
const stage = job.rejection_stage ?? 'applied'
|
||||
counts[stage] = (counts[stage] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
function formatRejectionDate(job: PipelineJob): string {
|
||||
// Use the most recent stage timestamp as rejection date
|
||||
const candidates = [job.offer_at, job.interviewing_at, job.phone_screen_at, job.applied_at]
|
||||
for (const ts of candidates) {
|
||||
if (ts) {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -519,26 +553,56 @@ function daysSince(dateStr: string | null) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rejected accordion -->
|
||||
<details class="rejected-accordion" v-if="store.rejected.length > 0">
|
||||
<summary class="rejected-summary">
|
||||
✗ Rejected ({{ store.rejected.length }})
|
||||
<span class="rejected-hint">— expand for details</span>
|
||||
</summary>
|
||||
<div class="rejected-body">
|
||||
<div class="rejected-stats">
|
||||
<div class="stat-chip">
|
||||
<span class="stat-num">{{ store.rejected.length }}</span>
|
||||
<span class="stat-lbl">Total</span>
|
||||
<!-- Rejected analytics section -->
|
||||
<section v-if="store.rejected.length > 0" class="rejected-section" aria-label="Rejected jobs">
|
||||
<button
|
||||
class="rejected-toggle"
|
||||
:aria-expanded="rejectedExpanded"
|
||||
aria-controls="rejected-body"
|
||||
@click="rejectedExpanded = !rejectedExpanded"
|
||||
>
|
||||
<span class="rejected-chevron" :class="{ 'is-expanded': rejectedExpanded }">▸</span>
|
||||
<span class="rejected-toggle-label">Rejected ({{ store.rejected.length }})</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="rejected-body"
|
||||
class="rejected-body"
|
||||
:class="{ 'is-expanded': rejectedExpanded }"
|
||||
>
|
||||
<!-- Stage breakdown stats bar -->
|
||||
<div class="rejected-stats-bar" role="list" aria-label="Rejections by stage">
|
||||
<div
|
||||
v-for="stage in REJECTION_STAGES"
|
||||
:key="stage"
|
||||
class="rejected-stat-chip"
|
||||
:class="{ 'rejected-stat-chip--active': (rejectedByStage[stage] ?? 0) > 0 }"
|
||||
role="listitem"
|
||||
>
|
||||
<span class="rejected-stat-num">{{ rejectedByStage[stage] ?? 0 }}</span>
|
||||
<span class="rejected-stat-lbl">{{ REJECTION_STAGE_LABELS[stage] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="job in store.rejected" :key="job.id" class="rejected-row">
|
||||
<span class="rejected-title">{{ job.title }} — {{ job.company }}</span>
|
||||
<span class="rejected-stage">{{ job.rejection_stage ?? 'No response' }}</span>
|
||||
<button class="btn-unrej" @click="openMove(job.id)">Move →</button>
|
||||
|
||||
<!-- Flat list of rejected jobs -->
|
||||
<div class="rejected-list">
|
||||
<div v-for="job in store.rejected" :key="job.id" class="rejected-row">
|
||||
<div class="rejected-row-info">
|
||||
<span class="rejected-job-title">{{ job.title }}</span>
|
||||
<span class="rejected-job-company">{{ job.company }}</span>
|
||||
</div>
|
||||
<div class="rejected-row-meta">
|
||||
<span
|
||||
class="rejected-stage-badge"
|
||||
:class="`rejected-stage-badge--${job.rejection_stage ?? 'applied'}`"
|
||||
>{{ REJECTION_STAGE_LABELS[job.rejection_stage as RejectionStage] ?? job.rejection_stage ?? 'Applied' }}</span>
|
||||
<span class="rejected-date">{{ formatRejectionDate(job) }}</span>
|
||||
<button class="btn-unrej" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<MoveToSheet
|
||||
v-if="moveTarget"
|
||||
|
|
@ -561,8 +625,8 @@ function daysSince(dateStr: string | null) {
|
|||
|
||||
<style scoped>
|
||||
.interviews-view {
|
||||
padding: var(--space-4) var(--space-4) var(--space-12);
|
||||
max-width: 1100px; margin: 0 auto; position: relative;
|
||||
padding: var(--space-4) var(--space-6) var(--space-12);
|
||||
max-width: 1400px; margin: 0 auto; position: relative;
|
||||
}
|
||||
.confetti-canvas { position: fixed; inset: 0; z-index: 300; pointer-events: none; display: none; }
|
||||
.hired-toast {
|
||||
|
|
@ -704,14 +768,17 @@ function daysSince(dateStr: string | null) {
|
|||
}
|
||||
|
||||
.kanban {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4); margin-bottom: var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(280px, 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
@media (max-width: 720px) { .kanban { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 768px) { .kanban { grid-template-columns: 1fr; } }
|
||||
.kanban-col {
|
||||
background: var(--color-surface); border-radius: 10px;
|
||||
padding: var(--space-3); display: flex; flex-direction: column; gap: var(--space-3);
|
||||
padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3);
|
||||
transition: box-shadow 150ms;
|
||||
min-height: 200px;
|
||||
}
|
||||
.kanban-col--focused { box-shadow: 0 0 0 2px var(--color-primary); }
|
||||
.col-header {
|
||||
|
|
@ -727,24 +794,230 @@ function daysSince(dateStr: string | null) {
|
|||
.empty-bird-float { font-size: 1.75rem; animation: float 3s ease-in-out infinite; }
|
||||
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
|
||||
.empty-msg { font-size: 0.8rem; color: var(--color-text-muted); line-height: 1.5; }
|
||||
.rejected-accordion { border: 1px solid var(--color-border-light); border-radius: 10px; overflow: hidden; }
|
||||
.rejected-summary {
|
||||
list-style: none; padding: var(--space-3) var(--space-4);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface));
|
||||
cursor: pointer; font-weight: 700; font-size: 0.85rem; color: var(--color-error);
|
||||
display: flex; align-items: center; gap: var(--space-2);
|
||||
/* Rejected analytics section */
|
||||
.rejected-section {
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 25%, var(--color-border-light));
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.rejected-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-error);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rejected-toggle:hover {
|
||||
background: color-mix(in srgb, var(--color-error) 12%, var(--color-surface));
|
||||
}
|
||||
|
||||
.rejected-chevron {
|
||||
font-size: 0.75em;
|
||||
display: inline-block;
|
||||
transition: transform 200ms;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.rejected-chevron.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rejected-toggle-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rejected-body {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 300ms ease;
|
||||
}
|
||||
|
||||
.rejected-body.is-expanded {
|
||||
max-height: 2000px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rejected-body, .rejected-chevron { transition: none; }
|
||||
}
|
||||
|
||||
/* Stats bar */
|
||||
.rejected-stats-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4) 0;
|
||||
background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised));
|
||||
}
|
||||
|
||||
.rejected-stat-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
min-width: 72px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.rejected-stat-chip--active {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--color-error) 40%, var(--color-border-light));
|
||||
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface-raised));
|
||||
}
|
||||
|
||||
.rejected-stat-num {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-error);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.rejected-stat-lbl {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Flat rejected list */
|
||||
.rejected-list {
|
||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||
background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.rejected-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-left: 3px solid color-mix(in srgb, var(--color-error) 60%, transparent);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rejected-row-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rejected-job-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rejected-job-company {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.rejected-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rejected-stage-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
border-radius: 99px;
|
||||
padding: 2px 8px;
|
||||
background: color-mix(in srgb, var(--color-error) 14%, var(--color-surface-raised));
|
||||
color: var(--color-error);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Tone down badges for earlier stages */
|
||||
.rejected-stage-badge--applied {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 10%, var(--color-surface-raised));
|
||||
color: var(--color-text-muted);
|
||||
border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
.rejected-stage-badge--phone_screen {
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface-raised));
|
||||
color: var(--color-warning);
|
||||
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
|
||||
}
|
||||
|
||||
.rejected-stage-badge--interviewing {
|
||||
background: color-mix(in srgb, var(--color-info) 12%, var(--color-surface-raised));
|
||||
color: var(--color-info);
|
||||
border-color: color-mix(in srgb, var(--color-info) 30%, transparent);
|
||||
}
|
||||
|
||||
.rejected-stage-badge--offer {
|
||||
background: color-mix(in srgb, var(--color-error) 14%, var(--color-surface-raised));
|
||||
color: var(--color-error);
|
||||
border-color: color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
}
|
||||
|
||||
.rejected-date {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-unrej {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-info);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-unrej:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.rejected-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.rejected-row-meta {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.rejected-stats-bar {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.rejected-stat-chip {
|
||||
min-width: 60px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
}
|
||||
.rejected-summary::-webkit-details-marker { display: none; }
|
||||
.rejected-hint { font-weight: 400; color: var(--color-text-muted); font-size: 0.75rem; }
|
||||
.rejected-body { padding: var(--space-3) var(--space-4); background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised)); display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.rejected-stats { display: flex; gap: var(--space-3); margin-bottom: var(--space-2); }
|
||||
.stat-chip { background: var(--color-surface-raised); border-radius: 6px; padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border-light); text-align: center; }
|
||||
.stat-num { display: block; font-size: 1.25rem; font-weight: 700; color: var(--color-error); }
|
||||
.stat-lbl { font-size: 0.7rem; color: var(--color-text-muted); }
|
||||
.rejected-row { display: flex; align-items: center; gap: var(--space-3); background: var(--color-surface-raised); border-radius: 6px; padding: var(--space-2) var(--space-3); border-left: 3px solid var(--color-error); }
|
||||
.rejected-title { flex: 1; font-weight: 600; font-size: 0.875rem; }
|
||||
.rejected-stage { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.btn-unrej { background: none; border: 1px solid var(--color-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-info); cursor: pointer; }
|
||||
.empty-bird { font-size: 1.25rem; }
|
||||
.pre-list-pagination {
|
||||
display: flex; align-items: center; justify-content: center; gap: var(--space-2);
|
||||
|
|
|
|||
|
|
@ -278,8 +278,9 @@ onMounted(loadList)
|
|||
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
|
||||
font-family: monospace; font-size: var(--font-sm, 0.875rem); resize: vertical;
|
||||
background: var(--color-surface-alt, #f8fafc);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.rv__textarea:not([readonly]) { background: var(--color-surface, #fff); }
|
||||
.rv__textarea:not([readonly]) { background: var(--color-surface); }
|
||||
.rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
|
||||
.rv__error { color: var(--color-error, #dc2626); font-size: var(--font-sm, 0.875rem); }
|
||||
|
||||
|
|
@ -299,6 +300,39 @@ onMounted(loadList)
|
|||
|
||||
.rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--font-sm, 0.875rem); }
|
||||
|
||||
/* Button styles — defined locally since no global button sheet exists yet */
|
||||
.btn-secondary {
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-generate {
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1, 0.25rem);
|
||||
}
|
||||
.btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rv__layout { grid-template-columns: 1fr; }
|
||||
.rv__list { max-height: 200px; }
|
||||
|
|
|
|||
|
|
@ -454,11 +454,14 @@ function toggleHistoryEntry(id: number) {
|
|||
border: 2px dashed var(--color-border, #e2e8f0);
|
||||
margin: var(--space-4);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.screenshot-zone:focus {
|
||||
border-color: var(--color-accent, #3182ce);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.screenshot-zone:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
|
|
|
|||
264
web/src/views/settings/ConnectionsSettingsView.vue
Normal file
264
web/src/views/settings/ConnectionsSettingsView.vue
Normal 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>
|
||||
|
|
@ -196,30 +196,54 @@
|
|||
<section class="form-section">
|
||||
<h3>Skills & Keywords</h3>
|
||||
<div class="tag-section">
|
||||
<label>Skills</label>
|
||||
<div class="tag-section-header">
|
||||
<label>Skills</label>
|
||||
<button @click="store.suggestTags('skills')" :disabled="store.suggestingField === 'skills'" class="btn-suggest">
|
||||
{{ store.suggestingField === 'skills' ? 'Thinking…' : '✦ Suggest' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span v-for="skill in store.skills" :key="skill" class="tag">
|
||||
{{ skill }} <button @click="store.removeTag('skills', skill)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="store.skillSuggestions.length > 0" class="suggestions">
|
||||
<span v-for="s in store.skillSuggestions" :key="s" class="suggestion-chip" @click="store.acceptTagSuggestion('skills', s)" title="Click to add">+ {{ s }}</span>
|
||||
</div>
|
||||
<input v-model="skillInput" @keydown.enter.prevent="store.addTag('skills', skillInput); skillInput = ''" placeholder="Add skill, press Enter" />
|
||||
</div>
|
||||
<div class="tag-section">
|
||||
<label>Domains</label>
|
||||
<div class="tag-section-header">
|
||||
<label>Domains</label>
|
||||
<button @click="store.suggestTags('domains')" :disabled="store.suggestingField === 'domains'" class="btn-suggest">
|
||||
{{ store.suggestingField === 'domains' ? 'Thinking…' : '✦ Suggest' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span v-for="domain in store.domains" :key="domain" class="tag">
|
||||
{{ domain }} <button @click="store.removeTag('domains', domain)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="store.domainSuggestions.length > 0" class="suggestions">
|
||||
<span v-for="s in store.domainSuggestions" :key="s" class="suggestion-chip" @click="store.acceptTagSuggestion('domains', s)" title="Click to add">+ {{ s }}</span>
|
||||
</div>
|
||||
<input v-model="domainInput" @keydown.enter.prevent="store.addTag('domains', domainInput); domainInput = ''" placeholder="Add domain, press Enter" />
|
||||
</div>
|
||||
<div class="tag-section">
|
||||
<label>Keywords</label>
|
||||
<div class="tag-section-header">
|
||||
<label>Keywords</label>
|
||||
<button @click="store.suggestTags('keywords')" :disabled="store.suggestingField === 'keywords'" class="btn-suggest">
|
||||
{{ store.suggestingField === 'keywords' ? 'Thinking…' : '✦ Suggest' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span v-for="kw in store.keywords" :key="kw" class="tag">
|
||||
{{ kw }} <button @click="store.removeTag('keywords', kw)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="store.keywordSuggestions.length > 0" class="suggestions">
|
||||
<span v-for="s in store.keywordSuggestions" :key="s" class="suggestion-chip" @click="store.acceptTagSuggestion('keywords', s)" title="Click to add">+ {{ s }}</span>
|
||||
</div>
|
||||
<input v-model="kwInput" @keydown.enter.prevent="store.addTag('keywords', kwInput); kwInput = ''" placeholder="Add keyword, press Enter" />
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -304,45 +328,83 @@ async function handleUpload() {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
|
||||
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
|
||||
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
|
||||
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
|
||||
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--space-3, 16px); }
|
||||
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
|
||||
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6); }
|
||||
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
|
||||
.form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); }
|
||||
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--space-3); }
|
||||
.field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
|
||||
.field-row input, .field-row textarea, .field-row select {
|
||||
background: var(--color-surface-2, rgba(255,255,255,0.05));
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-primary, #e2e8f0);
|
||||
color: var(--color-text);
|
||||
padding: 7px 10px;
|
||||
font-size: 0.88rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sync-label { font-size: 0.72rem; color: var(--color-accent, #7c3aed); margin-left: 6px; }
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
|
||||
.experience-card { border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: var(--space-4, 24px); margin-bottom: var(--space-4, 24px); }
|
||||
.remove-btn { margin-top: 8px; padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); cursor: pointer; font-size: 0.82rem; }
|
||||
.empty-state { text-align: center; padding: var(--space-8, 48px) 0; }
|
||||
.empty-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4, 24px); margin-top: var(--space-6, 32px); }
|
||||
.empty-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 10px; padding: var(--space-4, 24px); text-align: left; }
|
||||
.sync-label { font-size: 0.72rem; color: var(--color-accent); margin-left: 6px; }
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 0.88rem; color: var(--color-text); cursor: pointer; }
|
||||
.experience-card { border: 1px solid var(--color-border); border-radius: 8px; padding: var(--space-4); margin-bottom: var(--space-4); }
|
||||
.remove-btn {
|
||||
margin-top: 8px; padding: 4px 12px; border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-error) 15%, transparent);
|
||||
color: var(--color-error);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
cursor: pointer; font-size: 0.82rem;
|
||||
}
|
||||
.empty-state { text-align: center; padding: var(--space-8) 0; }
|
||||
.empty-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4); margin-top: var(--space-6); }
|
||||
.empty-card { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 10px; padding: var(--space-4); text-align: left; }
|
||||
.empty-card h3 { margin-bottom: 8px; }
|
||||
.empty-card p { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
|
||||
.empty-card button, .empty-card a { padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; text-decoration: none; display: inline-block; background: var(--color-accent, #7c3aed); color: #fff; border: none; }
|
||||
.tag-section { margin-bottom: var(--space-4, 24px); }
|
||||
.tag-section label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
|
||||
.empty-card p { font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 16px; }
|
||||
.empty-card button, .empty-card a { padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; text-decoration: none; display: inline-block; background: var(--color-accent); color: var(--color-text-inverse); border: none; }
|
||||
.tag-section { margin-bottom: var(--space-4); }
|
||||
.tag-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.tag-section-header label { font-size: 0.82rem; color: var(--color-text-muted); margin: 0; }
|
||||
.tag-section label { font-size: 0.82rem; color: var(--color-text-muted); display: block; margin-bottom: 6px; }
|
||||
.btn-suggest {
|
||||
padding: 4px 12px; border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||
color: var(--color-accent); cursor: pointer; font-size: 0.78rem; white-space: nowrap; transition: background 0.15s;
|
||||
}
|
||||
.btn-suggest:hover:not(:disabled) { background: color-mix(in srgb, var(--color-accent) 28%, transparent); }
|
||||
.btn-suggest:disabled { opacity: 0.55; cursor: default; }
|
||||
.suggestions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.suggestion-chip {
|
||||
padding: 3px 10px; border-radius: 12px; font-size: 0.78rem;
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px dashed var(--color-border);
|
||||
color: var(--color-text-muted); cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.suggestion-chip:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
|
||||
.tag {
|
||||
padding: 3px 10px;
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
border-radius: 12px; font-size: 0.78rem; color: var(--color-accent);
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.tag-section input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 6px 10px; font-size: 0.85rem; width: 100%; box-sizing: border-box; }
|
||||
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
|
||||
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||
.tag-section input { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 6px; color: var(--color-text); padding: 6px 10px; font-size: 0.85rem; width: 100%; box-sizing: border-box; }
|
||||
.form-actions { margin-top: var(--space-6); display: flex; align-items: center; gap: var(--space-4); }
|
||||
.btn-primary { padding: 9px 24px; background: var(--color-accent); color: var(--color-text-inverse); border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.error { color: #ef4444; font-size: 0.82rem; }
|
||||
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; font-size: 0.85rem; padding: 10px 14px; margin-bottom: var(--space-4, 24px); }
|
||||
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
|
||||
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
|
||||
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
|
||||
.replace-section { background: var(--color-surface-2, rgba(255,255,255,0.03)); border-radius: 8px; padding: var(--space-4, 24px); }
|
||||
.error { color: var(--color-error); font-size: 0.82rem; }
|
||||
.error-banner {
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
border-radius: 6px; color: var(--color-error); font-size: 0.85rem; padding: 10px 14px; margin-bottom: var(--space-4);
|
||||
}
|
||||
.section-note { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 16px; }
|
||||
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border); border-radius: 4px; color: var(--color-text-muted); cursor: pointer; font-size: 0.78rem; }
|
||||
.loading { text-align: center; padding: var(--space-8); color: var(--color-text-muted); }
|
||||
.replace-section { background: var(--color-surface-alt); border-radius: 8px; padding: var(--space-4); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -86,10 +86,16 @@
|
|||
<!-- Job Boards -->
|
||||
<section class="form-section">
|
||||
<h3>Job Boards</h3>
|
||||
<div v-for="board in store.job_boards" :key="board.name" class="board-row">
|
||||
<div v-for="board in store.job_boards" :key="board.name" class="board-row" :class="{ 'board-row--unsupported': board.supported === false }">
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" :checked="board.enabled" @change="store.toggleBoard(board.name)" />
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="board.enabled"
|
||||
:disabled="board.supported === false"
|
||||
@change="store.toggleBoard(board.name)"
|
||||
/>
|
||||
{{ board.name }}
|
||||
<span v-if="board.supported === false" class="board-badge board-badge--pending" title="Not yet implemented — tracked in backlog">coming soon</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top: 12px">
|
||||
|
|
@ -179,37 +185,78 @@ onMounted(() => store.load())
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
|
||||
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
|
||||
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
|
||||
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
|
||||
.search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
|
||||
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6); }
|
||||
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
|
||||
.form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); }
|
||||
.remote-options { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.remote-btn { padding: 8px 18px; border-radius: 6px; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); background: transparent; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; transition: all 0.15s; }
|
||||
.remote-btn.active { background: var(--color-accent, #7c3aed); border-color: var(--color-accent, #7c3aed); color: #fff; }
|
||||
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-top: 8px; }
|
||||
.remote-btn { padding: 8px 18px; border-radius: 6px; border: 1px solid var(--color-border); background: transparent; color: var(--color-text-muted); cursor: pointer; font-size: 0.88rem; transition: all 0.15s; }
|
||||
.remote-btn.active { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-text-inverse); }
|
||||
.section-note { font-size: 0.78rem; color: var(--color-text-muted); margin-top: 8px; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
|
||||
.tag {
|
||||
padding: 3px 10px;
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
border-radius: 12px; font-size: 0.78rem; color: var(--color-accent);
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.tag-input-row { display: flex; gap: 8px; }
|
||||
.tag-input-row input, input[type="text"], input:not([type]) {
|
||||
background: var(--color-surface-2, rgba(255,255,255,0.05));
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
|
||||
border-radius: 6px; color: var(--color-text-primary, #e2e8f0);
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px; color: var(--color-text);
|
||||
padding: 7px 10px; font-size: 0.85rem; flex: 1; box-sizing: border-box;
|
||||
}
|
||||
.btn-suggest { padding: 7px 14px; border-radius: 6px; background: rgba(124,58,237,0.2); border: 1px solid rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); cursor: pointer; font-size: 0.82rem; white-space: nowrap; }
|
||||
.btn-suggest {
|
||||
padding: 7px 14px; border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
color: var(--color-accent); cursor: pointer; font-size: 0.82rem; white-space: nowrap;
|
||||
}
|
||||
.suggestions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.suggestion-chip { padding: 4px 12px; border-radius: 12px; font-size: 0.78rem; background: rgba(255,255,255,0.05); border: 1px dashed rgba(255,255,255,0.2); color: var(--color-text-secondary, #94a3b8); cursor: pointer; transition: all 0.15s; }
|
||||
.suggestion-chip:hover { background: rgba(124,58,237,0.15); border-color: rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); }
|
||||
.suggestion-chip {
|
||||
padding: 4px 12px; border-radius: 12px; font-size: 0.78rem;
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px dashed var(--color-border);
|
||||
color: var(--color-text-muted); cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.suggestion-chip:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.board-row { margin-bottom: 8px; }
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
|
||||
.board-row--unsupported { opacity: 0.5; }
|
||||
.board-row--unsupported input[type="checkbox"] { cursor: not-allowed; }
|
||||
|
||||
.board-badge {
|
||||
display: inline-block;
|
||||
margin-left: var(--space-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.board-badge--pending {
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--color-text); cursor: pointer; }
|
||||
.field-row { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||
.blocklist-group { margin-bottom: var(--space-4, 24px); }
|
||||
.blocklist-group label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
|
||||
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
|
||||
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||
.field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
|
||||
.blocklist-group { margin-bottom: var(--space-4); }
|
||||
.blocklist-group label { font-size: 0.82rem; color: var(--color-text-muted); display: block; margin-bottom: 6px; }
|
||||
.form-actions { margin-top: var(--space-6); display: flex; align-items: center; gap: var(--space-4); }
|
||||
.btn-primary { padding: 9px 24px; background: var(--color-accent); color: var(--color-text-inverse); border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
|
||||
.error { color: #ef4444; font-size: 0.82rem; }
|
||||
.error-banner {
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
border-radius: 6px; color: var(--color-error); padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem;
|
||||
}
|
||||
.error { color: var(--color-error); font-size: 0.82rem; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const allGroups = [
|
|||
{ key: 'search', path: '/settings/search', label: 'Search Prefs', show: true },
|
||||
]},
|
||||
{ label: 'App', items: [
|
||||
{ key: 'connections', path: '/settings/connections', label: 'Connections', show: true },
|
||||
{ key: 'system', path: '/settings/system', label: 'System', show: showSystem },
|
||||
{ key: 'fine-tune', path: '/settings/fine-tune', label: 'Fine-Tune', show: showFineTune },
|
||||
]},
|
||||
|
|
|
|||
|
|
@ -44,6 +44,30 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Custom cover letter model (paid+, cloud) -->
|
||||
<section v-if="config.isCloud && meetsRequiredTier('paid')" class="form-section">
|
||||
<h3>Custom Cover Letter Model</h3>
|
||||
<p class="section-note">
|
||||
Select your fine-tuned Ollama model for cover letter generation.
|
||||
Leave blank to use the cloud default.
|
||||
</p>
|
||||
<div class="field-row">
|
||||
<label>Model</label>
|
||||
<select v-model="coverLetterModel" class="field-select">
|
||||
<option value="">(cloud default)</option>
|
||||
<option v-for="m in ollamaModels" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
<button @click="saveCoverLetterModel" :disabled="clmSaving" class="btn-save-inline">
|
||||
{{ clmSaving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="clmError" class="error">{{ clmError }}</p>
|
||||
<p v-if="clmSaved" class="success">Saved.</p>
|
||||
<p v-if="ollamaModels.length === 0" class="section-note">
|
||||
No Ollama models found — make sure Ollama is running and has models pulled.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Services section -->
|
||||
<section class="form-section">
|
||||
<h3>Services</h3>
|
||||
|
|
@ -65,97 +89,6 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Email section -->
|
||||
<section class="form-section">
|
||||
<h3>Email (IMAP)</h3>
|
||||
<p class="section-note">Used for email sync in the Interviews pipeline.</p>
|
||||
<div class="field-row">
|
||||
<label>IMAP Host</label>
|
||||
<input v-model="(store.emailConfig as any).host" placeholder="imap.gmail.com" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Port</label>
|
||||
<input v-model.number="(store.emailConfig as any).port" type="number" placeholder="993" />
|
||||
</div>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" v-model="(store.emailConfig as any).ssl" /> Use SSL
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label>Username</label>
|
||||
<input v-model="(store.emailConfig as any).username" type="email" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Password / App Password</label>
|
||||
<input
|
||||
v-model="emailPasswordInput"
|
||||
type="password"
|
||||
:placeholder="(store.emailConfig as any).password_set ? '••••••• (saved — enter new to change)' : 'Password'"
|
||||
/>
|
||||
<span class="field-hint">Gmail: use an App Password. Tip: type ${ENV_VAR_NAME} to use an environment variable.</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Sent Folder</label>
|
||||
<input v-model="(store.emailConfig as any).sent_folder" placeholder="[Gmail]/Sent Mail" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Lookback Days</label>
|
||||
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button @click="handleSaveEmail()" :disabled="store.emailSaving" class="btn-primary">
|
||||
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
|
||||
</button>
|
||||
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
|
||||
<span v-if="emailTestResult !== null" :class="emailTestResult ? 'test-ok' : 'test-fail'">
|
||||
{{ emailTestResult ? '✓ Connected' : '✗ Failed' }}
|
||||
</span>
|
||||
<p v-if="store.emailError" class="error">{{ store.emailError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Integrations -->
|
||||
<section class="form-section">
|
||||
<h3>Integrations</h3>
|
||||
<div v-if="store.integrations.length === 0" class="empty-note">No integrations registered.</div>
|
||||
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
|
||||
<div class="integration-header">
|
||||
<span class="integration-name">{{ integration.name }}</span>
|
||||
<div class="integration-badges">
|
||||
<span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
|
||||
Requires {{ integration.tier_required }}
|
||||
</span>
|
||||
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
|
||||
{{ integration.connected ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Locked state for insufficient tier -->
|
||||
<div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
|
||||
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
|
||||
</div>
|
||||
<!-- Normal state for sufficient tier -->
|
||||
<template v-else>
|
||||
<div v-if="!integration.connected" class="integration-form">
|
||||
<div v-for="field in integration.fields" :key="field.key" class="field-row">
|
||||
<label>{{ field.label }}</label>
|
||||
<input v-model="integrationInputs[integration.id + ':' + field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button @click="handleConnect(integration.id)" class="btn-primary">Connect</button>
|
||||
<button @click="handleTest(integration.id)" class="btn-secondary">Test</button>
|
||||
<span v-if="store.integrationResults[integration.id]" :class="store.integrationResults[integration.id].ok ? 'test-ok' : 'test-fail'">
|
||||
{{ store.integrationResults[integration.id].ok ? '✓ OK' : '✗ ' + store.integrationResults[integration.id].error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<button @click="store.disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- File Paths -->
|
||||
<section class="form-section">
|
||||
<h3>File Paths</h3>
|
||||
|
|
@ -239,6 +172,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||
import { storeToRefs } from 'pinia'
|
||||
import { useSystemStore } from '../../stores/settings/system'
|
||||
import { useAppConfigStore } from '../../stores/appConfig'
|
||||
import { useApiFetch } from '../../composables/useApi'
|
||||
|
||||
const store = useSystemStore()
|
||||
const config = useAppConfigStore()
|
||||
|
|
@ -287,108 +221,158 @@ async function handleConfirmByok() {
|
|||
byokConfirmed.value = false
|
||||
}
|
||||
|
||||
const emailTestResult = ref<boolean | null>(null)
|
||||
const emailPasswordInput = ref('')
|
||||
const integrationInputs = ref<Record<string, string>>({})
|
||||
async function handleTestEmail() {
|
||||
const result = await store.testEmail()
|
||||
emailTestResult.value = result?.ok ?? false
|
||||
// ── Custom cover letter model ─────────────────────────────────────────────────
|
||||
const coverLetterModel = ref('')
|
||||
const ollamaModels = ref<string[]>([])
|
||||
const clmSaving = ref(false)
|
||||
const clmError = ref<string | null>(null)
|
||||
const clmSaved = ref(false)
|
||||
|
||||
async function loadCoverLetterModel() {
|
||||
const { data } = await useApiFetch<{ model: string }>('/api/settings/llm/cover-letter-model')
|
||||
if (data) coverLetterModel.value = data.model ?? ''
|
||||
const { data: mData } = await useApiFetch<{ models: string[] }>('/api/settings/llm/ollama-models')
|
||||
if (mData) ollamaModels.value = mData.models ?? []
|
||||
}
|
||||
|
||||
async function handleSaveEmail() {
|
||||
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
|
||||
await store.saveEmailWithPassword(payload)
|
||||
}
|
||||
|
||||
async function handleConnect(id: string) {
|
||||
const integration = store.integrations.find(i => i.id === id)
|
||||
if (!integration) return
|
||||
const credentials: Record<string, string> = {}
|
||||
for (const field of integration.fields) {
|
||||
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
||||
}
|
||||
await store.connectIntegration(id, credentials)
|
||||
}
|
||||
|
||||
async function handleTest(id: string) {
|
||||
const integration = store.integrations.find(i => i.id === id)
|
||||
if (!integration) return
|
||||
const credentials: Record<string, string> = {}
|
||||
for (const field of integration.fields) {
|
||||
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
||||
}
|
||||
await store.testIntegration(id, credentials)
|
||||
async function saveCoverLetterModel() {
|
||||
clmSaving.value = true
|
||||
clmError.value = null
|
||||
clmSaved.value = false
|
||||
const { error } = await useApiFetch('/api/settings/llm/cover-letter-model', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: coverLetterModel.value }),
|
||||
})
|
||||
clmSaving.value = false
|
||||
if (error) { clmError.value = 'Failed to save model.'; return }
|
||||
clmSaved.value = true
|
||||
setTimeout(() => { clmSaved.value = false }, 3000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadLlm()
|
||||
await Promise.all([
|
||||
const tasks = [
|
||||
store.loadServices(),
|
||||
store.loadEmail(),
|
||||
store.loadIntegrations(),
|
||||
store.loadFilePaths(),
|
||||
store.loadDeployConfig(),
|
||||
])
|
||||
]
|
||||
if (config.isCloud && tierOrder.indexOf(tier.value) >= tierOrder.indexOf('paid')) {
|
||||
tasks.push(loadCoverLetterModel())
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
|
||||
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; color: var(--color-text-primary, #e2e8f0); }
|
||||
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
|
||||
.tab-note { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: var(--space-6, 32px); }
|
||||
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
|
||||
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 14px; }
|
||||
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4); }
|
||||
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; }
|
||||
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
|
||||
.tab-note { font-size: 0.82rem; color: var(--color-text-muted); margin-bottom: var(--space-6); }
|
||||
.form-section { margin-bottom: var(--space-8); padding-bottom: var(--space-6); border-bottom: 1px solid var(--color-border); }
|
||||
.section-note { font-size: 0.78rem; color: var(--color-text-muted); margin-bottom: 14px; }
|
||||
.backend-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
|
||||
.backend-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; cursor: grab; user-select: none; }
|
||||
.backend-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 8px; cursor: grab; user-select: none; }
|
||||
.backend-card:active { cursor: grabbing; }
|
||||
.drag-handle { font-size: 1.1rem; color: var(--color-text-secondary, #64748b); }
|
||||
.priority-badge { width: 22px; height: 22px; border-radius: 50%; background: rgba(124,58,237,0.2); color: var(--color-accent, #a78bfa); font-size: 0.72rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.backend-id { flex: 1; font-size: 0.9rem; font-family: monospace; color: var(--color-text-primary, #e2e8f0); }
|
||||
.toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||
.form-actions { display: flex; align-items: center; gap: var(--space-4, 24px); }
|
||||
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||
.drag-handle { font-size: 1.1rem; color: var(--color-text-muted); }
|
||||
.priority-badge { width: 22px; height: 22px; border-radius: 50%; background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent); font-size: 0.72rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.backend-id { flex: 1; font-size: 0.9rem; font-family: monospace; color: var(--color-text); }
|
||||
.toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--color-text-muted); }
|
||||
.form-actions { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.btn-primary { padding: 9px 24px; background: var(--color-accent); color: var(--color-text-inverse); border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-cancel { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.9rem; }
|
||||
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
|
||||
.error { color: #ef4444; font-size: 0.82rem; }
|
||||
.btn-cancel { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-text-muted); cursor: pointer; font-size: 0.9rem; }
|
||||
.error-banner {
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
border-radius: 6px; color: var(--color-error); padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem;
|
||||
}
|
||||
.error { color: var(--color-error); font-size: 0.82rem; }
|
||||
/* BYOK Modal */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||
.modal-card { background: var(--color-surface-1, #1e293b); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
|
||||
.modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; color: var(--color-text-primary, #e2e8f0); }
|
||||
.modal-card p { font-size: 0.88rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 12px; }
|
||||
.modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
|
||||
.byok-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; padding: 10px 12px; color: #fbbf24 !important; }
|
||||
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; margin: 16px 0; }
|
||||
.modal-card { background: var(--color-surface-raised); border: 1px solid var(--color-border); border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
|
||||
.modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; }
|
||||
.modal-card p { font-size: 0.88rem; color: var(--color-text-muted); margin-bottom: 12px; }
|
||||
.modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text); }
|
||||
.byok-warning {
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
|
||||
border-radius: 6px; padding: 10px 12px; color: var(--color-warning) !important;
|
||||
}
|
||||
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text); cursor: pointer; margin: 16px 0; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.service-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 14px; }
|
||||
.service-card { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 8px; padding: 14px; }
|
||||
.service-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.service-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot-running { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
|
||||
.dot-stopped { background: #64748b; }
|
||||
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
|
||||
.service-port { font-size: 0.75rem; color: var(--color-text-secondary, #64748b); font-family: monospace; }
|
||||
.service-note { font-size: 0.75rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 10px; }
|
||||
.dot-running { background: var(--color-success); box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 50%, transparent); }
|
||||
.dot-stopped { background: var(--color-text-muted); }
|
||||
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text); }
|
||||
.service-port { font-size: 0.75rem; color: var(--color-text-muted); font-family: monospace; }
|
||||
.service-note { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 10px; }
|
||||
.service-actions { display: flex; gap: 6px; }
|
||||
.btn-start { padding: 4px 12px; border-radius: 4px; background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); cursor: pointer; font-size: 0.78rem; }
|
||||
.btn-stop { padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2); cursor: pointer; font-size: 0.78rem; }
|
||||
.btn-start {
|
||||
padding: 4px 12px; border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent);
|
||||
cursor: pointer; font-size: 0.78rem;
|
||||
}
|
||||
.btn-stop {
|
||||
padding: 4px 12px; border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
color: var(--color-error);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
cursor: pointer; font-size: 0.78rem;
|
||||
}
|
||||
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
|
||||
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||
.field-row input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 7px 10px; font-size: 0.88rem; }
|
||||
.field-hint { font-size: 0.72rem; color: var(--color-text-secondary, #64748b); margin-top: 3px; }
|
||||
.btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; }
|
||||
.btn-danger { padding: 6px 14px; border-radius: 6px; background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.25); cursor: pointer; font-size: 0.82rem; }
|
||||
.test-ok { color: #22c55e; font-size: 0.85rem; }
|
||||
.test-fail { color: #ef4444; font-size: 0.85rem; }
|
||||
.integration-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
.integration-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.integration-name { font-weight: 600; font-size: 0.9rem; color: var(--color-text-primary, #e2e8f0); }
|
||||
.status-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; }
|
||||
.badge-connected { background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
|
||||
.badge-disconnected { background: rgba(100,116,139,0.15); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); }
|
||||
.empty-note { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); padding: 16px 0; }
|
||||
.tier-badge { font-size: 0.68rem; padding: 2px 7px; border-radius: 8px; background: rgba(245,158,11,0.15); color: #f59e0b; border: 1px solid rgba(245,158,11,0.3); margin-right: 6px; }
|
||||
.tier-locked { padding: 12px 0; font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); }
|
||||
.integration-badges { display: flex; align-items: center; gap: 4px; }
|
||||
.field-row label { font-size: 0.82rem; color: var(--color-text-muted); }
|
||||
.field-row input { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: 6px; color: var(--color-text); padding: 7px 10px; font-size: 0.88rem; }
|
||||
.field-hint { font-size: 0.72rem; color: var(--color-text-muted); margin-top: 3px; }
|
||||
.btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-text-muted); cursor: pointer; font-size: 0.88rem; }
|
||||
.btn-danger {
|
||||
padding: 6px 14px; border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
color: var(--color-error);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
|
||||
cursor: pointer; font-size: 0.82rem;
|
||||
}
|
||||
.test-ok { color: var(--color-success); font-size: 0.85rem; }
|
||||
.test-fail { color: var(--color-error); font-size: 0.85rem; }
|
||||
|
||||
.field-select {
|
||||
flex: 1;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.field-select:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
.btn-save-inline {
|
||||
background: var(--app-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-save-inline:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-save-inline:hover:not(:disabled) { background: var(--app-primary-hover); }
|
||||
|
||||
.success {
|
||||
color: var(--color-success);
|
||||
font-size: var(--text-sm);
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@
|
|||
Peregrine uses your hardware profile to choose the right inference setup.
|
||||
</p>
|
||||
|
||||
<div v-if="wizard.loading" class="step__info">Detecting hardware…</div>
|
||||
<div v-if="detecting" class="step__info">Detecting hardware…</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="wizard.hardware.gpus.length" class="step__success">
|
||||
✅ Detected {{ wizard.hardware.gpus.length }} GPU(s):
|
||||
✅ Detected {{ wizard.hardware.gpus.length }} local GPU(s):
|
||||
{{ wizard.hardware.gpus.join(', ') }}
|
||||
</div>
|
||||
<div v-else class="step__info">
|
||||
No NVIDIA GPUs detected. "Remote" or "CPU" mode recommended.
|
||||
No local NVIDIA GPUs detected. "Remote", "CPU", or "cf-orch" mode recommended.
|
||||
</div>
|
||||
|
||||
<div class="step__field">
|
||||
|
|
@ -23,15 +23,59 @@
|
|||
<option value="cpu">CPU — local Ollama, no GPU</option>
|
||||
<option value="single-gpu">Single GPU — local Ollama + one GPU</option>
|
||||
<option value="dual-gpu">Dual GPU — local Ollama + two GPUs</option>
|
||||
<option value="cf-orch">
|
||||
cf-orch — CircuitForge GPU cluster
|
||||
{{ orchAvailable ? `(${orchGpus.length} GPU(s) available)` : '(configure endpoint below)' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- cf-orch cluster summary -->
|
||||
<template v-if="selectedProfile === 'cf-orch'">
|
||||
<div v-if="orchAvailable" class="step__orch-nodes">
|
||||
<p class="step__orch-label">Available nodes:</p>
|
||||
<div
|
||||
v-for="gpu in orchGpus"
|
||||
:key="`${gpu.node}-${gpu.name}`"
|
||||
class="step__orch-row"
|
||||
>
|
||||
<span class="step__orch-node">{{ gpu.node }}</span>
|
||||
<span class="step__orch-name">{{ gpu.name }}</span>
|
||||
<span class="step__orch-vram">
|
||||
{{ Math.round(gpu.vram_free_mb / 1024 * 10) / 10 }} /
|
||||
{{ Math.round(gpu.vram_total_mb / 1024 * 10) / 10 }} GB free
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step__field">
|
||||
<label class="step__label" for="orch-url">cf-orch coordinator URL</label>
|
||||
<input
|
||||
id="orch-url"
|
||||
v-model="orchUrl"
|
||||
type="url"
|
||||
class="step__input"
|
||||
placeholder="http://10.1.10.71:7700"
|
||||
/>
|
||||
<p class="step__field-hint">
|
||||
The coordinator serves public inference endpoints for paid+ users.
|
||||
Leave blank to use the default cluster URL from Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="step__tier-note">
|
||||
<span aria-hidden="true">🔒</span>
|
||||
cf-orch inference requires a <strong>Paid</strong> license or higher.
|
||||
You can select this profile now; it will activate once your license is verified.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="selectedProfile !== 'remote' && !wizard.hardware.gpus.length"
|
||||
v-else-if="selectedProfile !== 'remote' && !wizard.hardware.gpus.length"
|
||||
class="step__warning"
|
||||
>
|
||||
⚠️ No GPUs detected — a GPU profile may not work. Choose CPU or Remote
|
||||
if you don't have a local NVIDIA GPU.
|
||||
⚠️ No local GPUs detected — a GPU profile may not work. Choose CPU, Remote,
|
||||
or cf-orch if you have access to the cluster.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -47,17 +91,52 @@
|
|||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useWizardStore } from '../../stores/wizard'
|
||||
import { useApiFetch } from '../../composables/useApi'
|
||||
import './wizard.css'
|
||||
|
||||
const wizard = useWizardStore()
|
||||
const router = useRouter()
|
||||
const selectedProfile = ref(wizard.hardware.selectedProfile)
|
||||
|
||||
onMounted(() => wizard.detectHardware())
|
||||
// Local loading flag — does NOT touch wizard.loading, avoiding the
|
||||
// WizardLayout unmount loop that was causing the infinite spinner.
|
||||
const detecting = ref(false)
|
||||
|
||||
// cf-orch cluster state
|
||||
const orchAvailable = ref(false)
|
||||
const orchGpus = ref<Array<{ node: string; name: string; vram_total_mb: number; vram_free_mb: number }>>([])
|
||||
const orchUrl = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
detecting.value = true
|
||||
const { data } = await useApiFetch<{
|
||||
gpus: string[]
|
||||
suggested_profile: string
|
||||
profiles: string[]
|
||||
cf_orch_available: boolean
|
||||
cf_orch_gpus: Array<{ node: string; name: string; vram_total_mb: number; vram_free_mb: number }>
|
||||
}>('/api/wizard/hardware')
|
||||
detecting.value = false
|
||||
if (!data) return
|
||||
|
||||
wizard.hardware.gpus = data.gpus
|
||||
wizard.hardware.suggestedProfile = data.suggested_profile as typeof wizard.hardware.suggestedProfile
|
||||
if (!wizard.hardware.selectedProfile || wizard.hardware.selectedProfile === 'remote') {
|
||||
wizard.hardware.selectedProfile = data.suggested_profile as typeof wizard.hardware.selectedProfile
|
||||
selectedProfile.value = wizard.hardware.selectedProfile
|
||||
}
|
||||
|
||||
orchAvailable.value = data.cf_orch_available ?? false
|
||||
orchGpus.value = data.cf_orch_gpus ?? []
|
||||
})
|
||||
|
||||
async function next() {
|
||||
wizard.hardware.selectedProfile = selectedProfile.value
|
||||
const ok = await wizard.saveStep(1, { inference_profile: selectedProfile.value })
|
||||
wizard.hardware.selectedProfile = selectedProfile.value as typeof wizard.hardware.selectedProfile
|
||||
const stepData: Record<string, unknown> = { inference_profile: selectedProfile.value }
|
||||
if (selectedProfile.value === 'cf-orch' && orchUrl.value) {
|
||||
stepData.cf_orch_url = orchUrl.value
|
||||
}
|
||||
const ok = await wizard.saveStep(1, stepData)
|
||||
if (ok) router.push('/setup/tier')
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
<!-- Step content -->
|
||||
<div class="wizard__body">
|
||||
<div v-if="wizard.loading" class="wizard__loading" aria-live="polite">
|
||||
<div v-if="!layoutReady" class="wizard__loading" aria-live="polite">
|
||||
<span class="wizard__spinner" aria-hidden="true" />
|
||||
Loading…
|
||||
</div>
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useWizardStore } from '../../stores/wizard'
|
||||
import { useAppConfigStore } from '../../stores/appConfig'
|
||||
|
|
@ -56,9 +56,15 @@ const router = useRouter()
|
|||
// Peregrine logo — served from the static assets directory
|
||||
const logoSrc = '/static/peregrine_logo_circle.png'
|
||||
|
||||
// layoutReady gates the RouterView — separate from wizard.loading so child
|
||||
// steps that do their own async work (detectHardware, etc.) don't unmount
|
||||
// themselves by setting the shared loading flag.
|
||||
const layoutReady = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!config.loaded) await config.load()
|
||||
const target = await wizard.loadStatus(config.isCloud)
|
||||
layoutReady.value = true
|
||||
if (router.currentRoute.value.path === '/setup') {
|
||||
router.replace(target)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -327,3 +327,66 @@
|
|||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── cf-orch hardware step ─────────────────────────────────────── */
|
||||
|
||||
.step__orch-nodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1, 0.25rem);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.step__orch-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.step__orch-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 1fr auto;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.875rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step__orch-node {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.step__orch-name {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.step__orch-vram {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step__field-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.step__tier-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary, #6366f1) 25%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue