From b06d596d4c52f0044ecfb5517ab46a1686b34014 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 2 Apr 2026 17:41:35 -0700 Subject: [PATCH] feat(vue): open Vue SPA to all tiers; fix cloud nav and feedback button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lower vue_ui_beta gate to "free" so all licensed users can access the new UI without a paid subscription - Remove "Paid tier" wording from the Try New UI banner - Fix Vue SPA navigation in cloud/demo deployments: add VITE_BASE_PATH build arg so Vite sets the correct subpath base, and pass import.meta.env.BASE_URL to createWebHistory() so router links emit /peregrine/... paths that Caddy can match - Fix feedback button missing on cloud instance by passing FORGEJO_API_TOKEN through compose.cloud.yml - Remove vLLM container from compose.yml (vLLM dropped from stack; cf-research service in cfcore covers the use case) - Fix cloud config path in Apply page (use get_config_dir() so per-user cloud data roots resolve correctly for user.yaml and resume YAML) - Refactor generate_cover_letter._build_system_context and _build_mission_notes to accept explicit profile arg (enables per-user cover letter generation in cloud multi-tenant mode) - Add API proxy block to nginx.conf (Vue web container can now call /api/ directly without Vite dev proxy) - Update .env.example: remove vLLM vars, add research model + tuning vars for external vLLM deployments - Update llm.yaml: switch vllm base_url to host.docker.internal (vLLM now runs outside Docker stack) Closes #63 (feedback button) Related: #8 (Vue SPA), #50–#62 (parity milestone) --- .env.example | 7 +++- app/components/ui_switcher.py | 2 +- app/pages/4_Apply.py | 18 ++++----- app/wizard/tiers.py | 4 +- compose.cloud.yml | 3 ++ compose.demo.yml | 2 + compose.yml | 19 +-------- config/llm.yaml | 5 +-- docker/web/Dockerfile | 2 + docker/web/nginx.conf | 15 ++++++-- scripts/generate_cover_letter.py | 66 +++++++++++++++++++++++--------- scripts/task_runner.py | 5 +++ web/src/router/index.ts | 2 +- web/vite.config.ts | 1 + 14 files changed, 93 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index 85223ab..9763220 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,11 @@ VISION_REVISION=2025-01-09 DOCS_DIR=~/Documents/JobSearch OLLAMA_MODELS_DIR=~/models/ollama -VLLM_MODELS_DIR=~/models/vllm -VLLM_MODEL=Ouro-1.4B +VLLM_MODELS_DIR=~/models/vllm # override with full path to your model dir +VLLM_MODEL=Ouro-1.4B # cover letters — fast 1.4B model +VLLM_RESEARCH_MODEL=Ouro-2.6B-Thinking # research — reasoning 2.6B model; restart vllm to switch +VLLM_MAX_MODEL_LEN=4096 # increase to 8192 for Thinking models with long CoT +VLLM_GPU_MEM_UTIL=0.75 # lower to 0.6 if sharing GPU with other services OLLAMA_DEFAULT_MODEL=llama3.2:3b # API keys (required for remote profile) diff --git a/app/components/ui_switcher.py b/app/components/ui_switcher.py index 8526e6b..33ed955 100644 --- a/app/components/ui_switcher.py +++ b/app/components/ui_switcher.py @@ -200,7 +200,7 @@ def render_banner(yaml_path: Path, tier: str) -> None: col1, col2, col3 = st.columns([8, 1, 1]) with col1: - st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta, Paid tier)") + st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta)") with col2: if st.button("Try it", key="_ui_banner_try"): switch_ui(yaml_path, to="vue", tier=tier) diff --git a/app/pages/4_Apply.py b/app/pages/4_Apply.py index 1e9a3d1..c51f9ba 100644 --- a/app/pages/4_Apply.py +++ b/app/pages/4_Apply.py @@ -15,28 +15,28 @@ import streamlit.components.v1 as components import yaml from scripts.user_profile import UserProfile - -_USER_YAML = Path(__file__).parent.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 ( DEFAULT_DB, init_db, get_jobs_by_status, update_cover_letter, mark_applied, update_job_status, get_task_for_job, ) from scripts.task_runner import submit_task -from app.cloud_session import resolve_session, get_db_path +from app.cloud_session import resolve_session, get_db_path, get_config_dir from app.telemetry import log_usage_event -DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch" -RESUME_YAML = Path(__file__).parent.parent.parent / "config" / "plain_text_resume.yaml" - st.title("🚀 Apply Workspace") 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" + +DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch" +RESUME_YAML = _CONFIG_DIR / "plain_text_resume.yaml" + # ── PDF generation ───────────────────────────────────────────────────────────── def _make_cover_letter_pdf(job: dict, cover_letter: str, output_dir: Path) -> Path: from reportlab.lib.pagesizes import letter diff --git a/app/wizard/tiers.py b/app/wizard/tiers.py index 4a97707..2b04ab9 100644 --- a/app/wizard/tiers.py +++ b/app/wizard/tiers.py @@ -64,8 +64,8 @@ FEATURES: dict[str, str] = { "apple_calendar_sync": "paid", "slack_notifications": "paid", - # Beta UI access — stays gated (access management, not compute) - "vue_ui_beta": "paid", + # Beta UI access — open to all tiers (access management, not compute) + "vue_ui_beta": "free", } # Features that unlock when the user supplies any LLM backend (local or BYOK). diff --git a/compose.cloud.yml b/compose.cloud.yml index 417a6c6..c42c15f 100644 --- a/compose.cloud.yml +++ b/compose.cloud.yml @@ -37,6 +37,7 @@ services: - PEREGRINE_CADDY_PROXY=1 - CF_ORCH_URL=http://host.docker.internal:7700 - DEMO_MODE=false + - FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-} depends_on: searxng: condition: service_healthy @@ -48,6 +49,8 @@ services: build: context: . dockerfile: docker/web/Dockerfile + args: + VITE_BASE_PATH: /peregrine/ ports: - "8508:80" restart: unless-stopped diff --git a/compose.demo.yml b/compose.demo.yml index db1b9f6..c6296c3 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -42,6 +42,8 @@ services: build: context: . dockerfile: docker/web/Dockerfile + args: + VITE_BASE_PATH: /peregrine/ ports: - "8507:80" restart: unless-stopped diff --git a/compose.yml b/compose.yml index 0c06bdb..cc82471 100644 --- a/compose.yml +++ b/compose.yml @@ -1,5 +1,5 @@ # compose.yml — Peregrine by Circuit Forge LLC -# Profiles: remote | cpu | single-gpu | dual-gpu-ollama | dual-gpu-vllm | dual-gpu-mixed +# Profiles: remote | cpu | single-gpu | dual-gpu-ollama services: app: @@ -129,23 +129,6 @@ services: profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed] restart: unless-stopped - vllm: - image: vllm/vllm-openai:latest - ports: - - "${VLLM_PORT:-8000}:8000" - volumes: - - ${VLLM_MODELS_DIR:-~/models/vllm}:/models - command: > - --model /models/${VLLM_MODEL:-Ouro-1.4B} - --trust-remote-code - --max-model-len 4096 - --gpu-memory-utilization 0.75 - --enforce-eager - --max-num-seqs 8 - --cpu-offload-gb ${CPU_OFFLOAD_GB:-0} - profiles: [dual-gpu-vllm, dual-gpu-mixed] - restart: unless-stopped - finetune: build: context: . diff --git a/config/llm.yaml b/config/llm.yaml index d033a2e..485b6a2 100644 --- a/config/llm.yaml +++ b/config/llm.yaml @@ -40,7 +40,7 @@ backends: type: vision_service vllm: api_key: '' - base_url: http://vllm:8000/v1 + base_url: http://host.docker.internal:8000/v1 enabled: true model: __auto__ supports_images: false @@ -49,11 +49,10 @@ backends: service: vllm model_candidates: - Qwen2.5-3B-Instruct - - Phi-4-mini-instruct ttl_s: 300 vllm_research: api_key: '' - base_url: http://vllm:8000/v1 + base_url: http://host.docker.internal:8000/v1 enabled: true model: __auto__ supports_images: false diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index de50164..a2e4119 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -4,6 +4,8 @@ WORKDIR /app COPY web/package*.json ./ RUN npm ci --prefer-offline COPY web/ ./ +ARG VITE_BASE_PATH=/ +ENV VITE_BASE_PATH=${VITE_BASE_PATH} RUN npm run build # Stage 2: serve diff --git a/docker/web/nginx.conf b/docker/web/nginx.conf index dcbcbb6..35b52b8 100644 --- a/docker/web/nginx.conf +++ b/docker/web/nginx.conf @@ -5,9 +5,13 @@ server { root /usr/share/nginx/html; index index.html; - # SPA fallback - location / { - try_files $uri $uri/ /index.html; + # Proxy API calls to the FastAPI backend service + location /api/ { + proxy_pass http://api:8601; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 120s; } # Cache static assets @@ -15,4 +19,9 @@ server { expires 1y; add_header Cache-Control "public, immutable"; } + + # SPA fallback — must come after API and assets + location / { + try_files $uri $uri/ /index.html; + } } diff --git a/scripts/generate_cover_letter.py b/scripts/generate_cover_letter.py index e55c36e..3067bdb 100644 --- a/scripts/generate_cover_letter.py +++ b/scripts/generate_cover_letter.py @@ -26,13 +26,14 @@ LETTERS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "Jo LETTER_GLOB = "*Cover Letter*.md" # Background injected into every prompt so the model has the candidate's facts -def _build_system_context() -> str: - if not _profile: +def _build_system_context(profile=None) -> str: + p = profile or _profile + if not p: return "You are a professional cover letter writer. Write in first person." - parts = [f"You are writing cover letters for {_profile.name}. {_profile.career_summary}"] - if _profile.candidate_voice: + parts = [f"You are writing cover letters for {p.name}. {p.career_summary}"] + if p.candidate_voice: parts.append( - f"Voice and personality: {_profile.candidate_voice} " + f"Voice and personality: {p.candidate_voice} " "Write in a way that reflects these authentic traits — not as a checklist, " "but as a natural expression of who this person is." ) @@ -125,15 +126,17 @@ _MISSION_DEFAULTS: dict[str, str] = { } -def _build_mission_notes() -> dict[str, str]: +def _build_mission_notes(profile=None, candidate_name: str | None = None) -> dict[str, str]: """Merge user's custom mission notes with generic defaults.""" - prefs = _profile.mission_preferences if _profile else {} + p = profile or _profile + name = candidate_name or _candidate + prefs = p.mission_preferences if p else {} notes = {} for industry, default_note in _MISSION_DEFAULTS.items(): custom = (prefs.get(industry) or "").strip() if custom: notes[industry] = ( - f"Mission alignment — {_candidate} shared: \"{custom}\". " + f"Mission alignment — {name} shared: \"{custom}\". " "Para 3 should warmly and specifically reflect this authentic connection." ) else: @@ -144,12 +147,15 @@ def _build_mission_notes() -> dict[str, str]: _MISSION_NOTES = _build_mission_notes() -def detect_mission_alignment(company: str, description: str) -> str | None: +def detect_mission_alignment( + company: str, description: str, mission_notes: dict | None = None +) -> str | None: """Return a mission hint string if company/JD matches a preferred industry, else None.""" + notes = mission_notes if mission_notes is not None else _MISSION_NOTES text = f"{company} {description}".lower() for industry, signals in _MISSION_SIGNALS.items(): if any(sig in text for sig in signals): - return _MISSION_NOTES[industry] + return notes[industry] return None @@ -190,10 +196,14 @@ def build_prompt( examples: list[dict], mission_hint: str | None = None, is_jobgether: bool = False, + system_context: str | None = None, + candidate_name: str | None = None, ) -> str: - parts = [SYSTEM_CONTEXT.strip(), ""] + ctx = system_context if system_context is not None else SYSTEM_CONTEXT + name = candidate_name or _candidate + parts = [ctx.strip(), ""] if examples: - parts.append(f"=== STYLE EXAMPLES ({_candidate}'s past letters) ===\n") + parts.append(f"=== STYLE EXAMPLES ({name}'s past letters) ===\n") for i, ex in enumerate(examples, 1): parts.append(f"--- Example {i} ({ex['company']}) ---") parts.append(ex["text"]) @@ -231,13 +241,14 @@ def build_prompt( return "\n".join(parts) -def _trim_to_letter_end(text: str) -> str: +def _trim_to_letter_end(text: str, profile=None) -> str: """Remove repetitive hallucinated content after the first complete sign-off. Fine-tuned models sometimes loop after completing the letter. This cuts at the first closing + candidate name so only the intended letter is saved. """ - candidate_first = (_profile.name.split()[0] if _profile else "").strip() + p = profile or _profile + candidate_first = (p.name.split()[0] if p else "").strip() pattern = ( r'(?:Warm regards|Sincerely|Best regards|Kind regards|Thank you)[,.]?\s*\n+\s*' + (re.escape(candidate_first) if candidate_first else r'\w+(?:\s+\w+)?') @@ -257,6 +268,8 @@ def generate( feedback: str = "", is_jobgether: bool = False, _router=None, + config_path: "Path | None" = None, + user_yaml_path: "Path | None" = None, ) -> str: """Generate a cover letter and return it as a string. @@ -264,15 +277,29 @@ def generate( and requested changes are appended to the prompt so the LLM revises rather than starting from scratch. + user_yaml_path overrides the module-level profile — required in cloud mode + so each user's name/voice/mission prefs are used instead of the global default. + _router is an optional pre-built LLMRouter (used in tests to avoid real LLM calls). """ + # Per-call profile override (cloud mode: each user has their own user.yaml) + if user_yaml_path and Path(user_yaml_path).exists(): + _prof = UserProfile(Path(user_yaml_path)) + else: + _prof = _profile + + sys_ctx = _build_system_context(_prof) + mission_notes = _build_mission_notes(_prof, candidate_name=(_prof.name if _prof else None)) + candidate_name = _prof.name if _prof else _candidate + corpus = load_corpus() examples = find_similar_letters(description or f"{title} {company}", corpus) - mission_hint = detect_mission_alignment(company, description) + mission_hint = detect_mission_alignment(company, description, mission_notes=mission_notes) if mission_hint: print(f"[cover-letter] Mission alignment detected for {company}", file=sys.stderr) prompt = build_prompt(title, company, description, examples, - mission_hint=mission_hint, is_jobgether=is_jobgether) + mission_hint=mission_hint, is_jobgether=is_jobgether, + system_context=sys_ctx, candidate_name=candidate_name) if previous_result: prompt += f"\n\n---\nPrevious draft:\n{previous_result}" @@ -281,8 +308,9 @@ def generate( if _router is None: sys.path.insert(0, str(Path(__file__).parent.parent)) - from scripts.llm_router import LLMRouter - _router = LLMRouter() + from scripts.llm_router import LLMRouter, CONFIG_PATH + resolved = config_path if (config_path and Path(config_path).exists()) else CONFIG_PATH + _router = LLMRouter(resolved) print(f"[cover-letter] Generating for: {title} @ {company}", file=sys.stderr) print(f"[cover-letter] Style examples: {[e['company'] for e in examples]}", file=sys.stderr) @@ -292,7 +320,7 @@ def generate( # max_tokens=1200 caps generation at ~900 words — enough for any cover letter # and prevents fine-tuned models from looping into repetitive garbage output. result = _router.complete(prompt, max_tokens=1200) - return _trim_to_letter_end(result) + return _trim_to_letter_end(result, _prof) def main() -> None: diff --git a/scripts/task_runner.py b/scripts/task_runner.py index ea2a652..b728dcc 100644 --- a/scripts/task_runner.py +++ b/scripts/task_runner.py @@ -179,6 +179,9 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int, import json as _json p = _json.loads(params or "{}") from scripts.generate_cover_letter import generate + _cfg_dir = Path(db_path).parent / "config" + _user_llm_cfg = _cfg_dir / "llm.yaml" + _user_yaml = _cfg_dir / "user.yaml" result = generate( job.get("title", ""), job.get("company", ""), @@ -186,6 +189,8 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int, previous_result=p.get("previous_result", ""), feedback=p.get("feedback", ""), is_jobgether=job.get("source") == "jobgether", + config_path=_user_llm_cfg, + user_yaml_path=_user_yaml, ) update_cover_letter(db_path, job_id, result) diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 6607169..7bb9812 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -3,7 +3,7 @@ import { useAppConfigStore } from '../stores/appConfig' import { settingsGuard } from './settingsGuard' export const router = createRouter({ - history: createWebHistory(), + history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', component: () => import('../views/HomeView.vue') }, { path: '/review', component: () => import('../views/JobReviewView.vue') }, diff --git a/web/vite.config.ts b/web/vite.config.ts index ceef245..31e45c4 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue' import UnoCSS from 'unocss/vite' export default defineConfig({ + base: process.env.VITE_BASE_PATH || '/', plugins: [vue(), UnoCSS()], server: { host: '0.0.0.0',