From db1b9c2dcc9763cd5bd54ad7891d811860c10762 Mon Sep 17 00:00:00 2001
From: "railway-app[bot]" <68434857+railway-app[bot]@users.noreply.github.com>
Date: Fri, 3 Apr 2026 17:10:42 +0000
Subject: [PATCH 01/12] fix: upgrade Levenshtein to >=0.26.0 for Python 3.13
compatibility
---
requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index 76214ed75..f17411596 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -14,7 +14,7 @@ langchain-ollama==0.1.3
langchain-openai==0.1.17
langchain-text-splitters==0.2.2
langsmith==0.1.93
-Levenshtein==0.25.1
+Levenshtein>=0.26.0
loguru==0.7.2
openai==1.37.1
pdfminer.six==20221105
From 8d781681f26df3fe0ad3b47b116adc4610bda6cf Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 17:51:19 +0000
Subject: [PATCH 02/12] feat: add web UI with FastAPI, async doc generation,
WebSocket progress, and Railway deployment
Agent-Logs-Url: https://github.com/emirsaffar-collab/Jobs_Applier_AI_Agent_AIHawk/sessions/fb01ed93-4579-4ff1-9531-a9dc19bca942
Co-authored-by: emirsaffar-collab <79217569+emirsaffar-collab@users.noreply.github.com>
---
Dockerfile | 30 ++
Procfile | 1 +
config.py | 17 +-
main.py | 14 +-
railway.json | 14 +
requirements.txt | 6 +-
src/web/__init__.py | 0
src/web/app.py | 497 +++++++++++++++++++++
src/web/ui.py | 1013 +++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1584 insertions(+), 8 deletions(-)
create mode 100644 Dockerfile
create mode 100644 Procfile
create mode 100644 railway.json
create mode 100644 src/web/__init__.py
create mode 100644 src/web/app.py
create mode 100644 src/web/ui.py
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..a89f485e5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,30 @@
+FROM python:3.11-slim
+
+# Install Chrome for PDF generation (optional, falls back to reportlab)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ wget \
+ gnupg2 \
+ && wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \
+ && echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends google-chrome-stable \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy requirements first for caching
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY . .
+
+# Create data directories
+RUN mkdir -p data_folder/output log
+
+# Expose port (Railway sets PORT env var)
+EXPOSE 8080
+
+# Run the web server
+CMD ["python", "main.py", "web"]
diff --git a/Procfile b/Procfile
new file mode 100644
index 000000000..af0db04e5
--- /dev/null
+++ b/Procfile
@@ -0,0 +1 @@
+web: python main.py web
diff --git a/config.py b/config.py
index 78d53e0cd..12bf5a48f 100644
--- a/config.py
+++ b/config.py
@@ -1,12 +1,13 @@
# In this file, you can set the configurations of the app.
+import os
from src.utils.constants import DEBUG, ERROR, LLM_MODEL, OPENAI
#config related to logging must have prefix LOG_
-LOG_LEVEL = 'ERROR'
+LOG_LEVEL = os.environ.get('LOG_LEVEL', 'ERROR')
LOG_SELENIUM_LEVEL = ERROR
-LOG_TO_FILE = False
-LOG_TO_CONSOLE = False
+LOG_TO_FILE = os.environ.get('LOG_TO_FILE', 'false').lower() == 'true'
+LOG_TO_CONSOLE = os.environ.get('LOG_TO_CONSOLE', 'false').lower() == 'true'
MINIMUM_WAIT_TIME_IN_SECONDS = 60
@@ -16,7 +17,11 @@
JOB_MAX_APPLICATIONS = 5
JOB_MIN_APPLICATIONS = 1
-LLM_MODEL_TYPE = 'openai'
-LLM_MODEL = 'gpt-4o-mini'
+LLM_MODEL_TYPE = os.environ.get('LLM_MODEL_TYPE', 'claude')
+LLM_MODEL = os.environ.get('LLM_MODEL', 'claude-sonnet-4-20250514')
# Only required for OLLAMA models
-LLM_API_URL = ''
+LLM_API_URL = os.environ.get('LLM_API_URL', '')
+
+# Web server configuration
+WEB_HOST = os.environ.get('WEB_HOST', '0.0.0.0')
+WEB_PORT = int(os.environ.get('PORT', os.environ.get('WEB_PORT', '8080')))
diff --git a/main.py b/main.py
index cb89b6222..a2570fd8f 100644
--- a/main.py
+++ b/main.py
@@ -562,4 +562,16 @@ def main():
if __name__ == "__main__":
- main()
+ import sys
+
+ if len(sys.argv) > 1 and sys.argv[1] == "web":
+ # Web server mode
+ import uvicorn
+ import config as cfg
+ from src.web.app import app
+
+ print(f"Starting AIHawk web server on {cfg.WEB_HOST}:{cfg.WEB_PORT}")
+ uvicorn.run(app, host=cfg.WEB_HOST, port=cfg.WEB_PORT)
+ else:
+ # CLI mode (original behavior)
+ main()
diff --git a/railway.json b/railway.json
new file mode 100644
index 000000000..c582b9ffe
--- /dev/null
+++ b/railway.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://railway.app/railway.schema.json",
+ "build": {
+ "builder": "DOCKERFILE",
+ "dockerfilePath": "Dockerfile"
+ },
+ "deploy": {
+ "startCommand": "python main.py web",
+ "healthcheckPath": "/api/health",
+ "healthcheckTimeout": 30,
+ "restartPolicyType": "ON_FAILURE",
+ "restartPolicyMaxRetries": 10
+ }
+}
diff --git a/requirements.txt b/requirements.txt
index f17411596..26c3d9ae5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -28,4 +28,8 @@ webdriver-manager==4.0.2
pytest
pytest-mock
pytest-cov
-undetected-chromedriver==3.5.5
\ No newline at end of file
+undetected-chromedriver==3.5.5
+fastapi>=0.110.0
+uvicorn[standard]>=0.24.0
+websockets>=12.0
+inquirer
\ No newline at end of file
diff --git a/src/web/__init__.py b/src/web/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/web/app.py b/src/web/app.py
new file mode 100644
index 000000000..b77275279
--- /dev/null
+++ b/src/web/app.py
@@ -0,0 +1,497 @@
+"""
+FastAPI web server for AIHawk Resume & Cover Letter Builder.
+Provides a web UI with async document generation and WebSocket progress updates.
+"""
+import asyncio
+import base64
+import hashlib
+import os
+import uuid
+from pathlib import Path
+from typing import Optional
+
+import yaml
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
+from fastapi.responses import HTMLResponse, Response
+from pydantic import BaseModel
+
+from src.logging import logger
+
+app = FastAPI(title="AIHawk Resume Builder", version="1.0.0")
+
+# In-memory job store for generated documents
+_jobs: dict = {}
+
+
+class GenerateRequest(BaseModel):
+ """Request model for document generation."""
+ action: str # "resume", "resume_tailored", "cover_letter"
+ resume_yaml: str
+ job_url: Optional[str] = None
+ style: Optional[str] = None
+ llm_api_key: str
+ llm_model_type: str = "claude"
+ llm_model: str = "claude-sonnet-4-20250514"
+
+
+class JobStatus(BaseModel):
+ """Status of a generation job."""
+ job_id: str
+ status: str # "pending", "running", "completed", "failed"
+ progress: int # 0-100
+ message: str
+ download_url: Optional[str] = None
+ error: Optional[str] = None
+
+
+# WebSocket connection manager
+class ConnectionManager:
+ """Manages WebSocket connections for real-time progress updates."""
+
+ def __init__(self):
+ self.active_connections: dict[str, list[WebSocket]] = {}
+
+ async def connect(self, websocket: WebSocket, job_id: str):
+ await websocket.accept()
+ if job_id not in self.active_connections:
+ self.active_connections[job_id] = []
+ self.active_connections[job_id].append(websocket)
+
+ def disconnect(self, websocket: WebSocket, job_id: str):
+ if job_id in self.active_connections:
+ self.active_connections[job_id] = [
+ ws for ws in self.active_connections[job_id] if ws != websocket
+ ]
+ if not self.active_connections[job_id]:
+ del self.active_connections[job_id]
+
+ async def send_progress(self, job_id: str, data: dict):
+ if job_id in self.active_connections:
+ disconnected = []
+ for ws in self.active_connections[job_id]:
+ try:
+ await ws.send_json(data)
+ except Exception:
+ disconnected.append(ws)
+ for ws in disconnected:
+ self.disconnect(ws, job_id)
+
+
+manager = ConnectionManager()
+
+
+def _get_available_styles() -> dict:
+ """Get available resume styles from the styles directory."""
+ from src.libs.resume_and_cover_builder.style_manager import StyleManager
+ sm = StyleManager()
+ return sm.get_styles()
+
+
+def _validate_resume_yaml(yaml_str: str) -> bool:
+ """Validate that the YAML string is a valid resume."""
+ try:
+ data = yaml.safe_load(yaml_str)
+ if not isinstance(data, dict):
+ return False
+ if "personal_information" not in data:
+ return False
+ return True
+ except yaml.YAMLError:
+ return False
+
+
+async def _run_generation(job_id: str, request: GenerateRequest):
+ """Run document generation in a background thread with progress updates."""
+ import config as cfg
+
+ try:
+ # Update config with user-selected LLM
+ cfg.LLM_MODEL_TYPE = request.llm_model_type
+ cfg.LLM_MODEL = request.llm_model
+
+ _jobs[job_id]["status"] = "running"
+ await manager.send_progress(job_id, {
+ "status": "running", "progress": 5, "message": "Initializing..."
+ })
+
+ # Validate resume YAML
+ if not _validate_resume_yaml(request.resume_yaml):
+ raise ValueError("Invalid resume YAML. Must contain at least 'personal_information' section.")
+
+ await manager.send_progress(job_id, {
+ "status": "running", "progress": 10, "message": "Loading resume data..."
+ })
+
+ # Import here to avoid circular imports
+ from src.libs.resume_and_cover_builder import ResumeFacade, ResumeGenerator, StyleManager
+ from src.resume_schemas.resume import Resume
+
+ # Parse resume
+ resume_object = Resume(request.resume_yaml)
+
+ await manager.send_progress(job_id, {
+ "status": "running", "progress": 15, "message": "Setting up style..."
+ })
+
+ # Setup style
+ style_manager = StyleManager()
+ available_styles = style_manager.get_styles()
+ if request.style and request.style in available_styles:
+ style_manager.set_selected_style(request.style)
+ elif available_styles:
+ # Default to first available style
+ first_style = next(iter(available_styles))
+ style_manager.set_selected_style(first_style)
+ else:
+ raise ValueError("No resume styles available.")
+
+ await manager.send_progress(job_id, {
+ "status": "running", "progress": 20, "message": "Initializing resume generator..."
+ })
+
+ # Setup generator
+ resume_generator = ResumeGenerator()
+ resume_generator.set_resume_object(resume_object)
+
+ output_path = Path("data_folder/output")
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ resume_facade = ResumeFacade(
+ api_key=request.llm_api_key,
+ style_manager=style_manager,
+ resume_generator=resume_generator,
+ resume_object=resume_object,
+ output_path=output_path,
+ )
+
+ if request.action in ("resume_tailored", "cover_letter"):
+ if not request.job_url:
+ raise ValueError("Job URL is required for tailored documents.")
+
+ await manager.send_progress(job_id, {
+ "status": "running", "progress": 25, "message": "Fetching job description..."
+ })
+
+ # Fetch job description using httpx instead of Selenium
+ result_base64 = await asyncio.to_thread(
+ _generate_with_job_url, resume_facade, request
+ )
+ else:
+ await manager.send_progress(job_id, {
+ "status": "running", "progress": 30, "message": "Generating resume with AI..."
+ })
+ result_base64 = await asyncio.to_thread(
+ _generate_base_resume, resume_facade
+ )
+
+ await manager.send_progress(job_id, {
+ "status": "running", "progress": 90, "message": "Finalizing PDF..."
+ })
+
+ # Store result
+ if isinstance(result_base64, tuple):
+ pdf_data = base64.b64decode(result_base64[0])
+ else:
+ pdf_data = base64.b64decode(result_base64)
+
+ _jobs[job_id]["pdf_data"] = pdf_data
+ _jobs[job_id]["status"] = "completed"
+ _jobs[job_id]["progress"] = 100
+
+ filename = _get_filename(request.action, request.job_url)
+ _jobs[job_id]["filename"] = filename
+
+ await manager.send_progress(job_id, {
+ "status": "completed",
+ "progress": 100,
+ "message": "Document generated successfully!",
+ "download_url": f"/api/download/{job_id}",
+ })
+
+ except Exception as e:
+ logger.error(f"Generation failed for job {job_id}: {e}")
+ _jobs[job_id]["status"] = "failed"
+ _jobs[job_id]["error"] = str(e)
+ await manager.send_progress(job_id, {
+ "status": "failed", "progress": 0, "message": f"Error: {e}",
+ "error": str(e),
+ })
+
+
+def _generate_with_job_url(resume_facade: "ResumeFacade", request: GenerateRequest):
+ """Generate a document that requires a job URL (runs in thread)."""
+ import httpx
+ from src.libs.resume_and_cover_builder.llm.llm_job_parser import LLMParser
+ from src.libs.resume_and_cover_builder.config import global_config
+ from src.job import Job
+
+ # Fetch job page HTML using httpx instead of Selenium
+ try:
+ with httpx.Client(follow_redirects=True, timeout=30.0) as client:
+ response = client.get(request.job_url, headers={
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ })
+ response.raise_for_status()
+ body_html = response.text
+ except httpx.HTTPError as e:
+ raise RuntimeError(f"Failed to fetch job URL: {e}")
+
+ # Parse job description using LLM
+ llm_parser = LLMParser(openai_api_key=global_config.API_KEY)
+ llm_parser.set_body_html(body_html)
+
+ job = Job()
+ job.role = llm_parser.extract_role()
+ job.company = llm_parser.extract_company_name()
+ job.description = llm_parser.extract_job_description()
+ job.location = llm_parser.extract_location()
+ job.link = request.job_url
+
+ resume_facade.job = job
+ resume_facade.llm_job_parser = llm_parser
+
+ if request.action == "cover_letter":
+ # Generate cover letter HTML
+ style_path = resume_facade.style_manager.get_style_path()
+ if style_path is None:
+ raise ValueError("You must choose a style before generating the PDF.")
+ cover_letter_html = resume_facade.resume_generator.create_cover_letter_job_description(
+ style_path, job.description
+ )
+ return _html_to_pdf_without_selenium(cover_letter_html)
+ else:
+ # Generate tailored resume HTML
+ style_path = resume_facade.style_manager.get_style_path()
+ if style_path is None:
+ raise ValueError("You must choose a style before generating the PDF.")
+ html_resume = resume_facade.resume_generator.create_resume_job_description_text(
+ style_path, job.description
+ )
+ return _html_to_pdf_without_selenium(html_resume)
+
+
+def _generate_base_resume(resume_facade: "ResumeFacade"):
+ """Generate a base resume (runs in thread)."""
+ style_path = resume_facade.style_manager.get_style_path()
+ if style_path is None:
+ raise ValueError("You must choose a style before generating the PDF.")
+ html_resume = resume_facade.resume_generator.create_resume(style_path)
+ return _html_to_pdf_without_selenium(html_resume)
+
+
+def _html_to_pdf_without_selenium(html_content: str) -> str:
+ """
+ Convert HTML to PDF without requiring Selenium/Chrome.
+ Uses reportlab as a fallback, or returns base64-encoded HTML wrapped as PDF.
+ For Railway deployment, we generate a simple PDF from HTML content.
+ """
+ try:
+ # Try using Chrome headless if available (e.g., in Docker)
+ from src.utils.chrome_utils import init_browser, HTML_to_PDF
+ driver = init_browser()
+ try:
+ result = HTML_to_PDF(html_content, driver)
+ return result
+ finally:
+ try:
+ driver.quit()
+ except Exception:
+ pass
+ except Exception:
+ # Fallback: use reportlab to create a basic PDF
+ logger.warning("Chrome not available, using reportlab PDF fallback")
+ return _reportlab_pdf_from_html(html_content)
+
+
+def _reportlab_pdf_from_html(html_content: str) -> str:
+ """Create a PDF from HTML content using reportlab as fallback."""
+ import io
+ import re
+ from reportlab.lib.pagesizes import A4
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+ from reportlab.lib.units import inch
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
+
+ buffer = io.BytesIO()
+ doc = SimpleDocTemplate(buffer, pagesize=A4,
+ topMargin=0.75 * inch, bottomMargin=0.75 * inch,
+ leftMargin=0.75 * inch, rightMargin=0.75 * inch)
+
+ styles = getSampleStyleSheet()
+ title_style = ParagraphStyle('CustomTitle', parent=styles['Title'], fontSize=16, spaceAfter=12)
+ heading_style = ParagraphStyle('CustomHeading', parent=styles['Heading2'], fontSize=12, spaceAfter=6)
+ body_style = ParagraphStyle('CustomBody', parent=styles['Normal'], fontSize=10, spaceAfter=4)
+
+ story = []
+
+ # Strip HTML tags for simple text extraction
+ text = re.sub(r'', '', html_content, flags=re.DOTALL)
+ text = re.sub(r'', '', text, flags=re.DOTALL)
+
+ # Extract sections
+ sections = re.split(r'<(?:h[12]|section)[^>]*>', text)
+ for section in sections:
+ # Clean HTML tags
+ clean = re.sub(r'<[^>]+>', ' ', section)
+ clean = re.sub(r'\s+', ' ', clean).strip()
+ if clean:
+ # Check if it looks like a heading
+ if len(clean) < 50 and not clean.endswith('.'):
+ story.append(Paragraph(clean, heading_style))
+ story.append(Spacer(1, 4))
+ else:
+ # Split into paragraphs
+ for para in clean.split(' '):
+ para = para.strip()
+ if para:
+ try:
+ story.append(Paragraph(para, body_style))
+ except Exception:
+ # If reportlab can't parse it, add as plain text
+ story.append(Paragraph(re.sub(r'[<>&]', '', para), body_style))
+ story.append(Spacer(1, 2))
+
+ if not story:
+ story.append(Paragraph("Document generated by AIHawk", body_style))
+
+ doc.build(story)
+ pdf_bytes = buffer.getvalue()
+ return base64.b64encode(pdf_bytes).decode("utf-8")
+
+
+def _get_filename(action: str, job_url: Optional[str] = None) -> str:
+ """Generate a filename for the document."""
+ if action == "resume":
+ return "resume_base.pdf"
+ elif action == "resume_tailored":
+ suffix = hashlib.md5(job_url.encode()).hexdigest()[:8] if job_url else "tailored"
+ return f"resume_tailored_{suffix}.pdf"
+ else:
+ suffix = hashlib.md5(job_url.encode()).hexdigest()[:8] if job_url else "cover"
+ return f"cover_letter_{suffix}.pdf"
+
+
+# === API Routes ===
+
+@app.get("/", response_class=HTMLResponse)
+async def index():
+ """Serve the main web UI."""
+ from src.web.ui import get_html
+ return HTMLResponse(content=get_html())
+
+
+@app.get("/api/health")
+async def health():
+ """Health check endpoint."""
+ return {"status": "ok", "version": "1.0.0"}
+
+
+@app.get("/api/styles")
+async def get_styles():
+ """Get available resume styles."""
+ try:
+ styles = _get_available_styles()
+ return {
+ "styles": [
+ {"name": name, "file": file_name, "author": author}
+ for name, (file_name, author) in styles.items()
+ ]
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to load styles: {e}")
+
+
+@app.post("/api/generate")
+async def generate_document(request: GenerateRequest):
+ """Start async document generation. Returns a job ID for tracking progress."""
+ # Validate request
+ if request.action not in ("resume", "resume_tailored", "cover_letter"):
+ raise HTTPException(status_code=400, detail="Invalid action. Must be 'resume', 'resume_tailored', or 'cover_letter'.")
+
+ if not request.llm_api_key:
+ raise HTTPException(status_code=400, detail="LLM API key is required.")
+
+ if not request.resume_yaml.strip():
+ raise HTTPException(status_code=400, detail="Resume YAML is required.")
+
+ if request.action in ("resume_tailored", "cover_letter") and not request.job_url:
+ raise HTTPException(status_code=400, detail="Job URL is required for tailored documents.")
+
+ # Create job
+ job_id = str(uuid.uuid4())
+ _jobs[job_id] = {
+ "status": "pending",
+ "progress": 0,
+ "message": "Job queued",
+ "pdf_data": None,
+ "filename": None,
+ "error": None,
+ }
+
+ # Start async generation
+ asyncio.create_task(_run_generation(job_id, request))
+
+ return {"job_id": job_id, "status": "pending", "ws_url": f"/ws/{job_id}"}
+
+
+@app.get("/api/status/{job_id}")
+async def get_status(job_id: str):
+ """Get the status of a generation job."""
+ if job_id not in _jobs:
+ raise HTTPException(status_code=404, detail="Job not found.")
+ job = _jobs[job_id]
+ result = {
+ "job_id": job_id,
+ "status": job["status"],
+ "progress": job.get("progress", 0),
+ "message": job.get("message", ""),
+ }
+ if job["status"] == "completed":
+ result["download_url"] = f"/api/download/{job_id}"
+ if job["status"] == "failed":
+ result["error"] = job.get("error", "Unknown error")
+ return result
+
+
+@app.get("/api/download/{job_id}")
+async def download_document(job_id: str):
+ """Download a generated document."""
+ if job_id not in _jobs:
+ raise HTTPException(status_code=404, detail="Job not found.")
+ job = _jobs[job_id]
+ if job["status"] != "completed":
+ raise HTTPException(status_code=400, detail="Document not ready yet.")
+ if not job.get("pdf_data"):
+ raise HTTPException(status_code=500, detail="PDF data not available.")
+
+ return Response(
+ content=job["pdf_data"],
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f'attachment; filename="{job.get("filename", "document.pdf")}"'
+ },
+ )
+
+
+@app.websocket("/ws/{job_id}")
+async def websocket_endpoint(websocket: WebSocket, job_id: str):
+ """WebSocket endpoint for real-time progress updates."""
+ await manager.connect(websocket, job_id)
+ try:
+ # Send current status immediately
+ if job_id in _jobs:
+ job = _jobs[job_id]
+ await websocket.send_json({
+ "status": job["status"],
+ "progress": job.get("progress", 0),
+ "message": job.get("message", ""),
+ })
+
+ # Keep connection alive
+ while True:
+ try:
+ await websocket.receive_text()
+ except WebSocketDisconnect:
+ break
+ finally:
+ manager.disconnect(websocket, job_id)
diff --git a/src/web/ui.py b/src/web/ui.py
new file mode 100644
index 000000000..2e5a526d7
--- /dev/null
+++ b/src/web/ui.py
@@ -0,0 +1,1013 @@
+"""
+Embedded single-page web UI for AIHawk Resume Builder.
+Returns a complete HTML page with inline CSS and JavaScript.
+"""
+
+
+def get_html() -> str:
+ """Return the complete HTML page for the web UI."""
+ return '''
+
+
+
+
+ AIHawk Resume Builder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📄
+
Resume
+
Generate base resume
+
+
+
🎯
+
Tailored Resume
+
Resume for a job
+
+
+
✉
+
Cover Letter
+
Tailored cover letter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No documents generated yet.
+
+
+
+
+
+
+
+
+'''
From 2ca3b04c5015ba070ed8ef4f4dcabb5c252d1da4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 17:54:07 +0000
Subject: [PATCH 03/12] fix: make web server work without selenium/openai, fix
imports for lightweight startup
Agent-Logs-Url: https://github.com/emirsaffar-collab/Jobs_Applier_AI_Agent_AIHawk/sessions/fb01ed93-4579-4ff1-9531-a9dc19bca942
Co-authored-by: emirsaffar-collab <79217569+emirsaffar-collab@users.noreply.github.com>
---
main.py | 27 +++++++++++++--------------
src/logging.py | 11 ++++++++++-
src/web/app.py | 20 +++++++++++++++++---
3 files changed, 40 insertions(+), 18 deletions(-)
diff --git a/main.py b/main.py
index a2570fd8f..63c150a13 100644
--- a/main.py
+++ b/main.py
@@ -1,5 +1,16 @@
-import base64
import sys
+
+# Check for web mode early, before importing CLI-specific dependencies
+if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "web":
+ import uvicorn
+ import config as cfg
+ from src.web.app import app
+
+ print(f"Starting AIHawk web server on {cfg.WEB_HOST}:{cfg.WEB_PORT}")
+ uvicorn.run(app, host=cfg.WEB_HOST, port=cfg.WEB_PORT)
+ sys.exit(0)
+
+import base64
from pathlib import Path
import traceback
from typing import List, Optional, Tuple, Dict
@@ -562,16 +573,4 @@ def main():
if __name__ == "__main__":
- import sys
-
- if len(sys.argv) > 1 and sys.argv[1] == "web":
- # Web server mode
- import uvicorn
- import config as cfg
- from src.web.app import app
-
- print(f"Starting AIHawk web server on {cfg.WEB_HOST}:{cfg.WEB_PORT}")
- uvicorn.run(app, host=cfg.WEB_HOST, port=cfg.WEB_PORT)
- else:
- # CLI mode (original behavior)
- main()
+ main()
diff --git a/src/logging.py b/src/logging.py
index 20b1448c0..d4b84551a 100644
--- a/src/logging.py
+++ b/src/logging.py
@@ -3,7 +3,13 @@
import sys
import logging
from loguru import logger
-from selenium.webdriver.remote.remote_connection import LOGGER as selenium_logger
+
+try:
+ from selenium.webdriver.remote.remote_connection import LOGGER as selenium_logger
+ _has_selenium = True
+except ImportError:
+ selenium_logger = None
+ _has_selenium = False
from config import LOG_LEVEL, LOG_SELENIUM_LEVEL, LOG_TO_CONSOLE, LOG_TO_FILE
@@ -54,6 +60,9 @@ def get_log_filename():
def init_selenium_logger():
"""Initialize and configure selenium logger to write to selenium.log."""
+ if not _has_selenium or selenium_logger is None:
+ return
+
log_file = "log/selenium.log"
os.makedirs(os.path.dirname(log_file), exist_ok=True)
diff --git a/src/web/app.py b/src/web/app.py
index b77275279..a97ae1059 100644
--- a/src/web/app.py
+++ b/src/web/app.py
@@ -82,9 +82,23 @@ async def send_progress(self, job_id: str, data: dict):
def _get_available_styles() -> dict:
"""Get available resume styles from the styles directory."""
- from src.libs.resume_and_cover_builder.style_manager import StyleManager
- sm = StyleManager()
- return sm.get_styles()
+ styles_dir = Path(__file__).resolve().parent.parent / "libs" / "resume_and_cover_builder" / "resume_style"
+ styles = {}
+ if not styles_dir.is_dir():
+ return styles
+ for file_path in styles_dir.iterdir():
+ if file_path.is_file() and file_path.suffix == ".css":
+ try:
+ with file_path.open("r", encoding="utf-8") as f:
+ first_line = f.readline().strip()
+ if first_line.startswith("/*") and first_line.endswith("*/"):
+ content = first_line[2:-2].strip()
+ if "$" in content:
+ style_name, author_link = content.split("$", 1)
+ styles[style_name.strip()] = (file_path.name, author_link.strip())
+ except Exception:
+ continue
+ return styles
def _validate_resume_yaml(yaml_str: str) -> bool:
From 8d05fa626d1649729637df2052053cb10a3a8045 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 17:55:34 +0000
Subject: [PATCH 04/12] fix: address CodeQL alert (case-insensitive regex) and
review feedback
Agent-Logs-Url: https://github.com/emirsaffar-collab/Jobs_Applier_AI_Agent_AIHawk/sessions/fb01ed93-4579-4ff1-9531-a9dc19bca942
Co-authored-by: emirsaffar-collab <79217569+emirsaffar-collab@users.noreply.github.com>
---
src/web/app.py | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/src/web/app.py b/src/web/app.py
index a97ae1059..1505d82f4 100644
--- a/src/web/app.py
+++ b/src/web/app.py
@@ -338,9 +338,9 @@ def _reportlab_pdf_from_html(html_content: str) -> str:
story = []
- # Strip HTML tags for simple text extraction
- text = re.sub(r'', '', html_content, flags=re.DOTALL)
- text = re.sub(r'', '', text, flags=re.DOTALL)
+ # Strip HTML tags for simple text extraction (case-insensitive for safety)
+ text = re.sub(r'', '', html_content, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r'', '', text, flags=re.DOTALL | re.IGNORECASE)
# Extract sections
sections = re.split(r'<(?:h[12]|section)[^>]*>', text)
@@ -360,8 +360,8 @@ def _reportlab_pdf_from_html(html_content: str) -> str:
if para:
try:
story.append(Paragraph(para, body_style))
- except Exception:
- # If reportlab can't parse it, add as plain text
+ except (ValueError, AttributeError):
+ # If reportlab can't parse the markup, sanitize and retry
story.append(Paragraph(re.sub(r'[<>&]', '', para), body_style))
story.append(Spacer(1, 2))
@@ -374,7 +374,10 @@ def _reportlab_pdf_from_html(html_content: str) -> str:
def _get_filename(action: str, job_url: Optional[str] = None) -> str:
- """Generate a filename for the document."""
+ """Generate a filename for the document.
+
+ Note: MD5 is used only for filename uniqueness, not for cryptographic security.
+ """
if action == "resume":
return "resume_base.pdf"
elif action == "resume_tailored":
From c656dcec3b62daf9934c838aa4ddc05207eba7a3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 17:56:30 +0000
Subject: [PATCH 05/12] fix: handle whitespace in closing script/style tags for
robust HTML stripping
Agent-Logs-Url: https://github.com/emirsaffar-collab/Jobs_Applier_AI_Agent_AIHawk/sessions/fb01ed93-4579-4ff1-9531-a9dc19bca942
Co-authored-by: emirsaffar-collab <79217569+emirsaffar-collab@users.noreply.github.com>
---
src/web/app.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/web/app.py b/src/web/app.py
index 1505d82f4..516e9c491 100644
--- a/src/web/app.py
+++ b/src/web/app.py
@@ -339,8 +339,8 @@ def _reportlab_pdf_from_html(html_content: str) -> str:
story = []
# Strip HTML tags for simple text extraction (case-insensitive for safety)
- text = re.sub(r'', '', html_content, flags=re.DOTALL | re.IGNORECASE)
- text = re.sub(r'', '', text, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r'', '', html_content, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r'', '', text, flags=re.DOTALL | re.IGNORECASE)
# Extract sections
sections = re.split(r'<(?:h[12]|section)[^>]*>', text)
From 3ae213aff485371ffee613d5e842de9be99bf5b1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 18:03:06 +0000
Subject: [PATCH 06/12] fix: add git to Dockerfile for pip git+ dependency
install
Agent-Logs-Url: https://github.com/emirsaffar-collab/Jobs_Applier_AI_Agent_AIHawk/sessions/b909bbed-135b-4bb6-bc76-150ca0817957
Co-authored-by: emirsaffar-collab <79217569+emirsaffar-collab@users.noreply.github.com>
---
Dockerfile | 1 +
1 file changed, 1 insertion(+)
diff --git a/Dockerfile b/Dockerfile
index a89f485e5..7d10aa771 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,6 +2,7 @@ FROM python:3.11-slim
# Install Chrome for PDF generation (optional, falls back to reportlab)
RUN apt-get update && apt-get install -y --no-install-recommends \
+ git \
wget \
gnupg2 \
&& wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \
From 8d0fe0a71219c6b357a667350afdf3f99773c04d Mon Sep 17 00:00:00 2001
From: "railway-app[bot]" <68434857+railway-app[bot]@users.noreply.github.com>
Date: Fri, 3 Apr 2026 18:05:54 +0000
Subject: [PATCH 07/12] fix: replace apt-key with gpg keyring for Google Chrome
repo
---
Dockerfile | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 7d10aa771..ffd9ecbbd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,8 +5,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
wget \
gnupg2 \
- && wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \
- && echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
+ && wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
+ && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends google-chrome-stable \
&& apt-get clean \
From ddabe9b19fa4363c45a9fb529708609433ced79a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 18:30:36 +0000
Subject: [PATCH 08/12] feat: add work preferences and resume config API
endpoints to web UI
Add GET/PUT /api/preferences for managing work_preferences.yaml
Add GET/PUT /api/resume for managing plain_text_resume.yaml
Add WorkPreferences Pydantic models with YAML round-trip support
Add validation matching ConfigValidator rules from main.py
Ensure data_folder exists when generating documents
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: emirsaffar-collab <79217569+emirsaffar-collab@users.noreply.github.com>
---
src/web/app.py | 238 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 238 insertions(+)
diff --git a/src/web/app.py b/src/web/app.py
index 516e9c491..d73d61a66 100644
--- a/src/web/app.py
+++ b/src/web/app.py
@@ -44,6 +44,169 @@ class JobStatus(BaseModel):
error: Optional[str] = None
+class ExperienceLevelModel(BaseModel):
+ internship: bool = False
+ entry: bool = True
+ associate: bool = True
+ mid_senior_level: bool = True
+ director: bool = False
+ executive: bool = False
+
+
+class JobTypesModel(BaseModel):
+ full_time: bool = True
+ contract: bool = False
+ part_time: bool = False
+ temporary: bool = True
+ internship: bool = False
+ other: bool = False
+ volunteer: bool = True
+
+
+class DateFiltersModel(BaseModel):
+ all_time: bool = False
+ month: bool = False
+ week: bool = False
+ twenty_four_hours: bool = True
+
+ class Config:
+ populate_by_name = True
+
+ @classmethod
+ def from_yaml_dict(cls, data: dict) -> "DateFiltersModel":
+ return cls(
+ all_time=data.get("all_time", False),
+ month=data.get("month", False),
+ week=data.get("week", False),
+ twenty_four_hours=data.get("24_hours", True),
+ )
+
+ def to_yaml_dict(self) -> dict:
+ return {
+ "all_time": self.all_time,
+ "month": self.month,
+ "week": self.week,
+ "24_hours": self.twenty_four_hours,
+ }
+
+
+class WorkPreferences(BaseModel):
+ remote: bool = True
+ hybrid: bool = True
+ onsite: bool = True
+ experience_level: ExperienceLevelModel = ExperienceLevelModel()
+ job_types: JobTypesModel = JobTypesModel()
+ date: DateFiltersModel = DateFiltersModel()
+ positions: list[str] = ["Software engineer"]
+ locations: list[str] = ["Germany"]
+ apply_once_at_company: bool = True
+ distance: int = 100
+ company_blacklist: list[str] = ["wayfair", "Crossover"]
+ title_blacklist: list[str] = ["word1", "word2"]
+ location_blacklist: list[str] = ["Brazil"]
+
+ @classmethod
+ def from_yaml_dict(cls, data: dict) -> "WorkPreferences":
+ date_data = data.get("date", {})
+ date_model = DateFiltersModel.from_yaml_dict(date_data) if isinstance(date_data, dict) else DateFiltersModel()
+ return cls(
+ remote=data.get("remote", True),
+ hybrid=data.get("hybrid", True),
+ onsite=data.get("onsite", True),
+ experience_level=ExperienceLevelModel(**data["experience_level"]) if "experience_level" in data else ExperienceLevelModel(),
+ job_types=JobTypesModel(**data["job_types"]) if "job_types" in data else JobTypesModel(),
+ date=date_model,
+ positions=data.get("positions", ["Software engineer"]),
+ locations=data.get("locations", ["Germany"]),
+ apply_once_at_company=data.get("apply_once_at_company", True),
+ distance=data.get("distance", 100),
+ company_blacklist=data.get("company_blacklist") or [],
+ title_blacklist=data.get("title_blacklist") or [],
+ location_blacklist=data.get("location_blacklist") or [],
+ )
+
+ def to_yaml_dict(self) -> dict:
+ return {
+ "remote": self.remote,
+ "hybrid": self.hybrid,
+ "onsite": self.onsite,
+ "experience_level": self.experience_level.model_dump(),
+ "job_types": self.job_types.model_dump(),
+ "date": self.date.to_yaml_dict(),
+ "positions": self.positions,
+ "locations": self.locations,
+ "apply_once_at_company": self.apply_once_at_company,
+ "distance": self.distance,
+ "company_blacklist": self.company_blacklist,
+ "title_blacklist": self.title_blacklist,
+ "location_blacklist": self.location_blacklist,
+ }
+
+
+class ResumeUpdate(BaseModel):
+ resume_yaml: str
+
+
+DATA_FOLDER = Path("data_folder")
+WORK_PREFERENCES_PATH = DATA_FOLDER / "work_preferences.yaml"
+PLAIN_TEXT_RESUME_PATH = DATA_FOLDER / "plain_text_resume.yaml"
+
+APPROVED_DISTANCES = {0, 5, 10, 25, 50, 100}
+
+
+def _validate_work_preferences(data: dict) -> list[str]:
+ """Validate work preferences dict using the same rules as ConfigValidator."""
+ errors = []
+
+ # Validate experience levels are booleans
+ exp = data.get("experience_level", {})
+ if not isinstance(exp, dict):
+ errors.append("experience_level must be a dict")
+ else:
+ for level in ["internship", "entry", "associate", "mid_senior_level", "director", "executive"]:
+ if not isinstance(exp.get(level), bool):
+ errors.append(f"Experience level '{level}' must be a boolean")
+
+ # Validate job types are booleans
+ jt = data.get("job_types", {})
+ if not isinstance(jt, dict):
+ errors.append("job_types must be a dict")
+ else:
+ for job_type in ["full_time", "contract", "part_time", "temporary", "internship", "other", "volunteer"]:
+ if not isinstance(jt.get(job_type), bool):
+ errors.append(f"Job type '{job_type}' must be a boolean")
+
+ # Validate date filters are booleans
+ date = data.get("date", {})
+ if not isinstance(date, dict):
+ errors.append("date must be a dict")
+ else:
+ for df in ["all_time", "month", "week", "24_hours"]:
+ if not isinstance(date.get(df), bool):
+ errors.append(f"Date filter '{df}' must be a boolean")
+
+ # Validate positions and locations are lists of strings
+ for key in ["positions", "locations"]:
+ val = data.get(key, [])
+ if not isinstance(val, list) or not all(isinstance(item, str) for item in val):
+ errors.append(f"'{key}' must be a list of strings")
+
+ # Validate distance
+ dist = data.get("distance")
+ if dist not in APPROVED_DISTANCES:
+ errors.append(f"distance must be one of {sorted(APPROVED_DISTANCES)}")
+
+ # Validate blacklists are lists
+ for bl in ["company_blacklist", "title_blacklist", "location_blacklist"]:
+ val = data.get(bl)
+ if val is None:
+ continue
+ if not isinstance(val, list):
+ errors.append(f"'{bl}' must be a list")
+
+ return errors
+
+
# WebSocket connection manager
class ConnectionManager:
"""Manages WebSocket connections for real-time progress updates."""
@@ -434,6 +597,9 @@ async def generate_document(request: GenerateRequest):
if request.action in ("resume_tailored", "cover_letter") and not request.job_url:
raise HTTPException(status_code=400, detail="Job URL is required for tailored documents.")
+ # Ensure data_folder exists for generation artifacts
+ DATA_FOLDER.mkdir(parents=True, exist_ok=True)
+
# Create job
job_id = str(uuid.uuid4())
_jobs[job_id] = {
@@ -490,6 +656,78 @@ async def download_document(job_id: str):
)
+@app.get("/api/preferences")
+async def get_preferences():
+ """Load work preferences from data_folder/work_preferences.yaml."""
+ if WORK_PREFERENCES_PATH.exists():
+ try:
+ with open(WORK_PREFERENCES_PATH, "r") as f:
+ data = yaml.safe_load(f)
+ if not isinstance(data, dict):
+ raise HTTPException(status_code=500, detail="Invalid work_preferences.yaml format.")
+ prefs = WorkPreferences.from_yaml_dict(data)
+ except yaml.YAMLError as exc:
+ raise HTTPException(status_code=500, detail=f"Error parsing work_preferences.yaml: {exc}")
+ else:
+ prefs = WorkPreferences()
+ return prefs.to_yaml_dict()
+
+
+@app.put("/api/preferences")
+async def update_preferences(prefs: WorkPreferences):
+ """Save work preferences to data_folder/work_preferences.yaml."""
+ yaml_dict = prefs.to_yaml_dict()
+
+ errors = _validate_work_preferences(yaml_dict)
+ if errors:
+ raise HTTPException(status_code=422, detail=errors)
+
+ DATA_FOLDER.mkdir(parents=True, exist_ok=True)
+ try:
+ with open(WORK_PREFERENCES_PATH, "w") as f:
+ yaml.dump(yaml_dict, f, default_flow_style=False, sort_keys=False)
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to save preferences: {exc}")
+ return {"status": "ok", "message": "Work preferences saved."}
+
+
+@app.get("/api/resume")
+async def get_resume():
+ """Load plain text resume YAML from data_folder/plain_text_resume.yaml."""
+ if PLAIN_TEXT_RESUME_PATH.exists():
+ try:
+ content = PLAIN_TEXT_RESUME_PATH.read_text(encoding="utf-8")
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Error reading resume file: {exc}")
+ else:
+ example_path = Path("data_folder_example") / "plain_text_resume.yaml"
+ if example_path.exists():
+ content = example_path.read_text(encoding="utf-8")
+ else:
+ content = "personal_information:\n name: \"\"\n surname: \"\"\n"
+ return {"resume_yaml": content}
+
+
+@app.put("/api/resume")
+async def update_resume(body: ResumeUpdate):
+ """Save plain text resume YAML to data_folder/plain_text_resume.yaml."""
+ if not body.resume_yaml.strip():
+ raise HTTPException(status_code=400, detail="Resume YAML content cannot be empty.")
+
+ if not _validate_resume_yaml(body.resume_yaml):
+ raise HTTPException(
+ status_code=422,
+ detail="Invalid resume YAML. Must be valid YAML with a 'personal_information' section.",
+ )
+
+ DATA_FOLDER.mkdir(parents=True, exist_ok=True)
+ try:
+ PLAIN_TEXT_RESUME_PATH.write_text(body.resume_yaml, encoding="utf-8")
+ except OSError as exc:
+ raise HTTPException(status_code=500, detail=f"Failed to save resume: {exc}")
+ return {"status": "ok", "message": "Resume saved."}
+
+
@app.websocket("/ws/{job_id}")
async def websocket_endpoint(websocket: WebSocket, job_id: str):
"""WebSocket endpoint for real-time progress updates."""
From 32c92f6b091e047c1a22469fac0a7b2d2a16a6c3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 18:51:26 +0000
Subject: [PATCH 09/12] feat: add tabbed UI with Resume and Settings panels
Rewrite the web UI to use a 3-tab interface:
- Generate tab: existing LLM config, action selection, progress tracking
- Resume tab: YAML editor with load/save to server via /api/resume
- Settings tab: work preferences form with all fields from
work_preferences.yaml, including checkboxes, radio buttons,
tag-style list inputs, and load/save via /api/preferences
The resume textarea moves from the Generate tab to the Resume tab.
The Generate tab shows a preview snippet and a hint to edit in the
Resume tab.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: emirsaffar-collab <79217569+emirsaffar-collab@users.noreply.github.com>
---
src/web/ui.py | 1164 +++++++++++++++++++++++++++++--------------------
1 file changed, 696 insertions(+), 468 deletions(-)
diff --git a/src/web/ui.py b/src/web/ui.py
index 2e5a526d7..725ff37c6 100644
--- a/src/web/ui.py
+++ b/src/web/ui.py
@@ -6,7 +6,11 @@
def get_html() -> str:
"""Return the complete HTML page for the web UI."""
- return '''
+ return _HTML
+
+
+_HTML = """\
+
@@ -44,7 +48,6 @@ def get_html() -> str:
line-height: 1.6;
}
- /* Header */
.header {
background: white;
border-bottom: 1px solid var(--gray-200);
@@ -69,41 +72,18 @@ def get_html() -> str:
.header h1 .icon { font-size: 24px; }
- .header-actions {
- display: flex;
- gap: 12px;
- align-items: center;
- }
-
- .badge {
- padding: 4px 10px;
- border-radius: 12px;
- font-size: 12px;
- font-weight: 600;
- }
+ .header-actions { display: flex; gap: 12px; align-items: center; }
+ .badge { padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.badge-success { background: #dcfce7; color: var(--success); }
.badge-warning { background: #fef3c7; color: var(--warning); }
.badge-danger { background: #fee2e2; color: var(--danger); }
- /* Layout */
- .container {
- max-width: 1200px;
- margin: 0 auto;
- padding: 24px;
- }
-
- .grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 24px;
- }
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
- @media (max-width: 768px) {
- .grid { grid-template-columns: 1fr; }
- }
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
+ @media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
- /* Cards */
.card {
background: white;
border-radius: var(--radius);
@@ -120,41 +100,31 @@ def get_html() -> str:
justify-content: space-between;
}
- .card-header h2 {
- font-size: 16px;
- font-weight: 600;
- color: var(--gray-900);
- }
-
+ .card-header h2 { font-size: 16px; font-weight: 600; color: var(--gray-900); }
.card-body { padding: 20px; }
- /* Forms */
- .form-group {
- margin-bottom: 16px;
- }
-
+ .form-group { margin-bottom: 16px; }
.form-group label {
- display: block;
- font-size: 13px;
- font-weight: 600;
- color: var(--gray-700);
- margin-bottom: 6px;
+ display: block; font-size: 13px; font-weight: 600;
+ color: var(--gray-700); margin-bottom: 6px;
}
- .form-group input,
+ .form-group input[type="text"],
+ .form-group input[type="password"],
+ .form-group input[type="url"],
.form-group select,
.form-group textarea {
- width: 100%;
- padding: 10px 12px;
+ width: 100%; padding: 10px 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
- font-size: 14px;
- color: var(--gray-800);
+ font-size: 14px; color: var(--gray-800);
background: white;
transition: border-color 0.2s, box-shadow 0.2s;
}
- .form-group input:focus,
+ .form-group input[type="text"]:focus,
+ .form-group input[type="password"]:focus,
+ .form-group input[type="url"]:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
@@ -164,489 +134,499 @@ def get_html() -> str:
.form-group textarea {
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace;
- font-size: 12px;
- resize: vertical;
- min-height: 200px;
+ font-size: 12px; resize: vertical; min-height: 200px;
}
- .form-row {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 12px;
- }
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+ .form-help { font-size: 12px; color: var(--gray-500); margin-top: 4px; }
- .form-help {
- font-size: 12px;
- color: var(--gray-500);
- margin-top: 4px;
- }
-
- /* Buttons */
.btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 10px 20px;
- border-radius: var(--radius);
- font-size: 14px;
- font-weight: 600;
- cursor: pointer;
- border: none;
- transition: all 0.2s;
- }
-
- .btn:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
-
- .btn-primary {
- background: var(--primary);
- color: white;
- }
-
- .btn-primary:hover:not(:disabled) {
- background: var(--primary-dark);
- }
-
- .btn-success {
- background: var(--success);
- color: white;
- }
-
- .btn-outline {
- background: white;
- color: var(--gray-700);
- border: 1px solid var(--gray-300);
- }
-
- .btn-outline:hover:not(:disabled) {
- background: var(--gray-50);
- }
-
+ display: inline-flex; align-items: center; justify-content: center;
+ gap: 6px; padding: 10px 20px; border-radius: var(--radius);
+ font-size: 14px; font-weight: 600; cursor: pointer;
+ border: none; transition: all 0.2s;
+ }
+ .btn:disabled { opacity: 0.6; cursor: not-allowed; }
+ .btn-primary { background: var(--primary); color: white; }
+ .btn-primary:hover:not(:disabled) { background: var(--primary-dark); }
+ .btn-success { background: var(--success); color: white; }
+ .btn-outline { background: white; color: var(--gray-700); border: 1px solid var(--gray-300); }
+ .btn-outline:hover:not(:disabled) { background: var(--gray-50); }
+ .btn-sm { padding: 6px 12px; font-size: 12px; }
.btn-block { width: 100%; }
+ .btn-group { display: flex; gap: 8px; flex-wrap: wrap; }
- .btn-group {
- display: flex;
- gap: 8px;
+ .main-tabs {
+ display: flex; border-bottom: 2px solid var(--gray-200);
+ margin-bottom: 24px; gap: 4px;
}
- /* Tabs */
- .tabs {
- display: flex;
- border-bottom: 2px solid var(--gray-200);
- margin-bottom: 20px;
+ .main-tab {
+ padding: 12px 24px; font-size: 15px; font-weight: 600;
+ color: var(--gray-500); cursor: pointer;
+ border-bottom: 3px solid transparent; margin-bottom: -2px;
+ transition: all 0.2s; background: none;
+ border-top: none; border-left: none; border-right: none;
+ user-select: none;
}
+ .main-tab:hover { color: var(--gray-700); background: var(--gray-50); }
+ .main-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
- .tab {
- padding: 10px 16px;
- font-size: 14px;
- font-weight: 500;
- color: var(--gray-500);
- cursor: pointer;
- border-bottom: 2px solid transparent;
- margin-bottom: -2px;
- transition: all 0.2s;
- background: none;
- border-top: none;
- border-left: none;
- border-right: none;
- }
-
- .tab:hover { color: var(--gray-700); }
-
- .tab.active {
- color: var(--primary);
- border-bottom-color: var(--primary);
- }
-
- /* Progress */
- .progress-container {
- padding: 20px;
- display: none;
- }
+ .tab-panel { display: none; }
+ .tab-panel.active { display: block; }
+ .progress-container { padding: 20px; display: none; }
.progress-container.active { display: block; }
.progress-bar-track {
- height: 8px;
- background: var(--gray-200);
- border-radius: 4px;
- overflow: hidden;
- margin: 12px 0;
+ height: 8px; background: var(--gray-200);
+ border-radius: 4px; overflow: hidden; margin: 12px 0;
}
-
.progress-bar-fill {
- height: 100%;
- background: var(--primary);
- border-radius: 4px;
- transition: width 0.5s ease;
- width: 0%;
+ height: 100%; background: var(--primary);
+ border-radius: 4px; transition: width 0.5s ease; width: 0%;
}
-
.progress-bar-fill.success { background: var(--success); }
.progress-bar-fill.error { background: var(--danger); }
+ .progress-text { font-size: 14px; color: var(--gray-600); text-align: center; }
+ .progress-percent { font-size: 24px; font-weight: 700; color: var(--gray-800); text-align: center; }
- .progress-text {
- font-size: 14px;
- color: var(--gray-600);
- text-align: center;
- }
-
- .progress-percent {
- font-size: 24px;
- font-weight: 700;
- color: var(--gray-800);
- text-align: center;
- }
-
- /* Status log */
.status-log {
- max-height: 200px;
- overflow-y: auto;
- font-size: 13px;
- font-family: monospace;
- background: var(--gray-50);
- border: 1px solid var(--gray-200);
- border-radius: var(--radius);
- padding: 12px;
- margin-top: 12px;
- }
-
- .status-entry {
- padding: 4px 0;
- border-bottom: 1px solid var(--gray-100);
+ max-height: 200px; overflow-y: auto; font-size: 13px;
+ font-family: monospace; background: var(--gray-50);
+ border: 1px solid var(--gray-200); border-radius: var(--radius);
+ padding: 12px; margin-top: 12px;
}
-
+ .status-entry { padding: 4px 0; border-bottom: 1px solid var(--gray-100); }
.status-entry:last-child { border-bottom: none; }
+ .status-time { color: var(--gray-400); font-size: 11px; margin-right: 8px; }
- .status-time {
- color: var(--gray-400);
- font-size: 11px;
- margin-right: 8px;
- }
-
- /* Alerts */
.alert {
- padding: 12px 16px;
- border-radius: var(--radius);
- margin-bottom: 16px;
- font-size: 14px;
- display: none;
+ padding: 12px 16px; border-radius: var(--radius);
+ margin-bottom: 16px; font-size: 14px; display: none;
}
-
.alert.show { display: block; }
+ .alert-error { background: #fee2e2; color: var(--danger); border: 1px solid #fecaca; }
+ .alert-success { background: #dcfce7; color: var(--success); border: 1px solid #bbf7d0; }
+ .alert-info { background: var(--primary-light); color: var(--primary-dark); border: 1px solid #93c5fd; }
- .alert-error {
- background: #fee2e2;
- color: var(--danger);
- border: 1px solid #fecaca;
- }
-
- .alert-success {
- background: #dcfce7;
- color: var(--success);
- border: 1px solid #bbf7d0;
- }
-
- .alert-info {
- background: var(--primary-light);
- color: var(--primary-dark);
- border: 1px solid #93c5fd;
- }
-
- /* Action cards */
- .action-cards {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 12px;
- margin-bottom: 20px;
- }
-
- @media (max-width: 768px) {
- .action-cards { grid-template-columns: 1fr; }
- }
-
+ .action-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
+ @media (max-width: 768px) { .action-cards { grid-template-columns: 1fr; } }
.action-card {
- padding: 16px;
- border: 2px solid var(--gray-200);
- border-radius: var(--radius);
- cursor: pointer;
- transition: all 0.2s;
- text-align: center;
+ padding: 16px; border: 2px solid var(--gray-200);
+ border-radius: var(--radius); cursor: pointer;
+ transition: all 0.2s; text-align: center;
}
+ .action-card:hover { border-color: var(--primary); background: var(--primary-light); }
+ .action-card.selected { border-color: var(--primary); background: var(--primary-light); }
+ .action-card .action-icon { font-size: 28px; margin-bottom: 8px; }
+ .action-card .action-title { font-weight: 600; font-size: 14px; color: var(--gray-800); }
+ .action-card .action-desc { font-size: 12px; color: var(--gray-500); margin-top: 4px; }
- .action-card:hover {
- border-color: var(--primary);
- background: var(--primary-light);
+ .history-item {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 12px 0; border-bottom: 1px solid var(--gray-100);
}
+ .history-item:last-child { border-bottom: none; }
+ .history-info h4 { font-size: 14px; color: var(--gray-800); }
+ .history-info p { font-size: 12px; color: var(--gray-500); }
- .action-card.selected {
- border-color: var(--primary);
- background: var(--primary-light);
+ .spinner {
+ display: inline-block; width: 16px; height: 16px;
+ border: 2px solid var(--gray-300); border-top-color: var(--primary);
+ border-radius: 50%; animation: spin 0.8s linear infinite;
}
+ @keyframes spin { to { transform: rotate(360deg); } }
- .action-card .action-icon {
- font-size: 28px;
- margin-bottom: 8px;
+ .tooltip { position: relative; }
+ .tooltip::after {
+ content: attr(data-tip); position: absolute; bottom: 100%;
+ left: 50%; transform: translateX(-50%);
+ background: var(--gray-800); color: white;
+ padding: 4px 8px; border-radius: 4px; font-size: 12px;
+ white-space: nowrap; opacity: 0; pointer-events: none;
+ transition: opacity 0.2s;
}
+ .tooltip:hover::after { opacity: 1; }
- .action-card .action-title {
- font-weight: 600;
- font-size: 14px;
- color: var(--gray-800);
- }
+ .full-width { grid-column: 1 / -1; }
+ .hidden { display: none !important; }
+ #jobUrlGroup { display: none; }
+ #jobUrlGroup.show { display: block; }
- .action-card .action-desc {
- font-size: 12px;
- color: var(--gray-500);
- margin-top: 4px;
- }
+ .example-link { font-size: 12px; color: var(--primary); cursor: pointer; text-decoration: underline; }
+ .example-link:hover { color: var(--primary-dark); }
- /* History */
- .history-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 0;
- border-bottom: 1px solid var(--gray-100);
+ .settings-section {
+ margin-bottom: 24px; padding-bottom: 20px;
+ border-bottom: 1px solid var(--gray-200);
}
-
- .history-item:last-child { border-bottom: none; }
-
- .history-info h4 {
- font-size: 14px;
- color: var(--gray-800);
+ .settings-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
+ .settings-section h3 {
+ font-size: 15px; font-weight: 600; color: var(--gray-800);
+ margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
}
- .history-info p {
- font-size: 12px;
- color: var(--gray-500);
+ .check-group { display: flex; flex-wrap: wrap; gap: 12px; }
+ .check-item {
+ display: flex; align-items: center; gap: 6px;
+ font-size: 14px; color: var(--gray-700); cursor: pointer;
}
-
- /* Spinner */
- .spinner {
- display: inline-block;
- width: 16px;
- height: 16px;
- border: 2px solid var(--gray-300);
- border-top-color: var(--primary);
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
+ .check-item input[type="checkbox"],
+ .check-item input[type="radio"] {
+ width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer;
}
- @keyframes spin {
- to { transform: rotate(360deg); }
+ .tag-input-wrap { display: flex; gap: 8px; margin-bottom: 8px; }
+ .tag-input-wrap input {
+ flex: 1; padding: 8px 12px;
+ border: 1px solid var(--gray-300); border-radius: var(--radius);
+ font-size: 14px; color: var(--gray-800); background: white;
}
-
- /* Tooltip */
- .tooltip {
- position: relative;
+ .tag-input-wrap input:focus {
+ outline: none; border-color: var(--primary);
+ box-shadow: 0 0 0 3px var(--primary-light);
}
- .tooltip::after {
- content: attr(data-tip);
- position: absolute;
- bottom: 100%;
- left: 50%;
- transform: translateX(-50%);
- background: var(--gray-800);
- color: white;
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 12px;
- white-space: nowrap;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.2s;
+ .tag-list { display: flex; flex-wrap: wrap; gap: 6px; min-height: 28px; }
+ .tag {
+ display: inline-flex; align-items: center; gap: 4px;
+ padding: 4px 10px; background: var(--primary-light);
+ color: var(--primary-dark); border-radius: 16px;
+ font-size: 13px; font-weight: 500;
}
-
- .tooltip:hover::after { opacity: 1; }
-
- /* Full-width section */
- .full-width {
- grid-column: 1 / -1;
+ .tag-remove {
+ cursor: pointer; font-size: 14px; font-weight: 700;
+ line-height: 1; opacity: 0.7; border: none;
+ background: none; color: var(--primary-dark); padding: 0 2px;
}
+ .tag-remove:hover { opacity: 1; }
- /* Hidden */
- .hidden { display: none !important; }
-
- /* Job URL group */
- #jobUrlGroup { display: none; }
- #jobUrlGroup.show { display: block; }
-
- /* Example YAML button */
- .example-link {
- font-size: 12px;
- color: var(--primary);
- cursor: pointer;
- text-decoration: underline;
+ .status-msg {
+ padding: 10px 14px; border-radius: var(--radius);
+ font-size: 13px; margin-top: 12px; display: none;
}
+ .status-msg.show { display: block; }
+ .status-msg.success { background: #dcfce7; color: var(--success); border: 1px solid #bbf7d0; }
+ .status-msg.error { background: #fee2e2; color: var(--danger); border: 1px solid #fecaca; }
+
+ .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
+ @media (max-width: 768px) { .settings-grid { grid-template-columns: 1fr; } }
- .example-link:hover {
- color: var(--primary-dark);
+ .resume-hint {
+ background: var(--primary-light); border: 1px solid #93c5fd;
+ border-radius: var(--radius); padding: 10px 14px;
+ font-size: 13px; color: var(--primary-dark); margin-bottom: 16px;
}
-
-
-
-
+
+
+
+
+
+
-