diff --git a/.env.example b/.env.example index 000f11c4..8d7ceff5 100644 --- a/.env.example +++ b/.env.example @@ -21,9 +21,6 @@ OLLAMA_ENDPOINT=http://localhost:11434 ALIBABA_ENDPOINT=https://dashscope.aliyuncs.com/compatible-mode/v1 ALIBABA_API_KEY= -MODELSCOPE_ENDPOINT=https://api-inference.modelscope.cn/v1 -MODELSCOPE_API_KEY= - MOONSHOT_ENDPOINT=https://api.moonshot.cn/v1 MOONSHOT_API_KEY= diff --git a/.gitignore b/.gitignore index a7a55cd1..398a9f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,192 +1,8 @@ -# Byte-compiled / optimized / DLL files +# Python / Streamlit __pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments +*.pyc .env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ -test_env/ -myenv - - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ -temp -tmp - - -.DS_Store - -private_example.py -private_example - -browser_cookies.json -cookies.json -AgentHistory.json -cv_04_24.pdf -AgentHistoryList.json -*.gif - -# For Sharing (.pem files) -.gradio/ - -# For Docker -data/ - -# For Config Files (Current Settings) -.config.pkl -*.pdf +.streamlit/secrets.toml -workflow \ No newline at end of file +# Browser automation cache +playwright/.cache/ diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 00000000..be182e1f --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,41 @@ +[global] +# Streamlit configuration for Job Application Agent + +developmentMode = false +showWarningOnDirectExecution = false + +[server] +# Server configuration +port = 8501 +headless = true +runOnSave = true +allowRunOnSave = true +enableCORS = false +enableXsrfProtection = true +maxUploadSize = 200 + +[browser] +# Browser configuration +serverAddress = "localhost" +gatherUsageStats = false +serverPort = 8501 + +[theme] +# Theme configuration +primaryColor = "#1f77b4" +backgroundColor = "#ffffff" +secondaryBackgroundColor = "#f0f2f6" +textColor = "#262730" +font = "sans serif" + +[runner] +# Runner configuration +magicEnabled = true +postScriptGC = true +fastReruns = true +enforceSerializableSessionState = false + +[client] +# Client configuration +showErrorDetails = true +toolbarMode = "minimal" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d093f829..5a149830 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM python:3.11-slim-bookworm +FROM python:3.11.13-slim-bookworm # Set platform for multi-arch builds (Docker Buildx will set this) ARG TARGETPLATFORM ARG NODE_MAJOR=20 -# Install system dependencies (removed libgconf-2-4) +# Install system dependencies RUN apt-get update && apt-get install -y \ wget \ netcat-traditional \ @@ -29,8 +29,6 @@ RUN apt-get update && apt-get install -y \ libxrandr2 \ xdg-utils \ fonts-liberation \ - fonts-noto-color-emoji \ - fonts-unifont \ dbus \ xauth \ x11vnc \ @@ -44,8 +42,7 @@ RUN apt-get update && apt-get install -y \ fonts-dejavu \ fonts-dejavu-core \ fonts-dejavu-extra \ - vim \ - && rm -rf /var/lib/apt/lists/* + vim && rm -rf /var/lib/apt/lists/* # Install noVNC RUN git clone https://github.com/novnc/noVNC.git /opt/novnc \ @@ -57,10 +54,10 @@ RUN mkdir -p /etc/apt/keyrings \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ && apt-get update \ - && apt-get install -y nodejs \ + && apt-get install nodejs -y \ && rm -rf /var/lib/apt/lists/* -# Verify Node.js and npm installation +# Verify Node.js and npm installation (optional, but good for debugging) RUN node -v && npm -v && npx -v # Set up working directory @@ -68,16 +65,26 @@ WORKDIR /app # Copy requirements and install Python dependencies COPY requirements.txt . + RUN pip install --no-cache-dir -r requirements.txt -# Playwright setup +# Install playwright browsers and dependencies +# playwright documentation suggests PLAYWRIGHT_BROWSERS_PATH is still relevant +# or that playwright installs to a similar default location that Playwright would. +# Let's assume playwright respects PLAYWRIGHT_BROWSERS_PATH or its default install location is findable. ENV PLAYWRIGHT_BROWSERS_PATH=/ms-browsers RUN mkdir -p $PLAYWRIGHT_BROWSERS_PATH -# Install Chromium via Playwright without --with-deps -RUN PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0 playwright install chromium +# Install recommended: Google Chrome (instead of just Chromium for better undetectability) +# The 'playwright install chrome' command might download and place it. +# The '--with-deps' equivalent for playwright install is to run 'playwright install-deps chrome' after. +# RUN playwright install chrome --with-deps + +# Alternative: Install Chromium if Google Chrome is problematic in certain environments +RUN playwright install chromium --with-deps + -# Copy application code +# Copy the application code COPY . . # Set up supervisor configuration @@ -87,3 +94,4 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf EXPOSE 7788 6080 5901 9222 CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] +#CMD ["/bin/bash"] \ No newline at end of file diff --git a/SECURITY_GUIDE.md b/SECURITY_GUIDE.md new file mode 100644 index 00000000..9efb7956 --- /dev/null +++ b/SECURITY_GUIDE.md @@ -0,0 +1,171 @@ +# šŸ” Security Guide for Job Application Agent + +This guide ensures your OpenAI API key and other sensitive information remain secure while using the Job Application Agent. + +## āœ… Current Security Status + +Your application is already configured with proper security practices: + +- āœ… **Environment Variables**: API keys are loaded via `os.getenv()` from environment variables +- āœ… **No Hardcoded Keys**: No API keys are hardcoded in the source code +- āœ… **Gitignore Protection**: `.env` files are excluded from version control +- āœ… **Dotenv Loading**: Environment variables are properly loaded using `python-dotenv` + +## šŸ”‘ API Key Security Best Practices + +### 1. Environment Variable Setup + +**āœ… DO THIS:** +```bash +# Create .env file (never commit this!) +cp env.example .env + +# Edit .env with your real API key +OPENAI_API_KEY=sk-proj-your-actual-key-here +LLM_PROVIDER=openai +LLM_MODEL=gpt-4o +``` + +**āŒ NEVER DO THIS:** +```python +# Don't hardcode API keys in source code +api_key = "sk-proj-your-actual-key-here" # āŒ NEVER! +``` + +### 2. File Protection + +**Ensure these files are NEVER committed to git:** +``` +.env # Contains your real API keys +.env.local # Local environment overrides +.env.production # Production keys +*.pem # Certificate files +credentials.json # Any credential files +``` + +**Verify with:** +```bash +# Check if .env is gitignored +git check-ignore .env +# Should return: .env + +# Check git status (should not show .env) +git status +``` + +### 3. Environment Variable Access + +**Current secure implementation:** +```python +# āœ… Secure: Uses environment variables +api_key = os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY") + +# āœ… Secure: Fails gracefully if key is missing +if not api_key: + raise ValueError("API key not found in environment variables") +``` + +## šŸ›”ļø Additional Security Measures + +### 1. API Key Rotation + +**Regularly rotate your API keys:** +1. Generate new API key in OpenAI dashboard +2. Update `.env` file with new key +3. Delete old key from OpenAI dashboard +4. Test application with new key + +### 2. API Key Permissions + +**Limit OpenAI API key permissions:** +- āœ… Enable only necessary endpoints +- āœ… Set usage limits/quotas +- āœ… Monitor API usage regularly +- āœ… Use separate keys for development/production + +### 3. Network Security + +**When running the application:** +```bash +# āœ… Local development (secure) +python webui.py --ip 127.0.0.1 --port 7788 + +# āš ļø Public access (use with caution) +python webui.py --ip 0.0.0.0 --port 7788 +# Only use 0.0.0.0 on secure networks! +``` + +### 4. Browser Session Security + +**LinkedIn credentials security:** +- āœ… Credentials are passed as environment variables +- āœ… Not stored in application memory permanently +- āœ… Browser context is isolated per session +- āœ… Credentials are only used during active sessions + +## 🚨 Security Checklist + +Before running the application, verify: + +- [ ] `.env` file exists and contains your API key +- [ ] `.env` file is listed in `.gitignore` +- [ ] No API keys are hardcoded in source files +- [ ] API key has appropriate usage limits set +- [ ] You're running on a secure network +- [ ] Browser is configured with appropriate security settings + +## šŸ” Security Verification + +**Check for exposed secrets:** +```bash +# Search for potential API key leaks +grep -r "sk-proj-" . --exclude-dir=.git --exclude-dir=.venv +grep -r "sk-ant-" . --exclude-dir=.git --exclude-dir=.venv + +# Should only return example keys in documentation +``` + +**Verify environment loading:** +```python +# Test environment variable loading +import os +from dotenv import load_dotenv + +load_dotenv() +api_key = os.getenv("OPENAI_API_KEY") +print(f"API key loaded: {'āœ… Yes' if api_key else 'āŒ No'}") +print(f"Key starts with: {api_key[:7]}..." if api_key else "No key found") +``` + +## 🚨 If API Key is Compromised + +**Immediate actions:** +1. **Revoke the key immediately** in OpenAI dashboard +2. **Generate a new API key** +3. **Update your `.env` file** with the new key +4. **Monitor your OpenAI usage** for any unauthorized activity +5. **Check your git history** for any accidental commits: + ```bash + git log --oneline --grep="api" --grep="key" -i + git log -p --all -S "sk-proj-" | head -50 + ``` + +## šŸ“ž Support + +If you discover any security issues: +1. **DO NOT** post the issue publicly with API keys +2. Create a private issue or contact the maintainers +3. Include steps to reproduce (without sensitive data) +4. Provide suggested fixes if possible + +## šŸŽÆ Security Summary + +Your Job Application Agent is designed with security in mind: + +- **šŸ” Environment-based**: All secrets loaded from environment variables +- **🚫 No hardcoding**: No API keys in source code +- **šŸ›”ļø Git protection**: Sensitive files excluded from version control +- **⚔ Minimal exposure**: API keys only used when needed +- **šŸ”„ Rotation-friendly**: Easy to update keys without code changes + +**Remember: Your API key is like a password - keep it secret, keep it safe!** šŸ”‘ \ No newline at end of file diff --git a/STREAMLIT_DEPLOYMENT.md b/STREAMLIT_DEPLOYMENT.md new file mode 100644 index 00000000..0f114e2f --- /dev/null +++ b/STREAMLIT_DEPLOYMENT.md @@ -0,0 +1,271 @@ +# šŸš€ Streamlit Deployment Guide + +This guide covers deploying your LinkedIn Job Application Agent using Streamlit. + +## šŸ“‹ Quick Start + +### 1. Install Dependencies +```bash +pip install -r requirements.txt +``` + +### 2. Set Up Environment Variables +Create a `.env` file in the project root: +```bash +# Required +OPENAI_API_KEY=your_openai_api_key_here + +# Optional (with defaults) +LLM_PROVIDER=openai +LLM_MODEL=gpt-4o +LLM_TEMPERATURE=0.1 +LLM_MAX_TOKENS=2000 + +# Optional endpoints +OPENAI_ENDPOINT=https://api.openai.com/v1 +ANTHROPIC_ENDPOINT=https://api.anthropic.com +``` + +### 3. Run the Application + +#### Option A: Using the Runner Script (Recommended) +```bash +python run_streamlit.py +``` + +#### Option B: Direct Streamlit Command +```bash +streamlit run streamlit_app.py --server.port 8501 +``` + +### 4. Access the Application +Open your browser to: **http://localhost:8501** + +## 🌐 Deployment Options + +### Local Development +- Use `python run_streamlit.py` for local development +- The application runs on `localhost:8501` by default +- Browser window will open automatically + +### Streamlit Cloud (Free) +1. Push your code to GitHub +2. Connect to [Streamlit Cloud](https://streamlit.io/cloud) +3. Deploy directly from your repository +4. Add environment variables in Streamlit Cloud settings + +### Docker Deployment +```dockerfile +FROM python:3.9-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . + +EXPOSE 8501 + +CMD ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"] +``` + +### Cloud Platforms + +#### Heroku +1. Create `Procfile`: +``` +web: streamlit run streamlit_app.py --server.port=$PORT --server.address=0.0.0.0 +``` + +2. Deploy: +```bash +heroku create your-app-name +git push heroku main +``` + +#### AWS/GCP/Azure +- Use container deployment with the Docker image +- Set environment variables in cloud platform +- Configure load balancing if needed + +## āš™ļø Configuration + +### Streamlit Configuration +The app includes a `.streamlit/config.toml` file with optimized settings: +- Custom theme colors +- Performance optimizations +- Security settings +- Upload limits + +### Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `OPENAI_API_KEY` | āœ… | - | Your OpenAI API key | +| `LLM_PROVIDER` | āŒ | `openai` | AI provider (openai/anthropic) | +| `LLM_MODEL` | āŒ | `gpt-4o` | Model to use | +| `LLM_TEMPERATURE` | āŒ | `0.1` | Response creativity | +| `LLM_MAX_TOKENS` | āŒ | `2000` | Max response length | + +## šŸ”’ Security Considerations + +### API Keys +- āš ļø **Never** commit API keys to version control +- Use environment variables or secure secret management +- Consider using cloud platform secret managers + +### Browser Automation +- The app runs browser automation (Playwright) +- Ensure proper firewall settings in production +- Consider running in containerized environments + +### File Uploads +- Resume uploads are limited to 200MB +- Files are stored locally in `data/documents/` +- Implement cleanup policies for production + +## šŸš€ Performance Optimization + +### Caching +```python +@st.cache_data +def load_profile_data(): + # Cache expensive operations + pass +``` + +### Session State +- Profile data is stored in Streamlit session state +- Browser instances are reused when possible +- Cleanup happens automatically on session end + +### Resource Management +- Browser instances are properly closed +- Temporary files are cleaned up +- Memory usage is optimized with streaming responses + +## šŸ› Troubleshooting + +### Common Issues + +#### "Module not found" errors +```bash +pip install -r requirements.txt +``` + +#### Browser automation fails +- Check if Playwright browsers are installed: +```bash +playwright install +``` + +#### Streamlit won't start +- Check port 8501 is available +- Verify Python version (3.8+ required) +- Check environment variables are set + +#### LLM connection fails +- Verify API key is correct and has credits +- Check network connectivity +- Ensure correct model name + +### Debug Mode +Add to your environment: +```bash +STREAMLIT_DEBUG=true +``` + +### Logs +Check application logs in: +- Local: Terminal output +- Cloud: Platform-specific logs +- Docker: `docker logs ` + +## šŸ“Š Monitoring + +### Application Health +- Built-in health checks in config tab +- LLM connection testing +- Environment variable validation + +### Usage Tracking +- Application history is automatically tracked +- Export functionality for analytics +- Session state debugging tools + +## šŸ”„ Migration from Gradio + +The Streamlit version maintains all functionality from the original Gradio version: + +### Key Differences +- **UI Framework**: Streamlit vs Gradio +- **Session Management**: Streamlit session state vs custom manager +- **Deployment**: More deployment options with Streamlit +- **Performance**: Generally faster with better caching + +### Data Compatibility +- All profile data remains compatible +- Application history format unchanged +- Configuration files can be imported/exported + +## šŸ“ˆ Scaling + +### Single User +- Perfect for personal use +- Local or cloud deployment +- Minimal resource requirements + +### Multi-User (Enterprise) +- Deploy on cloud platforms +- Consider user authentication +- Implement data isolation +- Use container orchestration + +### High Availability +- Load balancer setup +- Database for shared state +- Redis for session management +- Container clustering + +## šŸ“ Development + +### Local Development +```bash +# Install in development mode +pip install -e . + +# Run with auto-reload +streamlit run streamlit_app.py --server.runOnSave=true +``` + +### Adding Features +- Components are in `src/webui/streamlit_components/` +- Follow existing patterns for state management +- Test with different browsers and screen sizes + +### Testing +```bash +# Unit tests +python -m pytest tests/ + +# Integration tests +python -m pytest tests/test_streamlit_integration.py +``` + +## šŸ†˜ Support + +For issues and questions: +1. Check this documentation +2. Review environment variable setup +3. Test LLM connection in config tab +4. Check application logs +5. Open an issue on GitHub + +## šŸŽÆ Next Steps + +After successful deployment: +1. Set up your profile in the Profile Settings tab +2. Configure browser settings as needed +3. Test with a few job applications +4. Review application history +5. Export configurations for backup \ No newline at end of file diff --git a/data/applications/applications.json b/data/applications/applications.json new file mode 100644 index 00000000..d6db2c3e --- /dev/null +++ b/data/applications/applications.json @@ -0,0 +1,44 @@ +[ + { + "id": 1, + "job_title": "Applied AI Engineer", + "company": "USAFacts", + "job_url": "https://www.linkedin.com/jobs/search/?currentJobId=4270784374&f_E=2%2C4&f_TPR=r86400&geoId=103644278&keywords=data%20scientist&origin=JOB_SEARCH_PAGE_JOB_FILTER&refresh=true&spellCorrectionEnabled=true", + "status": "Submitted", + "applied_date": "2025-07-22T15:12:37.194415", + "notes": "Application submitted successfully.", + "application_method": "Easy Apply", + "job_location": "", + "salary_range": "", + "application_duration_seconds": 0.0, + "platform": "LinkedIn" + }, + { + "id": 2, + "job_title": "Data Scientist", + "company": "LanceSoft, Inc.", + "job_url": "https://www.linkedin.com/jobs/search/post-apply/default/?currentJobId=4270787019&f_E=2%2C4&f_TPR=r86400&geoId=103644278&keywords=data%20scientist&origin=JOB_SEARCH_PAGE_JOB_FILTER&postApplyJobId=4270787019&refresh=true&spellCorrectionEnabled=true", + "status": "Success", + "applied_date": "2025-07-22T16:02:44.363467", + "notes": "Application sent to LanceSoft, Inc.", + "application_method": "Easy Apply", + "job_location": "", + "salary_range": "", + "application_duration_seconds": 0.0, + "platform": "LinkedIn" + }, + { + "id": 3, + "job_title": "Python Developer with testing experience (Healthcare)", + "company": "FUSTIS LLC", + "job_url": "https://www.linkedin.com/jobs/search/post-apply/default/?currentJobId=4272212204&f_E=2%2C4&f_TPR=r86400&geoId=103644278&keywords=data%20scientist&origin=JOB_SEARCH_PAGE_JOB_FILTER&postApplyJobId=4272212204&refresh=true&spellCorrectionEnabled=true", + "status": "Success", + "applied_date": "2025-07-22T16:51:30.169346", + "notes": "Application sent successfully to FUSTIS LLC.", + "application_method": "Easy Apply", + "job_location": "", + "salary_range": "", + "application_duration_seconds": 0.0, + "platform": "LinkedIn" + } +] \ No newline at end of file diff --git a/data/profile/documents/Dinesh_Satram_Resume_DS_.pdf b/data/profile/documents/Dinesh_Satram_Resume_DS_.pdf new file mode 100644 index 00000000..16e446b7 Binary files /dev/null and b/data/profile/documents/Dinesh_Satram_Resume_DS_.pdf differ diff --git a/data/profile/profile.json b/data/profile/profile.json new file mode 100644 index 00000000..685a3cc3 --- /dev/null +++ b/data/profile/profile.json @@ -0,0 +1,50 @@ +{ + "personal": { + "first_name": "Dinesh", + "last_name": "Satram", + "email": "dineshsatram05@gmail.com", + "phone": "8067025056", + "city": "Atlanta", + "state": "Georgia", + "address": "2591 piedmont rd", + "zip_code": "30324", + "country": "United states", + "linkedin_profile": "" + }, + "professional": { + "current_position": "graduate research assistant", + "current_company": "georgia State University", + "industry": "", + "current_salary": 0, + "experience_years": 2, + "work_experience": "", + "skills": [] + }, + "education": { + "education_level": "Master's Degree", + "degree_field": "Computer Science", + "university": "Georgia State University", + "graduation_year": 2023 + }, + "preferences": { + "work_authorization": "F1 OPT", + "salary_min": 0, + "availability": "Immediately", + "remote_preference": "Remote", + "willing_to_relocate": "Yes", + "security_clearance": "None", + "visa_sponsorship": "No", + "notice_period": "Immediately" + }, + "eeo_information": { + "gender": "Male", + "ethnicity": "Asian", + "veteran_status": "Not a veteran", + "disability_status": "No disability" + }, + "documents": { + "cover_letter_template": "", + "resume_path": "data/documents/Dinesh_Satram_Resume_DS_A.pdf", + "resume_name": "Dinesh_Satram_Resume_DS_A.pdf" + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 97fdd2c4..71e8f755 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,10 +8,10 @@ services: args: TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64} ports: - - "7788:7788" - - "6080:6080" - - "5901:5901" - - "9222:9222" + - "8501:8501" + - "6080:6080" + - "5901:5901" + - "9222:9222" environment: # LLM API Keys & Endpoints - OPENAI_ENDPOINT=${OPENAI_ENDPOINT:-https://api.openai.com/v1} @@ -40,6 +40,13 @@ services: - IBM_PROJECT_ID=${IBM_PROJECT_ID:-} # Application Settings + # Application Settings + + # LLM Configuration + - LLM_PROVIDER=${LLM_PROVIDER:-ollama} + - LLM_MODEL=${LLM_MODEL:-qwen3:8b} + - LLM_TEMPERATURE=${LLM_TEMPERATURE:-0.1} + - ANONYMIZED_TELEMETRY=${ANONYMIZED_TELEMETRY:-false} - BROWSER_USE_LOGGING_LEVEL=${BROWSER_USE_LOGGING_LEVEL:-info} @@ -68,7 +75,7 @@ services: - /tmp/.X11-unix:/tmp/.X11-unix # - ./my_chrome_data:/app/data/chrome_data # Optional: persist browser data restart: unless-stopped - shm_size: "2gb" + shm_size: '2gb' cap_add: - SYS_ADMIN tmpfs: @@ -77,4 +84,4 @@ services: test: ["CMD", "nc", "-z", "localhost", "5901"] # VNC port interval: 10s timeout: 5s - retries: 3 + retries: 3 \ No newline at end of file diff --git a/env.example b/env.example new file mode 100644 index 00000000..eeea39f5 --- /dev/null +++ b/env.example @@ -0,0 +1,54 @@ +# Job Application Agent Environment Variables +# Copy this file to .env and fill in your actual values +# NEVER commit .env to git - it contains your private API keys + +# ================================ +# LLM Configuration (Required) +# ================================ + +# OpenAI Configuration +OPENAI_API_KEY=your_openai_api_key_here +LLM_PROVIDER=openai +LLM_MODEL=gpt-4o +LLM_TEMPERATURE=0.1 + +# Alternative: Anthropic Configuration +# ANTHROPIC_API_KEY=your_anthropic_api_key_here +# LLM_PROVIDER=anthropic +# LLM_MODEL=claude-3-sonnet-20240229 + +# Alternative: Azure OpenAI Configuration +# AZURE_OPENAI_API_KEY=your_azure_api_key_here +# AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +# LLM_PROVIDER=azure_openai +# LLM_MODEL=gpt-4 + +# ================================ +# Browser Configuration (Optional) +# ================================ + +# Browser Settings +BROWSER_HEADLESS=false +BROWSER_PATH="" +BROWSER_USER_DATA="" + +# ================================ +# Application Configuration (Optional) +# ================================ + +# Web UI Settings +WEBUI_HOST=127.0.0.1 +WEBUI_PORT=7788 + +# Data Storage +PROFILE_DIR=data +APPLICATIONS_DIR=data + +# ================================ +# Security Notes +# ================================ +# 1. NEVER share your .env file +# 2. NEVER commit .env to version control +# 3. Keep your API keys secure and rotate them regularly +# 4. Use environment variables, not hardcoded values +# 5. Limit API key permissions where possible \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f7055242..60f414a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ browser-use==0.1.48 pyperclip==1.9.0 -gradio==5.27.0 +streamlit==1.31.0 json-repair langchain-mistralai==0.2.4 MainContentExtractor==0.0.4 -langchain-ibm==0.3.10 +langchain-ibm==0.3.15 langchain_mcp_adapters==0.0.9 langgraph==0.3.34 langchain-community +python-dotenv diff --git a/run_streamlit.py b/run_streamlit.py new file mode 100644 index 00000000..1670dc6d --- /dev/null +++ b/run_streamlit.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Simple runner script for ApplyAgent.AI (Streamlit Version) +""" + +import subprocess +import sys +import os +from pathlib import Path + +def main(): + """Run the Streamlit application""" + + # Ensure we're in the project directory + project_root = Path(__file__).parent + os.chdir(project_root) + + # Check if .env file exists + env_file = project_root / ".env" + if not env_file.exists(): + print("āš ļø Warning: .env file not found!") + print("šŸ“ Please create a .env file with your configuration:") + print(" OPENAI_API_KEY=your_openai_api_key_here") + print(" LLM_PROVIDER=openai") + print(" LLM_MODEL=gpt-4o") + print(" LLM_TEMPERATURE=0.1") + print() + + # Run Streamlit + print("šŸš€ Starting ApplyAgent.AI (Streamlit)...") + print("🌐 The application will open in your browser at http://localhost:8501") + print("šŸ›‘ Press Ctrl+C to stop the application") + print() + + try: + subprocess.run([ + sys.executable, + "-m", + "streamlit", + "run", + "streamlit_app.py", + "--server.port=8501", + "--server.headless=false", + "--browser.gatherUsageStats=false" + ], check=True) + except KeyboardInterrupt: + print("\nšŸ›‘ Application stopped by user") + except subprocess.CalledProcessError as e: + print(f"āŒ Error running Streamlit: {e}") + print("šŸ’” Make sure Streamlit is installed: pip install streamlit") + except Exception as e: + print(f"āŒ Unexpected error: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/browser/custom_browser.py b/src/browser/custom_browser.py index 1556959d..2f32401c 100644 --- a/src/browser/custom_browser.py +++ b/src/browser/custom_browser.py @@ -32,14 +32,103 @@ class CustomBrowser(Browser): + + async def async_start(self): + """Start the browser instance asynchronously""" + if hasattr(self, '_browser') and self._browser: + logger.info("Browser already started") + return + + try: + # Create Playwright instance and launch browser + self._playwright = await async_playwright().start() + + # Check if external browser path is specified + if self.config.browser_binary_path: + self._browser = await self._setup_external_browser(self._playwright) + else: + self._browser = await self._setup_builtin_browser(self._playwright) + + logger.info("Browser started successfully") + except Exception as e: + logger.error(f"Failed to start browser: {e}") + raise + + async def close(self): + """Close the browser and clean up resources""" + try: + if hasattr(self, '_browser') and self._browser: + await self._browser.close() + logger.info("Browser closed") + if hasattr(self, '_playwright') and self._playwright: + await self._playwright.stop() + logger.info("Playwright stopped") + except Exception as e: + logger.error(f"Error closing browser: {e}") + + async def create_context(self, config: BrowserContextConfig | None = None) -> CustomBrowserContext: + """Create a browser context (alias for new_context for compatibility)""" + return await self.new_context(config) async def new_context(self, config: BrowserContextConfig | None = None) -> CustomBrowserContext: """Create a browser context""" + # Ensure browser is started + if not hasattr(self, '_browser') or not self._browser: + await self.async_start() + browser_config = self.config.model_dump() if self.config else {} context_config = config.model_dump() if config else {} merged_config = {**browser_config, **context_config} return CustomBrowserContext(config=BrowserContextConfig(**merged_config), browser=self) + async def _setup_external_browser(self, playwright: Playwright) -> PlaywrightBrowser: + """Sets up and returns an external Browser instance (like Chrome).""" + assert self.config.browser_binary_path is not None, 'browser_binary_path should be set for external browsers' + + # Use configured window size + if ( + not self.config.headless + and hasattr(self.config, 'new_context_config') + and hasattr(self.config.new_context_config, 'window_width') + and hasattr(self.config.new_context_config, 'window_height') + ): + screen_size = { + 'width': self.config.new_context_config.window_width, + 'height': self.config.new_context_config.window_height, + } + offset_x, offset_y = get_window_adjustments() + elif self.config.headless: + screen_size = {'width': 1920, 'height': 1080} + offset_x, offset_y = 0, 0 + else: + screen_size = get_screen_resolution() + offset_x, offset_y = get_window_adjustments() + + # Build chrome args for external browser + chrome_args = [ + f'--remote-debugging-port={self.config.chrome_remote_debugging_port}', + *CHROME_ARGS, + *(CHROME_DOCKER_ARGS if IN_DOCKER else []), + *(CHROME_HEADLESS_ARGS if self.config.headless else []), + *(CHROME_DISABLE_SECURITY_ARGS if self.config.disable_security else []), + *(CHROME_DETERMINISTIC_RENDERING_ARGS if self.config.deterministic_rendering else []), + f'--window-position={offset_x},{offset_y}', + f'--window-size={screen_size["width"]},{screen_size["height"]}', + ] + + # Add extra browser args if provided + if self.config.extra_browser_args: + chrome_args.extend(self.config.extra_browser_args) + + logger.info(f"Launching external browser: {self.config.browser_binary_path}") + browser = await playwright.chromium.launch( + executable_path=self.config.browser_binary_path, + headless=self.config.headless, + args=chrome_args, + channel=None, # Don't use channel for external browsers + ) + return browser + async def _setup_builtin_browser(self, playwright: Playwright) -> PlaywrightBrowser: """Sets up and returns a Playwright Browser instance with anti-detection measures.""" assert self.config.browser_binary_path is None, 'browser_binary_path should be None if trying to use the builtin browsers' diff --git a/src/controller/custom_controller.py b/src/controller/custom_controller.py index 00e050c5..7fd5d4db 100644 --- a/src/controller/custom_controller.py +++ b/src/controller/custom_controller.py @@ -1,4 +1,8 @@ import pdb +import json +import os +from datetime import datetime +from pathlib import Path import pyperclip from typing import Optional, Type, Callable, Dict, Any, Union, Awaitable, TypeVar @@ -35,6 +39,11 @@ Context = TypeVar('Context') +# Profile and Application Storage Paths +PROFILE_DIR = "./data/profile" +APPLICATIONS_DIR = "./data/applications" +PROFILE_FILE = os.path.join(PROFILE_DIR, "profile.json") +APPLICATIONS_FILE = os.path.join(APPLICATIONS_DIR, "applications.json") class CustomController(Controller): def __init__(self, exclude_actions: list[str] = [], @@ -44,9 +53,16 @@ def __init__(self, exclude_actions: list[str] = [], ): super().__init__(exclude_actions=exclude_actions, output_model=output_model) self._register_custom_actions() + self._register_job_application_tools() self.ask_assistant_callback = ask_assistant_callback self.mcp_client = None self.mcp_server_config = None + self._ensure_data_directories() + + def _ensure_data_directories(self): + """Ensure data directories exist""" + os.makedirs(PROFILE_DIR, exist_ok=True) + os.makedirs(APPLICATIONS_DIR, exist_ok=True) def _register_custom_actions(self): """Register all custom browser actions""" @@ -106,6 +122,372 @@ async def upload_file(index: int, path: str, browser: BrowserContext, available_ logger.info(msg) return ActionResult(error=msg) + def _register_job_application_tools(self): + """Register job application specific tools""" + + @self.registry.action( + "Get the user's job application profile including personal details, experience, skills, and preferences" + ) + async def get_profile() -> Dict[str, Any]: + """Retrieve the user's job application profile""" + try: + if os.path.exists(PROFILE_FILE): + with open(PROFILE_FILE, 'r', encoding='utf-8') as f: + profile = json.load(f) + return ActionResult( + extracted_content=f"Profile retrieved successfully: {json.dumps(profile, indent=2)}", + include_in_memory=True + ) + else: + # Return default empty profile structure + default_profile = { + "personal": { + "full_name": "", + "email": "", + "phone": "", + "address": "", + "linkedin_url": "", + "portfolio_url": "" + }, + "professional": { + "current_position": { + "job_title": "", + "company": "", + "start_date": "", + "end_date": "Present", + "work_description": "" + }, + "previous_positions": [ + # Each will be: {"job_title": "", "company": "", "start_date": "", "end_date": "", "work_description": ""} + ], + "years_experience": 0, + "skills": [], + "education": [] + }, + "preferences": { + "target_roles": [], + "target_locations": [], + "salary_min": 0, + "work_authorization": "", + "visa_status": "", + "availability": "", + "remote_preference": "" + }, + "eeo_information": { + "race_ethnicity": "Prefer not to answer", + "gender": "Prefer not to answer", + "veteran_status": "Prefer not to answer", + "disability_status": "Prefer not to answer", + "voluntary_disclosure": True + }, + "documents": { + "resume_path": "", + "cover_letter_template": "" + } + } + return ActionResult( + extracted_content=f"No profile found. Default profile structure: {json.dumps(default_profile, indent=2)}", + include_in_memory=True + ) + except Exception as e: + logger.error(f"Error retrieving profile: {e}") + return ActionResult( + extracted_content=f"Error retrieving profile: {str(e)}", + include_in_memory=True + ) + + @self.registry.action( + "Update the user's job application profile with new information" + ) + async def update_profile(profile_data: Dict[str, Any]) -> Dict[str, Any]: + """Update the user's job application profile""" + try: + # Load existing profile or create new one + existing_profile = {} + if os.path.exists(PROFILE_FILE): + with open(PROFILE_FILE, 'r', encoding='utf-8') as f: + existing_profile = json.load(f) + + # Deep merge the new data with existing profile + def deep_merge(existing, new): + for key, value in new.items(): + if key in existing and isinstance(existing[key], dict) and isinstance(value, dict): + deep_merge(existing[key], value) + else: + existing[key] = value + return existing + + updated_profile = deep_merge(existing_profile, profile_data) + updated_profile["last_updated"] = datetime.now().isoformat() + + # Save updated profile + with open(PROFILE_FILE, 'w', encoding='utf-8') as f: + json.dump(updated_profile, f, indent=2, ensure_ascii=False) + + return ActionResult( + extracted_content=f"Profile updated successfully: {json.dumps(updated_profile, indent=2)}", + include_in_memory=True + ) + except Exception as e: + logger.error(f"Error updating profile: {e}") + return ActionResult( + extracted_content=f"Error updating profile: {str(e)}", + include_in_memory=True + ) + + @self.registry.action( + "Log a job application attempt with detailed results and metadata" + ) + async def log_application( + job_title: str, + company: str, + job_url: str, + status: str, + notes: str = "", + application_method: str = "Easy Apply", + job_location: str = "", + salary_range: str = "", + application_duration: float = 0.0 + ) -> Dict[str, Any]: + """Log a job application attempt with comprehensive metadata""" + try: + # Load existing applications + applications = [] + if os.path.exists(APPLICATIONS_FILE): + with open(APPLICATIONS_FILE, 'r', encoding='utf-8') as f: + applications = json.load(f) + + # Create new application entry with enhanced data + application_entry = { + "id": len(applications) + 1, + "job_title": job_title, + "company": company, + "job_url": job_url, + "status": status, # "submitted", "failed", "skipped" + "applied_date": datetime.now().isoformat(), + "notes": notes, + "application_method": application_method, + "job_location": job_location, + "salary_range": salary_range, + "application_duration_seconds": application_duration, + "platform": "LinkedIn" if "linkedin.com" in job_url else "Other" + } + + applications.append(application_entry) + + # Save updated applications list + with open(APPLICATIONS_FILE, 'w', encoding='utf-8') as f: + json.dump(applications, f, indent=2, ensure_ascii=False) + + return ActionResult( + extracted_content=f"Application logged: {application_entry['company']} - {application_entry['job_title']} ({status})", + include_in_memory=True + ) + except Exception as e: + logger.error(f"Error logging application: {e}") + return ActionResult( + extracted_content=f"Error logging application: {str(e)}", + include_in_memory=True + ) + + @self.registry.action( + "Retrieve the list of all job applications with their status and analytics" + ) + async def list_applications(limit: int = 50, status_filter: str = "all") -> Dict[str, Any]: + """List all job applications with filtering and analytics""" + try: + if os.path.exists(APPLICATIONS_FILE): + with open(APPLICATIONS_FILE, 'r', encoding='utf-8') as f: + applications = json.load(f) + + # Filter applications if requested + if status_filter.lower() != "all": + applications = [app for app in applications if app.get('status', '').lower() == status_filter.lower()] + + # Sort by date (most recent first) and limit results + applications.sort(key=lambda x: x.get('applied_date', ''), reverse=True) + limited_applications = applications[:limit] + + # Calculate analytics + total_applications = len(applications) + platform_stats = {} + status_stats = { + "submitted": 0, + "failed": 0, + "skipped": 0 + } + + for app in applications: + platform = app.get('platform', 'Other') + platform_stats[platform] = platform_stats.get(platform, 0) + 1 + + status = app.get('status', 'unknown') + if status in status_stats: + status_stats[status] += 1 + + summary = { + "total_applications": total_applications, + "recent_applications": limited_applications, + "stats": status_stats, + "platform_stats": platform_stats, + "success_rate": (status_stats["submitted"] / total_applications * 100) if total_applications > 0 else 0, + "filter_applied": status_filter, + "results_limited_to": limit + } + + return ActionResult( + extracted_content=f"Applications retrieved: {json.dumps(summary, indent=2)}", + include_in_memory=True + ) + else: + return ActionResult( + extracted_content="No applications found. Application history is empty.", + include_in_memory=True + ) + except Exception as e: + logger.error(f"Error listing applications: {e}") + return ActionResult( + extracted_content=f"Error listing applications: {str(e)}", + include_in_memory=True + ) + + @self.registry.action( + "Extract job details from a LinkedIn job search results page" + ) + async def extract_linkedin_jobs(page_url: str, max_jobs: int = 25) -> Dict[str, Any]: + """Extract Easy Apply job listings from LinkedIn search results""" + try: + # This would be called by the browser automation agent + # The actual extraction logic is handled in the browser automation + return ActionResult( + extracted_content=f"LinkedIn job extraction initiated for: {page_url} (max {max_jobs} jobs)", + include_in_memory=True + ) + except Exception as e: + logger.error(f"Error in LinkedIn job extraction: {e}") + return ActionResult( + extracted_content=f"Error extracting LinkedIn jobs: {str(e)}", + include_in_memory=True + ) + + @self.registry.action( + "Check if a job application form field should be filled based on profile data" + ) + async def get_field_value(field_name: str, field_type: str = "text", context: str = "") -> Dict[str, Any]: + """Get the appropriate value for a job application form field from profile data""" + try: + # Load current profile + profile = {} + if os.path.exists(PROFILE_FILE): + with open(PROFILE_FILE, 'r', encoding='utf-8') as f: + profile = json.load(f) + + field_name_lower = field_name.lower() + suggested_value = "" + confidence = "high" + + # Personal information mapping + if "first name" in field_name_lower or field_name_lower == "firstname": + full_name = profile.get("personal", {}).get("full_name", "") + suggested_value = full_name.split()[0] if full_name else "" + elif "last name" in field_name_lower or field_name_lower == "lastname": + full_name = profile.get("personal", {}).get("full_name", "") + suggested_value = " ".join(full_name.split()[1:]) if full_name and len(full_name.split()) > 1 else "" + elif "email" in field_name_lower: + suggested_value = profile.get("personal", {}).get("email", "") + elif "phone" in field_name_lower: + suggested_value = profile.get("personal", {}).get("phone", "") + elif "address" in field_name_lower or "location" in field_name_lower: + suggested_value = profile.get("personal", {}).get("address", "") + elif "linkedin" in field_name_lower: + suggested_value = profile.get("personal", {}).get("linkedin_url", "") + + # Professional information mapping + elif "current" in field_name_lower and "title" in field_name_lower: + suggested_value = profile.get("professional", {}).get("current_position", {}).get("job_title", "") + elif "current" in field_name_lower and "company" in field_name_lower: + suggested_value = profile.get("professional", {}).get("current_position", {}).get("company", "") + elif "experience" in field_name_lower and "years" in field_name_lower: + suggested_value = str(profile.get("professional", {}).get("years_experience", 0)) + + # Preferences and requirements + elif "salary" in field_name_lower: + suggested_value = str(profile.get("preferences", {}).get("salary_min", "")) + elif "authorization" in field_name_lower or "visa" in field_name_lower: + suggested_value = profile.get("preferences", {}).get("work_authorization", "") + elif "start date" in field_name_lower or "availability" in field_name_lower: + suggested_value = profile.get("preferences", {}).get("availability", "") + elif "remote" in field_name_lower: + suggested_value = profile.get("preferences", {}).get("remote_preference", "") + + # EEO fields + elif "race" in field_name_lower or "ethnicity" in field_name_lower: + suggested_value = profile.get("eeo_information", {}).get("race_ethnicity", "Prefer not to answer") + elif "gender" in field_name_lower: + suggested_value = profile.get("eeo_information", {}).get("gender", "Prefer not to answer") + elif "veteran" in field_name_lower: + suggested_value = profile.get("eeo_information", {}).get("veteran_status", "Prefer not to answer") + elif "disability" in field_name_lower: + suggested_value = profile.get("eeo_information", {}).get("disability_status", "Prefer not to answer") + + else: + suggested_value = "" + confidence = "low" + + result = { + "field_name": field_name, + "suggested_value": suggested_value, + "confidence": confidence, + "field_type": field_type, + "context": context, + "found_in_profile": bool(suggested_value) + } + + return ActionResult( + extracted_content=f"Field mapping result: {json.dumps(result, indent=2)}", + include_in_memory=True + ) + + except Exception as e: + logger.error(f"Error getting field value: {e}") + return ActionResult( + extracted_content=f"Error getting field value for {field_name}: {str(e)}", + include_in_memory=True + ) + + @self.registry.action( + "Start application session tracking for progress monitoring" + ) + async def start_application_session(session_name: str, total_expected_jobs: int = 0) -> Dict[str, Any]: + """Start tracking an application session for progress monitoring""" + try: + session_data = { + "session_name": session_name, + "start_time": datetime.now().isoformat(), + "total_expected_jobs": total_expected_jobs, + "jobs_processed": 0, + "applications_submitted": 0, + "applications_failed": 0, + "status": "running" + } + + # Store session data in a temporary location for real-time tracking + session_file = f"./data/profile/current_session.json" + with open(session_file, 'w', encoding='utf-8') as f: + json.dump(session_data, f, indent=2, ensure_ascii=False) + + return ActionResult( + extracted_content=f"Application session started: {session_name}", + include_in_memory=True + ) + + except Exception as e: + logger.error(f"Error starting application session: {e}") + return ActionResult( + extracted_content=f"Error starting session: {str(e)}", + include_in_memory=True + ) + @time_execution_sync('--act') async def act( self, diff --git a/src/utils/llm_provider.py b/src/utils/llm_provider.py index 2ef3d638..36da5536 100644 --- a/src/utils/llm_provider.py +++ b/src/utils/llm_provider.py @@ -349,7 +349,6 @@ def get_llm_model(provider: str, **kwargs): base_url=base_url, model_name=kwargs.get("model_name", "Qwen/QwQ-32B"), temperature=kwargs.get("temperature", 0.0), - extra_body = {"enable_thinking": False} ) else: raise ValueError(f"Unsupported provider: {provider}") diff --git a/src/webui/components/application_history_tab.py b/src/webui/components/application_history_tab.py new file mode 100644 index 00000000..bfae9056 --- /dev/null +++ b/src/webui/components/application_history_tab.py @@ -0,0 +1,306 @@ +import os +import json +import logging +import gradio as gr +from datetime import datetime +from typing import Dict, Any, List + +from src.webui.webui_manager import WebuiManager + +logger = logging.getLogger(__name__) + +APPLICATIONS_FILE = "./data/applications/applications.json" + +def load_applications(): + """Load applications from file""" + try: + if os.path.exists(APPLICATIONS_FILE): + with open(APPLICATIONS_FILE, 'r', encoding='utf-8') as f: + applications = json.load(f) + # Sort by date (most recent first) + applications.sort(key=lambda x: x.get('applied_date', ''), reverse=True) + return applications + else: + return [] + except Exception as e: + logger.error(f"Error loading applications: {e}") + return [] + +def format_applications_table(applications: List[Dict], status_filter: str = "All") -> tuple: + """Format applications for display in a table""" + if not applications: + return [], "No applications found." + + # Filter by status if specified + if status_filter != "All": + applications = [app for app in applications if app.get('status', '').lower() == status_filter.lower()] + + # Create table data + table_data = [] + for app in applications: + # Format date for display + applied_date = app.get('applied_date', '') + if applied_date: + try: + date_obj = datetime.fromisoformat(applied_date.replace('Z', '+00:00')) + formatted_date = date_obj.strftime('%Y-%m-%d %H:%M') + except: + formatted_date = applied_date[:16] if len(applied_date) > 16 else applied_date + else: + formatted_date = 'Unknown' + + # Format status with emoji + status = app.get('status', 'unknown') + status_emoji = { + 'submitted': 'āœ… Submitted', + 'failed': 'āŒ Failed', + 'skipped': 'ā­ļø Skipped' + }.get(status.lower(), f'ā“ {status}') + + table_data.append([ + app.get('id', ''), + app.get('company', 'Unknown'), + app.get('job_title', 'Unknown'), + status_emoji, + formatted_date, + app.get('job_url', '')[:50] + '...' if len(app.get('job_url', '')) > 50 else app.get('job_url', ''), + app.get('notes', '')[:100] + '...' if len(app.get('notes', '')) > 100 else app.get('notes', '') + ]) + + # Generate summary + total = len(applications) + summary = f"Showing {len(table_data)} of {total} applications" + + return table_data, summary + +def get_application_stats(applications: List[Dict]) -> Dict[str, int]: + """Get statistics about applications""" + stats = { + 'total': len(applications), + 'submitted': 0, + 'failed': 0, + 'skipped': 0 + } + + for app in applications: + status = app.get('status', '').lower() + if status in stats: + stats[status] += 1 + + return stats + +def refresh_applications_display(status_filter: str): + """Refresh the applications display with current filter""" + applications = load_applications() + table_data, summary = format_applications_table(applications, status_filter) + stats = get_application_stats(applications) + + # Create stats display + stats_text = f""" + šŸ“Š **Application Statistics:** + - **Total Applications:** {stats['total']} + - **āœ… Submitted:** {stats['submitted']} + - **āŒ Failed:** {stats['failed']} + - **ā­ļø Skipped:** {stats['skipped']} + """ + + return table_data, summary, stats_text + +def delete_application(application_id: int): + """Delete an application by ID""" + try: + applications = load_applications() + # Filter out the application with the given ID + updated_applications = [app for app in applications if app.get('id') != application_id] + + if len(updated_applications) < len(applications): + # Save updated list + os.makedirs(os.path.dirname(APPLICATIONS_FILE), exist_ok=True) + with open(APPLICATIONS_FILE, 'w', encoding='utf-8') as f: + json.dump(updated_applications, f, indent=2, ensure_ascii=False) + return True, f"Application #{application_id} deleted successfully" + else: + return False, f"Application #{application_id} not found" + except Exception as e: + logger.error(f"Error deleting application: {e}") + return False, f"Error deleting application: {str(e)}" + +def clear_all_applications(): + """Clear all applications""" + try: + if os.path.exists(APPLICATIONS_FILE): + os.remove(APPLICATIONS_FILE) + return True, "All applications cleared successfully" + except Exception as e: + logger.error(f"Error clearing applications: {e}") + return False, f"Error clearing applications: {str(e)}" + +def create_application_history_tab(webui_manager: WebuiManager): + """ + Creates an application history tab for viewing job application results. + """ + tab_components = {} + + with gr.Column(): + gr.Markdown("## šŸ“‹ Job Application History") + + # Control panel + with gr.Row(): + status_filter = gr.Dropdown( + label="Filter by Status", + choices=["All", "Submitted", "Failed", "Skipped"], + value="All", + scale=2 + ) + refresh_button = gr.Button("šŸ”„ Refresh", variant="secondary", scale=1) + clear_all_button = gr.Button("šŸ—‘ļø Clear All", variant="stop", scale=1) + + # Statistics display + stats_display = gr.Markdown( + value="šŸ“Š **Application Statistics:** No applications yet.", + visible=True + ) + + # Applications table + applications_table = gr.Dataframe( + headers=["ID", "Company", "Job Title", "Status", "Applied Date", "URL", "Notes"], + datatype=["number", "str", "str", "str", "str", "str", "str"], + col_count=(7, "fixed"), + row_count=(10, "dynamic"), + interactive=False, + wrap=True, + label="Applications" + ) + + summary_text = gr.Textbox( + label="Summary", + value="No applications loaded yet.", + interactive=False, + max_lines=1 + ) + + # Delete specific application + with gr.Row(): + delete_id_input = gr.Number( + label="Application ID to Delete", + minimum=1, + precision=0, + scale=3 + ) + delete_button = gr.Button("šŸ—‘ļø Delete Application", variant="secondary", scale=1) + + # Status message + status_message = gr.Textbox( + label="Status", + interactive=False, + visible=False + ) + + # Store components + tab_components.update({ + "status_filter": status_filter, + "refresh_button": refresh_button, + "clear_all_button": clear_all_button, + "stats_display": stats_display, + "applications_table": applications_table, + "summary_text": summary_text, + "delete_id_input": delete_id_input, + "delete_button": delete_button, + "status_message": status_message + }) + + webui_manager.add_components("application_history", tab_components) + + # Event handlers + def on_refresh(status_filter_value): + """Handle refresh button click""" + table_data, summary, stats_text = refresh_applications_display(status_filter_value) + return ( + gr.update(value=table_data), # applications_table + gr.update(value=summary), # summary_text + gr.update(value=stats_text), # stats_display + gr.update(value="Data refreshed", visible=True) # status_message + ) + + def on_status_filter_change(status_filter_value): + """Handle status filter change""" + table_data, summary, stats_text = refresh_applications_display(status_filter_value) + return ( + gr.update(value=table_data), # applications_table + gr.update(value=summary), # summary_text + gr.update(value=stats_text) # stats_display + ) + + def on_delete_application(app_id): + """Handle delete application""" + if not app_id: + return ( + gr.update(), # applications_table + gr.update(), # summary_text + gr.update(), # stats_display + gr.update(value="Please enter a valid Application ID", visible=True) # status_message + ) + + success, message = delete_application(int(app_id)) + if success: + # Refresh the display after successful deletion + table_data, summary, stats_text = refresh_applications_display("All") + return ( + gr.update(value=table_data), # applications_table + gr.update(value=summary), # summary_text + gr.update(value=stats_text), # stats_display + gr.update(value=message, visible=True) # status_message + ) + else: + return ( + gr.update(), # applications_table + gr.update(), # summary_text + gr.update(), # stats_display + gr.update(value=message, visible=True) # status_message + ) + + def on_clear_all(): + """Handle clear all applications""" + success, message = clear_all_applications() + if success: + return ( + gr.update(value=[]), # applications_table + gr.update(value="No applications found."), # summary_text + gr.update(value="šŸ“Š **Application Statistics:** No applications yet."), # stats_display + gr.update(value=message, visible=True) # status_message + ) + else: + return ( + gr.update(), # applications_table + gr.update(), # summary_text + gr.update(), # stats_display + gr.update(value=message, visible=True) # status_message + ) + + # Connect event handlers + refresh_button.click( + fn=on_refresh, + inputs=[status_filter], + outputs=[applications_table, summary_text, stats_display, status_message] + ) + + status_filter.change( + fn=on_status_filter_change, + inputs=[status_filter], + outputs=[applications_table, summary_text, stats_display] + ) + + delete_button.click( + fn=on_delete_application, + inputs=[delete_id_input], + outputs=[applications_table, summary_text, stats_display, status_message] + ) + + clear_all_button.click( + fn=on_clear_all, + inputs=[], + outputs=[applications_table, summary_text, stats_display, status_message] + ) + + # Initialize with empty data - will be loaded when user refreshes + # The initial refresh can be triggered by the user \ No newline at end of file diff --git a/src/webui/components/browser_settings_tab.py b/src/webui/components/browser_settings_tab.py index 77fbfb52..922f01f7 100644 --- a/src/webui/components/browser_settings_tab.py +++ b/src/webui/components/browser_settings_tab.py @@ -1,5 +1,4 @@ import os -from distutils.util import strtobool import gradio as gr import logging from gradio.components import Component @@ -9,6 +8,17 @@ logger = logging.getLogger(__name__) +def str_to_bool(value): + """Convert string to boolean, similar to distutils.util.strtobool""" + if isinstance(value, bool): + return value + if value.lower() in ('yes', 'true', 't', 'y', '1', 'on'): + return True + elif value.lower() in ('no', 'false', 'f', 'n', '0', 'off'): + return False + else: + return False + async def close_browser(webui_manager: WebuiManager): """ Close browser @@ -52,13 +62,13 @@ def create_browser_settings_tab(webui_manager: WebuiManager): with gr.Row(): use_own_browser = gr.Checkbox( label="Use Own Browser", - value=bool(strtobool(os.getenv("USE_OWN_BROWSER", "false"))), + value=str_to_bool(os.getenv("USE_OWN_BROWSER", "false")), info="Use your existing browser instance", interactive=True ) keep_browser_open = gr.Checkbox( label="Keep Browser Open", - value=bool(strtobool(os.getenv("KEEP_BROWSER_OPEN", "true"))), + value=str_to_bool(os.getenv("KEEP_BROWSER_OPEN", "true")), info="Keep Browser Open between Tasks", interactive=True ) diff --git a/src/webui/components/browser_use_agent_tab.py b/src/webui/components/browser_use_agent_tab.py index b51a1663..a488e70d 100644 --- a/src/webui/components/browser_use_agent_tab.py +++ b/src/webui/components/browser_use_agent_tab.py @@ -632,8 +632,11 @@ def done_callback_wrapper(history: AgentHistoryList): last_chat_len = len(webui_manager.bu_chat_history) yield update_dict # Wait until response is submitted or task finishes - await webui_manager.bu_response_event.wait() - + while ( + webui_manager.bu_response_event is not None + and not agent_task.done() + ): + await asyncio.sleep(0.2) # Restore UI after response submitted or if task ended unexpectedly if not agent_task.done(): yield { @@ -1068,7 +1071,7 @@ async def clear_wrapper() -> AsyncGenerator[Dict[Component, Any], None]: # --- Connect Event Handlers using the Wrappers -- run_button.click( - fn=submit_wrapper, inputs=all_managed_components, outputs=run_tab_outputs, trigger_mode="multiple" + fn=submit_wrapper, inputs=all_managed_components, outputs=run_tab_outputs ) user_input.submit( fn=submit_wrapper, inputs=all_managed_components, outputs=run_tab_outputs diff --git a/src/webui/components/job_application_tab.py b/src/webui/components/job_application_tab.py new file mode 100644 index 00000000..37b89107 --- /dev/null +++ b/src/webui/components/job_application_tab.py @@ -0,0 +1,575 @@ +import asyncio +import json +import logging +import os +import uuid +from typing import Any, AsyncGenerator, Dict, Optional + +# import gradio as gr # Commented out for Streamlit compatibility + +from browser_use.agent.views import ( + AgentHistoryList, + AgentOutput, +) +from browser_use.browser.browser import BrowserConfig +from browser_use.browser.context import BrowserContext, BrowserContextConfig +from browser_use.browser.views import BrowserState +from gradio.components import Component +from langchain_core.language_models.chat_models import BaseChatModel + +from src.agent.browser_use.browser_use_agent import BrowserUseAgent +from src.browser.custom_browser import CustomBrowser +from src.controller.custom_controller import CustomController +from src.utils import llm_provider +from src.webui.webui_manager import WebuiManager + +logger = logging.getLogger(__name__) + +# Job Application System Prompt +LINKEDIN_JOB_APPLICATION_SYSTEM_PROMPT = """ +You are an expert LinkedIn Job Application Automation Agent designed to process LinkedIn job search results and automatically apply to positions using stored profile data. + +## PRIMARY MISSION +Process LinkedIn job search URLs, extract individual job listings, and automatically apply to jobs with "Easy Apply" buttons using the user's MCP-stored profile information. + +## CORE WORKFLOW + +### 1. PROFILE PREPARATION +- ALWAYS start by calling `get_profile()` to fetch the latest user profile data +- Cache profile information for the session - NEVER fabricate missing data +- If critical fields are missing, report what's needed and pause execution + +### 2. LINKEDIN SEARCH URL PROCESSING +- Navigate to the provided LinkedIn job search URL +- Handle LinkedIn authentication if prompted (user should be logged in) +- Scroll through search results to load all job listings (handle pagination) +- Extract from each job card: + * Job title and company name + * Job posting URL/link + * Whether "Easy Apply" button is available + * Location and other basic details +- Build a list of applicable jobs for processing + +### 3. INDIVIDUAL JOB APPLICATION FLOW +For each job with "Easy Apply" available: + +**A. Navigation & Access** +- Click on the job title/card to open job details +- Locate and click the "Easy Apply" button +- Wait for application modal/form to load + +**B. Form Field Population (use profile data ONLY)** +- **Personal Info**: Full name, email, phone, address from profile.personal +- **Current Position**: Use profile.professional.current_position data +- **Work Experience**: Include current and previous positions with dates and descriptions +- **Education**: Use profile.professional.education data +- **Skills**: Use profile.professional.skills data +- **Work Authorization**: Use profile.preferences.work_authorization +- **Visa Status**: Use profile.preferences.visa_status +- **Salary Expectations**: Use profile.preferences.salary_min +- **Availability**: Use profile.preferences.availability +- **EEO Information**: Use profile.eeo_information (race, gender, veteran status, disability) if required + +**C. Resume Upload** +- Locate file upload field for resume +- Upload file from profile.documents.resume_path +- Wait for upload confirmation + +**D. Application Questions Handling** +- Answer standard questions using profile data +- For unknown questions, use LLM reasoning based on profile context +- If personal decision required, skip and note in application log +- Common question types: + * Years of experience → calculate from profile + * Salary requirements → use profile.preferences.salary_min + * Start date → use profile.preferences.availability + * Work authorization → use profile.preferences.work_authorization + * Cover letter → use profile.documents.cover_letter_template if needed + +**E. Submission & Verification** +- Click submit/send application button +- Wait for confirmation message (e.g., "Application submitted", "Thank you for applying") +- Take screenshot of confirmation for verification +- If error occurs, capture error message + +### 4. APPLICATION LOGGING +After each application attempt, call `log_application()` with: +- job_title: Extracted job title +- company: Company name +- job_url: Direct job posting URL +- status: "submitted" | "failed" | "skipped" +- notes: Detailed outcome including any errors or specific reasons + +### 5. PROGRESS TRACKING +- Update progress counter: "Processing job X of Y" +- Report real-time status for each application +- Provide summary statistics at completion + +## ERROR HANDLING & EDGE CASES + +### Rate Limiting & Delays +- Add 3-5 second delays between applications +- If rate limited, wait 30-60 seconds before retrying +- Respect LinkedIn's usage policies + +### Application Failures +- **CAPTCHA Detected**: Mark as failed, log reason, continue to next job +- **Login Required**: Pause and report login needed +- **Job No Longer Available**: Mark as skipped, continue +- **Form Submission Errors**: Retry once, then mark as failed +- **Missing Profile Data**: Use available data, note missing fields + +### Quality Assurance +- Verify form fields are populated before submission +- Confirm resume upload succeeded +- Validate application confirmation message +- Take screenshots for critical steps + +## SUCCESS CRITERIA +- Extract all available Easy Apply jobs from search results +- Successfully submit applications using only verified profile data +- Log comprehensive results for each attempt +- Provide real-time progress updates +- Handle errors gracefully without stopping the entire process + +## IMPORTANT CONSTRAINTS +- NEVER invent or guess profile information +- Only apply to jobs with "Easy Apply" buttons +- Skip jobs requiring external applications or complex processes +- Maintain professional, respectful interaction with all forms +- Stop immediately if instructed by stop button + +Your goal is to efficiently and accurately process LinkedIn job applications while maintaining data integrity and providing comprehensive logging for the user's review. +""" + +async def run_job_application_task( + linkedin_email: str, + linkedin_password: str, + job_urls: str, + override_role: str = "", + override_location: str = "" +) -> AsyncGenerator[tuple[list, str], None]: + """ + Main function for LinkedIn job application automation with user credentials + """ + webui_manager = WebuiManager() + + # Validate inputs + if not linkedin_email or not linkedin_email.strip(): + yield [{"role": "assistant", "content": "āŒ LinkedIn email is required. Please enter your LinkedIn email address."}], "Error: LinkedIn email required" + return + + if not linkedin_password or not linkedin_password.strip(): + yield [{"role": "assistant", "content": "āŒ LinkedIn password is required. Please enter your LinkedIn password."}], "Error: LinkedIn password required" + return + + if not job_urls or not job_urls.strip(): + yield [{"role": "assistant", "content": "āŒ Please enter at least one LinkedIn job search URL."}], "Error: No URLs provided" + return + + # Clean and validate credentials + linkedin_email = linkedin_email.strip() + linkedin_password = linkedin_password.strip() + + yield [{"role": "assistant", "content": f"šŸ” LinkedIn credentials received for: {linkedin_email}\nšŸ”‘ Password length: {len(linkedin_password)} characters\nšŸŽÆ Starting job application automation..."}], "Initializing with your LinkedIn credentials..." + + # Debug: Confirm credentials are properly set + if not linkedin_email or "@" not in linkedin_email: + yield [{"role": "assistant", "content": "āŒ Invalid LinkedIn email format. Please provide a valid email address."}], "Error: Invalid email format" + return + + if len(linkedin_password) < 6: + yield [{"role": "assistant", "content": "āŒ LinkedIn password seems too short. Please check your password."}], "Error: Password validation failed" + return + + yield [{"role": "assistant", "content": f"āœ… Credentials validated successfully!\nšŸ“§ Email: {linkedin_email}\nšŸ”‘ Password: {'*' * len(linkedin_password)}\n\nThese EXACT credentials will be used for LinkedIn login."}], "Credentials validated - ready to start" + + # Parse job URLs + job_urls_list = [url.strip() for url in job_urls.strip().split('\n') if url.strip()] + + # Validate LinkedIn URLs + linkedin_urls = [] + for url in job_urls_list: + if "linkedin.com" in url: + linkedin_urls.append(url) + else: + yield [{"role": "assistant", "content": f"āš ļø Skipping non-LinkedIn URL: {url}"}], "Validating URLs..." + + if not linkedin_urls: + yield [{"role": "assistant", "content": "āŒ No valid LinkedIn URLs found. Please provide LinkedIn job search URLs."}], "Error: No valid LinkedIn URLs" + return + + yield [{"role": "assistant", "content": f"šŸŽÆ Starting LinkedIn job application automation for {len(linkedin_urls)} search URL(s)"}], f"Initializing automation for {len(linkedin_urls)} LinkedIn search(es)..." + + # Initialize LLM with default values or from environment + try: + # Use default provider settings or environment variables + provider = os.getenv("LLM_PROVIDER", "openai") + model_name = os.getenv("LLM_MODEL", "gpt-4o") + temperature = float(os.getenv("LLM_TEMPERATURE", "0.1")) + base_url = os.getenv("OPENAI_ENDPOINT") or os.getenv("ANTHROPIC_ENDPOINT") or None + api_key = os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY") or None + + llm: BaseChatModel = llm_provider.get_llm_model( + provider=provider, + model_name=model_name, + temperature=temperature, + base_url=base_url, + api_key=api_key, + ) + except Exception as e: + yield [{"role": "assistant", "content": f"āŒ LLM configuration error: {str(e)}\n\nPlease check your environment variables:\n- OPENAI_API_KEY or ANTHROPIC_API_KEY\n- Optionally: LLM_PROVIDER, LLM_MODEL, LLM_TEMPERATURE"}], "Error: LLM configuration required" + return + + # Browser configuration - Make it VISIBLE so you can see LinkedIn automation + browser_config = BrowserConfig( + headless=False, # VISIBLE browser window + browser_type="chromium", + user_data_dir=getattr(webui_manager, 'browser_user_data_dir', None), + disable_security=True, # Allow easier LinkedIn automation + ) + + yield [{"role": "assistant", "content": "🌐 Initializing browser and preparing for LinkedIn automation..."}], "Setting up VISIBLE browser for LinkedIn..." + + try: + browser = CustomBrowser(config=browser_config) + await browser.async_start() + + browser_context_config = BrowserContextConfig( + window_width=1280, + window_height=1024, + ) + context = await browser.create_context(config=browser_context_config) + + # Initialize custom controller with MCP tools + controller = CustomController() + webui_manager.controller = controller + + total_applications = 0 + successful_applications = 0 + failed_applications = 0 + + for url_index, linkedin_url in enumerate(linkedin_urls): + yield [{"role": "assistant", "content": f"šŸ” Processing LinkedIn search URL {url_index + 1}/{len(linkedin_urls)}: {linkedin_url[:80]}...\n\nāš ļø **You should now see a browser window opening!** Watch as the agent navigates LinkedIn and applies to jobs."}], f"VISIBLE Browser: Processing search URL {url_index + 1}/{len(linkedin_urls)}..." + + yield [{"role": "assistant", "content": f"šŸ¤– Creating agent with credentials:\nšŸ“§ Email: {linkedin_email}\nšŸ”‘ Password: {'*' * len(linkedin_password)}\n\nāœ… **FIXED**: Agent will now receive the detailed prompt with your REAL credentials!\n\nšŸ” Debug: Enhanced prompt contains your email {len(linkedin_email)} chars and password {len(linkedin_password)} chars."}], f"Setting up agent with your REAL credentials..." + + # EXACT WORKING PROMPT FROM YESTERDAY'S GITHUB CODE + enhanced_prompt = f""" +🚨 CRITICAL LINKEDIN APPLICATION MISSION 🚨 + +You are a LinkedIn job application automation agent. Your goal is to successfully complete job applications by following the COMPLETE application flow until you see the "Thank you for applying" message. + +1. **Navigate to the LinkedIn job search URL**: {linkedin_url} + +2. **LOGIN** with credentials: + **EMAIL**: {linkedin_email} + **PASSWORD**: {linkedin_password} + +3. **COMPLETE APPLICATION PROTOCOL** - MUST REACH "THANK YOU" MESSAGE: + + **STEP 1: FIND AND CLICK EASY APPLY JOBS** + - Find jobs with "Easy Apply" button + - Click "Easy Apply" to start application process + + **STEP 2: FILL CONTACT INFORMATION** + - Fill email: {linkedin_email} + - Fill phone: 8067025056 + - Select country: United States + - Look for "Next" button and click it + + **STEP 3: RESUME UPLOAD** + - Select the first available resume + - Click "Next" to continue + + **STEP 4: ANSWER QUESTIONS** + - Fill any required questions with appropriate answers: + * Years experience: 3-5 years + * Work authorization: Yes + * Willing to relocate: Yes + * Any dropdowns: Select appropriate option + - Click "Next" or "Review" to continue + + **STEP 5: CRITICAL - COMPLETE SUBMISSION (MUST DO THIS)** + - **ALWAYS SCROLL DOWN** to find buttons at the bottom + - Look for these buttons IN ORDER: + 1. "Submit Application" (PRIORITY 1 - click this!) + 2. "Submit" (PRIORITY 2 - click this!) + 3. "Review" (continue to next step) + 4. "Next" (continue to next step) + + **SUBMISSION RULES:** + - **NEVER CLICK "X" OR CLOSE BUTTON** + - **NEVER CLICK "Discard" OR "Save for later"** + - **ALWAYS SCROLL DOWN** if you don't see Submit button + - **KEEP SCROLLING** until you find Submit Application button + - **ONLY COMPLETE** when you see "Thank you for applying" message + - **LOG SUCCESS** only after seeing confirmation message + +**ULTRA-FAST MODE OPTIMIZATIONS:** + +RULE #3: AGGRESSIVE BUTTON HUNTING +- Look for Next/Review/Submit buttons FIRST (don't scroll initially) +- If you see ANY progression button, click it IMMEDIATELY +- Only if NO buttons visible, then press "End" key ONCE to jump to bottom +- NO multiple scroll_down actions - use "End" key for instant bottom navigation + +**MANDATORY SCROLL BEHAVIOR:** +If at ANY point you cannot see a button to proceed (Next/Review/Submit Application), you MUST: +1. Use send_keys action with keys="End" to jump to page bottom +2. Look for the button that appeared at the bottom +3. Click it immediately +4. NEVER sit idle without taking action - if no button visible, ALWAYS use End key + +**CRITICAL: When stuck or idle, IMMEDIATELY use send_keys with keys="End"** + +**LOGIN CREDENTIALS:** +Email: {linkedin_email} +Password: {linkedin_password} + +**EMERGENCY PROTOCOL: If you are idle and not seeing progression buttons, use send_keys action with keys="End" IMMEDIATELY!** +""" + + # ABSOLUTE FINAL ATTEMPT - DIRECT JAVASCRIPT SCROLL + final_desperate_prompt = f""" +You are applying to LinkedIn Easy Apply jobs. Login with {linkedin_email}/{linkedin_password} at {linkedin_url}. + +CRITICAL RULE: If you cannot see a "Next", "Review", or "Submit Application" button, you MUST scroll to the bottom of the page. + +TO SCROLL TO BOTTOM: Click anywhere on the page, then press the "End" key on the keyboard. This will take you to the bottom where the buttons are. + +STEP BY STEP: +1. Fill out the form fields +2. Look for Next/Review/Submit Application button +3. If you DON'T see the button: Press "End" key to scroll to bottom +4. Click the button that appears +5. Repeat until you see "Thank you for applying" + +NEVER click X, Discard, or Save buttons. + +Fill forms: Email {linkedin_email}, Phone 8067025056, Experience 3-5 years, Authorization Yes, Relocate Yes. + +When stuck, ALWAYS press "End" key to go to bottom of page. +""" + + # Create browser use agent + agent = BrowserUseAgent( + task=final_desperate_prompt, + llm=llm, + browser=browser, + browser_context=context, + controller=controller, + use_vision=True, + max_actions_per_step=10 + ) + + # Store current agent for stop functionality + webui_manager.current_agent = agent + + try: + yield [{ + "role": "assistant", + "content": f"šŸ†˜ **ABSOLUTE FINAL ATTEMPT** šŸ†˜\n\nšŸŽÆ LinkedIn agent starting for URL {url_index + 1}/{len(linkedin_urls)}\n\nšŸ“‹ **ULTRA-SIMPLE APPROACH:**\n- āœ… **CLEAR INSTRUCTION**: If no button visible → Press 'End' key\n- āœ… **DIRECT LANGUAGE**: 'Press End key to scroll to bottom'\n- āœ… **STEP-BY-STEP**: Explicit 5-step process\n- āœ… **CRITICAL RULE**: Must scroll when button not found\n- āœ… **WHEN STUCK**: Always press End key\n\nšŸ”‘ **KEY INSTRUCTION:**\n'If you DON'T see the button: Press End key to scroll to bottom'\n\nšŸŽÆ **EXPECTED BEHAVIOR:**\n- Fill form fields\n- Look for Next/Submit button\n- **NO BUTTON? → PRESS END KEY**\n- Click button that appears\n- Repeat until 'Thank you for applying'\n\nāš ļø **THIS IS THE FINAL ATTEMPT - WATCH FOR END KEY USAGE!**" + }], f"šŸ†˜ FINAL ATTEMPT: URL {url_index + 1}/{len(linkedin_urls)}..." + + # Run the agent and wait for completion + history = await agent.run(max_steps=250) # Increased for ultra-fast batch processing + + # Process results with aggressive validation + if history and history.is_done(): + final_result = history.final_result() + if final_result and "success" in str(final_result).lower(): + successful_applications += 1 + yield [{"role": "assistant", "content": f"šŸŽ‰ **MISSION SUCCESS** šŸŽ‰\n\nāœ… **AGGRESSIVE SUBMISSION PROTOCOL WORKED!**\n- URL {url_index + 1}/{len(linkedin_urls)} completed\n- Applications submitted with 'Thank you' confirmations\n- NO X button clicks detected\n- Scrolling protocol executed successfully\n\nšŸ“Š **Success Indicators:**\n- Found Submit buttons through scrolling\n- Avoided all Discard/Close buttons\n- Received application confirmations\n\nšŸ“ Result: {str(final_result)[:300]}..."}], f"šŸŽ‰ MISSION SUCCESS: URL {url_index + 1}/{len(linkedin_urls)}" + else: + failed_applications += 1 + yield [{"role": "assistant", "content": f"āš ļø **PARTIAL SUCCESS** āš ļø\n\nšŸ” Agent processed URL {url_index + 1} but may need review:\n\nšŸ“ Result: {str(final_result)[:300]}...\n\nšŸ” **VERIFICATION NEEDED:**\n- Did agent scroll down properly?\n- Were Submit buttons found and clicked?\n- Did agent avoid X/Discard buttons?\n- Were 'Thank you' messages displayed?\n\n🚨 **If agent clicked X button, this is a FAILURE!**"}], f"āš ļø Partial: URL {url_index + 1}/{len(linkedin_urls)}" + else: + failed_applications += 1 + yield [{"role": "assistant", "content": f"āŒ **MISSION FAILED** āŒ\n\n🚨 Agent did not complete URL {url_index + 1}/{len(linkedin_urls)}\n\nšŸ” **CRITICAL FAILURE ANALYSIS:**\n- Agent may have clicked X button (PROHIBITED!)\n- Scrolling protocol may have failed\n- Submit buttons not found despite scrolling\n- Applications abandoned without submission\n\nšŸ’” **INVESTIGATION REQUIRED:**\n- Check browser window for evidence\n- Verify scrolling actions were performed\n- Confirm no X/Discard buttons were clicked\n- Look for abandoned applications"}], f"āŒ MISSION FAILED: URL {url_index + 1}/{len(linkedin_urls)}" + + total_applications += 1 + + except Exception as e: + yield [{"role": "assistant", "content": f"āŒ Error processing URL {url_index + 1}: {str(e)}"}], f"Error on URL {url_index + 1}/{len(linkedin_urls)}" + failed_applications += 1 + + # Final summary + summary_message = f""" + āœ… LinkedIn Job Application Automation Complete! + + šŸ“Š Final Results: + • Total Applications Processed: {total_applications} + • Successfully Submitted: {successful_applications} + • Failed Applications: {failed_applications} + • Success Rate: {(successful_applications/total_applications*100) if total_applications > 0 else 0:.1f}% + + Check the Application History tab for detailed results of each application. + """ + + yield [{"role": "assistant", "content": summary_message}], f"Complete: {successful_applications} submitted, {failed_applications} failed" + + # Summary of all job search URLs processed + yield [{ + "role": "assistant", + "content": f"šŸ **AGGRESSIVE SUBMISSION MISSION COMPLETE!** šŸ\n\n🚨 **MISSION CRITICAL STATS:**\nšŸ“Š **URLs Processed**: {len(linkedin_urls)}\nāœ… **Successful Submissions**: {successful_applications}\nāŒ **Failed Missions**: {failed_applications}\n\nšŸ’Ŗ **AGGRESSIVE ENFORCEMENT FEATURES USED:**\n- 🚫 **ZERO X BUTTON CLICKS** (prohibited!)\n- šŸ”„ **MANDATORY SCROLLING** protocol activated\n- šŸŽÆ **END KEY NAVIGATION** to bottom\n- šŸ’„ **SUBMIT BUTTON HUNTING** with multiple techniques\n- šŸ›”ļø **DISCARD BUTTON AVOIDANCE** system\n- āœ… **THANK YOU MESSAGE VALIDATION**\n\nšŸ“‹ **MISSION RESULTS:**\n- Check 'Application History' tab for confirmed applications\n- Only applications with 'Thank you' messages count as success\n- Agent forced to scroll until Submit buttons found\n- NO applications abandoned via X button clicks\n\nšŸŽÆ **SUBMISSION ENFORCEMENT NOTES:**\n- Agent programmed to NEVER click X/Discard buttons\n- Aggressive scrolling ensures Submit buttons are found\n- Multiple scroll techniques prevent mission failure\n- Success only confirmed with 'Thank you' messages\n\nāš ļø **IF ANY X BUTTON CLICKS DETECTED = MISSION FAILURE!**" + }], "šŸ AGGRESSIVE SUBMISSION mission complete!" + + # Try to get application statistics from controller + try: + applications_result = controller.run_tool("list_applications", {}) + if applications_result and "applications" in applications_result: + total_logged = len(applications_result["applications"]) + yield [{"role": "assistant", "content": f"šŸ“ˆ **APPLICATION STATISTICS**\n\nšŸŽÆ **Total Applications Logged**: {total_logged}\nšŸ“ **Applications found in your history**\n\nšŸ‘‰ Go to 'Application History' tab to view all applications!"}], f"Found {total_logged} total applications in history" + except Exception as e: + pass # Don't fail if we can't get stats + + except Exception as e: + error_message = f"Critical error in job application automation: {str(e)}" + yield [{"role": "assistant", "content": error_message}], f"Critical Error: {str(e)[:50]}..." + + finally: + # Cleanup + try: + if hasattr(webui_manager, 'current_agent'): + webui_manager.current_agent = None + if 'context' in locals(): + await context.close() + if 'browser' in locals(): + await browser.close() + except: + pass + +async def handle_stop_application(webui_manager: WebuiManager): + """Handle stopping the job application automation""" + webui_manager.stop_agent = True + if hasattr(webui_manager, 'current_agent') and webui_manager.current_agent: + try: + # Try to gracefully stop the current agent + webui_manager.current_agent = None + except: + pass + return "šŸ›‘ Stopping job application automation..." + +def create_job_application_tab(webui_manager: WebuiManager): + """ + Creates a job application automation tab. + """ + tab_components = {} + + with gr.Column(): + gr.Markdown("## šŸš€ Automated Job Applications") + gr.Markdown("Enter LinkedIn job search URLs and let the agent automatically apply using your saved profile.") + + # LinkedIn Credentials Section + gr.Markdown("### šŸ” LinkedIn Login Credentials") + gr.Markdown("*Required for applying to jobs. Your credentials are only used for this session and not stored permanently.*") + + with gr.Row(): + linkedin_email = gr.Textbox( + label="LinkedIn Email", + placeholder="your-email@example.com", + type="email", + info="Your LinkedIn account email" + ) + linkedin_password = gr.Textbox( + label="LinkedIn Password", + placeholder="Enter your LinkedIn password", + type="password", + info="Your LinkedIn account password" + ) + + # Job URLs Input Section + gr.Markdown("### šŸ“‹ Job Search URLs") + job_urls = gr.Textbox( + label="LinkedIn Job Search URLs (one per line)", + lines=6, + placeholder="""https://www.linkedin.com/jobs/search/?keywords=data%20scientist&location=San%20Francisco +https://www.linkedin.com/jobs/search/?keywords=software%20engineer&location=Remote""", + info="šŸ’” Pro tip: Use LinkedIn's job search filters to find your target roles, then paste the results URL here!" + ) + + # Optional Overrides Section + gr.Markdown("### šŸŽÆ Optional Overrides") + + with gr.Row(): + override_role = gr.Textbox( + label="Override Target Role (Optional)", + placeholder="e.g., Senior Software Engineer", + info="Override the target role from your profile for these applications" + ) + override_location = gr.Textbox( + label="Override Target Location (Optional)", + placeholder="e.g., San Francisco, CA", + info="Override the target location from your profile for these applications" + ) + + # Action Buttons + with gr.Row(): + apply_button = gr.Button("šŸš€ Apply to Jobs", variant="primary", size="lg") + stop_button = gr.Button("šŸ›‘ Stop", variant="secondary", size="lg") + + # Progress and Results + progress_status = gr.Textbox( + label="⚔ Progress Status", + interactive=False, + max_lines=1 + ) + + gr.Markdown("### šŸ¤– Application Process Log") + + chatbot = gr.Chatbot( + label="Real-time Application Progress", + height=400, + type="messages", + show_copy_button=True + ) + + clear_log_button = gr.Button("šŸ—‘ļø Clear Log", variant="secondary") + + # Store components for access + tab_components = { + "linkedin_email": linkedin_email, + "linkedin_password": linkedin_password, + "job_urls": job_urls, + "override_role": override_role, + "override_location": override_location, + "apply_button": apply_button, + "stop_button": stop_button, + "progress_status": progress_status, + "chatbot": chatbot, + "clear_log_button": clear_log_button + } + + # Event handlers + apply_button.click( + fn=run_application_wrapper, + inputs=[linkedin_email, linkedin_password, job_urls, override_role, override_location], + outputs=[chatbot, progress_status], + queue=True, + show_progress="full" + ) + + stop_button.click( + fn=lambda: handle_stop_application(webui_manager), + outputs=progress_status, + queue=True + ) + + clear_log_button.click( + fn=lambda: ([], ""), + outputs=[chatbot, progress_status], + queue=False + ) + + return tab_components + + +async def run_application_wrapper(linkedin_email: str, linkedin_password: str, job_urls: str, override_role: str, override_location: str): + """Wrapper function to handle the application process with proper error handling""" + try: + async for result in run_job_application_task(linkedin_email, linkedin_password, job_urls, override_role, override_location): + yield result + except Exception as e: + error_msg = f"āŒ Critical error in job application process: {str(e)}" + yield [{"role": "assistant", "content": error_msg}], f"Error: {str(e)[:50]}..." \ No newline at end of file diff --git a/src/webui/components/profile_settings_tab.py b/src/webui/components/profile_settings_tab.py new file mode 100644 index 00000000..6f19334b --- /dev/null +++ b/src/webui/components/profile_settings_tab.py @@ -0,0 +1,687 @@ +import os +import json +import logging +from datetime import datetime +import gradio as gr +from gradio.components import Component +from typing import Dict, Any + +from src.webui.webui_manager import WebuiManager + +logger = logging.getLogger(__name__) + +PROFILE_FILE = "./data/profile/profile.json" + +def load_profile(): + """Load the user's profile from file""" + try: + if os.path.exists(PROFILE_FILE): + with open(PROFILE_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + else: + # Return default empty profile structure + return { + "personal": { + "full_name": "", + "email": "", + "phone": "", + "address": "", + "linkedin_url": "", + "portfolio_url": "" + }, + "professional": { + "current_position": { + "job_title": "", + "company": "", + "start_date": "", + "end_date": "Present", + "work_description": "" + }, + "previous_positions": [ + # Each will be: {"job_title": "", "company": "", "start_date": "", "end_date": "", "work_description": ""} + ], + "years_experience": 0, + "skills": [], + "education": [] + }, + "preferences": { + "target_roles": [], + "target_locations": [], + "salary_min": 0, + "work_authorization": "", + "visa_status": "", + "availability": "", + "remote_preference": "" + }, + "eeo_information": { + "race_ethnicity": "Prefer not to answer", + "gender": "Prefer not to answer", + "veteran_status": "Prefer not to answer", + "disability_status": "Prefer not to answer", + "voluntary_disclosure": True + }, + "documents": { + "resume_path": "", + "cover_letter_template": "" + } + } + except Exception as e: + logger.error(f"Error loading profile: {e}") + return {} + +def save_profile(profile_data): + """Save the user's profile to file""" + try: + os.makedirs(os.path.dirname(PROFILE_FILE), exist_ok=True) + with open(PROFILE_FILE, 'w', encoding='utf-8') as f: + json.dump(profile_data, f, indent=2, ensure_ascii=False) + return True, "Profile saved successfully!" + except Exception as e: + logger.error(f"Error saving profile: {e}") + return False, f"Error saving profile: {str(e)}" + +async def save_profile_handler( + full_name, email, phone, address, linkedin_url, portfolio_url, + current_job_title, current_company, current_start_date, current_end_date, current_work_description, + prev_job_1_title, prev_job_1_company, prev_job_1_start, prev_job_1_end, prev_job_1_description, + prev_job_2_title, prev_job_2_company, prev_job_2_start, prev_job_2_end, prev_job_2_description, + prev_job_3_title, prev_job_3_company, prev_job_3_start, prev_job_3_end, prev_job_3_description, + years_experience, skills_text, education_text, + target_roles_text, target_locations_text, salary_min, work_authorization, visa_status, + availability, remote_preference, race_ethnicity, gender, veteran_status, disability_status, + resume_file, cover_letter_template +): + """Handle saving profile data from the UI""" + try: + # Parse skills, education, target roles and locations from text + skills = [skill.strip() for skill in skills_text.split('\n') if skill.strip()] + education = [edu.strip() for edu in education_text.split('\n') if edu.strip()] + target_roles = [role.strip() for role in target_roles_text.split('\n') if role.strip()] + target_locations = [loc.strip() for loc in target_locations_text.split('\n') if loc.strip()] + + # Build previous positions array (only include if title and company are provided) + previous_positions = [] + + # Previous Job 1 + if prev_job_1_title.strip() and prev_job_1_company.strip(): + previous_positions.append({ + "job_title": prev_job_1_title.strip(), + "company": prev_job_1_company.strip(), + "start_date": prev_job_1_start.strip(), + "end_date": prev_job_1_end.strip(), + "work_description": prev_job_1_description.strip() + }) + + # Previous Job 2 + if prev_job_2_title.strip() and prev_job_2_company.strip(): + previous_positions.append({ + "job_title": prev_job_2_title.strip(), + "company": prev_job_2_company.strip(), + "start_date": prev_job_2_start.strip(), + "end_date": prev_job_2_end.strip(), + "work_description": prev_job_2_description.strip() + }) + + # Previous Job 3 + if prev_job_3_title.strip() and prev_job_3_company.strip(): + previous_positions.append({ + "job_title": prev_job_3_title.strip(), + "company": prev_job_3_company.strip(), + "start_date": prev_job_3_start.strip(), + "end_date": prev_job_3_end.strip(), + "work_description": prev_job_3_description.strip() + }) + + # Handle resume file upload + resume_path = "" + if resume_file: + resume_dir = "./data/profile/documents" + os.makedirs(resume_dir, exist_ok=True) + resume_filename = os.path.basename(resume_file.name) + resume_path = os.path.join(resume_dir, resume_filename) + + # Copy the uploaded file + import shutil + shutil.copy2(resume_file.name, resume_path) + + profile_data = { + "personal": { + "full_name": full_name, + "email": email, + "phone": phone, + "address": address, + "linkedin_url": linkedin_url, + "portfolio_url": portfolio_url + }, + "professional": { + "current_position": { + "job_title": current_job_title, + "company": current_company, + "start_date": current_start_date, + "end_date": current_end_date, + "work_description": current_work_description + }, + "previous_positions": previous_positions, + "years_experience": years_experience, + "skills": skills, + "education": education + }, + "preferences": { + "target_roles": target_roles, + "target_locations": target_locations, + "salary_min": salary_min, + "work_authorization": work_authorization, + "visa_status": visa_status, + "availability": availability, + "remote_preference": remote_preference + }, + "eeo_information": { + "race_ethnicity": race_ethnicity, + "gender": gender, + "veteran_status": veteran_status, + "disability_status": disability_status, + "voluntary_disclosure": True + }, + "documents": { + "resume_path": resume_path, + "cover_letter_template": cover_letter_template + }, + "last_updated": datetime.now().isoformat() + } + + success, message = save_profile(profile_data) + if success: + return gr.update(value=message, visible=True) + else: + return gr.update(value=message, visible=True) + + except Exception as e: + logger.error(f"Error in save_profile_handler: {e}") + return gr.update(value=f"Error saving profile: {str(e)}", visible=True) + +def create_profile_settings_tab(webui_manager: WebuiManager): + """ + Creates a profile settings tab for job application management. + """ + tab_components = {} + + # Load existing profile (will be empty after clearing demo data) + profile = load_profile() + personal = profile.get("personal", {}) + professional = profile.get("professional", {}) + current_position = professional.get("current_position", {}) + previous_positions = professional.get("previous_positions", []) + preferences = profile.get("preferences", {}) + eeo_info = profile.get("eeo_information", {}) + documents = profile.get("documents", {}) + + with gr.Column(): + gr.Markdown("## šŸ‘¤ Personal Information") + + with gr.Row(): + full_name = gr.Textbox( + label="Full Name", + value=personal.get("full_name", ""), + placeholder="Your full name as it appears on your resume" + ) + email = gr.Textbox( + label="Email", + value=personal.get("email", ""), + placeholder="your.email@example.com" + ) + + with gr.Row(): + phone = gr.Textbox( + label="Phone Number", + value=personal.get("phone", ""), + placeholder="+1 (555) 123-4567" + ) + address = gr.Textbox( + label="Address", + value=personal.get("address", ""), + placeholder="City, State, Country" + ) + + with gr.Row(): + linkedin_url = gr.Textbox( + label="LinkedIn URL", + value=personal.get("linkedin_url", ""), + placeholder="https://linkedin.com/in/yourprofile" + ) + portfolio_url = gr.Textbox( + label="Portfolio/Website URL", + value=personal.get("portfolio_url", ""), + placeholder="https://yourportfolio.com" + ) + + gr.Markdown("## šŸ’¼ Professional Information") + + # Current Position Section + gr.Markdown("### Current Position") + with gr.Row(): + current_job_title = gr.Textbox( + label="Job Title", + value=current_position.get("job_title", ""), + placeholder="Software Engineer, Data Scientist, etc." + ) + current_company = gr.Textbox( + label="Company", + value=current_position.get("company", ""), + placeholder="Company Name" + ) + + with gr.Row(): + current_start_date = gr.Textbox( + label="Start Date", + value=current_position.get("start_date", ""), + placeholder="January 2022" + ) + current_end_date = gr.Textbox( + label="End Date", + value=current_position.get("end_date", "Present"), + placeholder="Present or December 2023" + ) + + current_work_description = gr.Textbox( + label="Work Description", + lines=4, + value=current_position.get("work_description", ""), + placeholder="Describe your key responsibilities, achievements, and technologies used in this role..." + ) + + # Previous Positions Section + gr.Markdown("### Previous Work Experience") + + # Get previous positions data (pad with empty dicts if needed) + prev_positions = previous_positions + [{} for _ in range(3 - len(previous_positions))] + + # Previous Job 1 + gr.Markdown("#### Previous Position 1") + with gr.Row(): + prev_job_1_title = gr.Textbox( + label="Job Title", + value=prev_positions[0].get("job_title", ""), + placeholder="Previous job title" + ) + prev_job_1_company = gr.Textbox( + label="Company", + value=prev_positions[0].get("company", ""), + placeholder="Company Name" + ) + + with gr.Row(): + prev_job_1_start = gr.Textbox( + label="Start Date", + value=prev_positions[0].get("start_date", ""), + placeholder="January 2020" + ) + prev_job_1_end = gr.Textbox( + label="End Date", + value=prev_positions[0].get("end_date", ""), + placeholder="December 2021" + ) + + prev_job_1_description = gr.Textbox( + label="Work Description", + lines=3, + value=prev_positions[0].get("work_description", ""), + placeholder="Describe your responsibilities and achievements in this role..." + ) + + # Previous Job 2 + gr.Markdown("#### Previous Position 2") + with gr.Row(): + prev_job_2_title = gr.Textbox( + label="Job Title", + value=prev_positions[1].get("job_title", ""), + placeholder="Previous job title" + ) + prev_job_2_company = gr.Textbox( + label="Company", + value=prev_positions[1].get("company", ""), + placeholder="Company Name" + ) + + with gr.Row(): + prev_job_2_start = gr.Textbox( + label="Start Date", + value=prev_positions[1].get("start_date", ""), + placeholder="January 2018" + ) + prev_job_2_end = gr.Textbox( + label="End Date", + value=prev_positions[1].get("end_date", ""), + placeholder="December 2019" + ) + + prev_job_2_description = gr.Textbox( + label="Work Description", + lines=3, + value=prev_positions[1].get("work_description", ""), + placeholder="Describe your responsibilities and achievements in this role..." + ) + + # Previous Job 3 + gr.Markdown("#### Previous Position 3") + with gr.Row(): + prev_job_3_title = gr.Textbox( + label="Job Title", + value=prev_positions[2].get("job_title", ""), + placeholder="Previous job title" + ) + prev_job_3_company = gr.Textbox( + label="Company", + value=prev_positions[2].get("company", ""), + placeholder="Company Name" + ) + + with gr.Row(): + prev_job_3_start = gr.Textbox( + label="Start Date", + value=prev_positions[2].get("start_date", ""), + placeholder="January 2016" + ) + prev_job_3_end = gr.Textbox( + label="End Date", + value=prev_positions[2].get("end_date", ""), + placeholder="December 2017" + ) + + prev_job_3_description = gr.Textbox( + label="Work Description", + lines=3, + value=prev_positions[2].get("work_description", ""), + placeholder="Describe your responsibilities and achievements in this role..." + ) + + # General Professional Information + gr.Markdown("### General Professional Information") + years_experience = gr.Number( + label="Total Years of Experience", + value=professional.get("years_experience", 0), + minimum=0, + maximum=50 + ) + + skills_text = gr.Textbox( + label="Skills (one per line)", + lines=5, + value='\n'.join(professional.get("skills", [])), + placeholder="Python\nJavaScript\nReact\nMachine Learning\nProject Management" + ) + + education_text = gr.Textbox( + label="Education (one per line)", + lines=3, + value='\n'.join(professional.get("education", [])), + placeholder="Bachelor's in Computer Science - University Name (2020)\nMaster's in Data Science - University Name (2022)" + ) + + gr.Markdown("## šŸŽÆ Job Preferences") + + target_roles_text = gr.Textbox( + label="Target Job Roles (one per line)", + lines=3, + value='\n'.join(preferences.get("target_roles", [])), + placeholder="Software Engineer\nFull Stack Developer\nData Scientist" + ) + + target_locations_text = gr.Textbox( + label="Target Locations (one per line)", + lines=3, + value='\n'.join(preferences.get("target_locations", [])), + placeholder="San Francisco, CA\nNew York, NY\nRemote" + ) + + with gr.Row(): + salary_min = gr.Number( + label="Minimum Salary ($)", + value=preferences.get("salary_min", 0), + minimum=0 + ) + work_authorization = gr.Dropdown( + label="Work Authorization", + choices=["US Citizen", "Green Card", "H1B", "OPT", "CPT", "Need Sponsorship", "Other"], + value=preferences.get("work_authorization", None), + allow_custom_value=True + ) + + with gr.Row(): + visa_status = gr.Textbox( + label="Visa Status/Notes", + value=preferences.get("visa_status", ""), + placeholder="Additional visa information if needed" + ) + availability = gr.Textbox( + label="Availability", + value=preferences.get("availability", ""), + placeholder="Immediately, 2 weeks notice, etc." + ) + + remote_preference = gr.Dropdown( + label="Remote Work Preference", + choices=["Remote", "Hybrid", "On-site", "No Preference"], + value=preferences.get("remote_preference", None), + allow_custom_value=True + ) + + # EEO Information Section + gr.Markdown("## šŸ“Š Equal Employment Opportunity Information") + gr.Markdown("*This information is voluntary and used for compliance reporting. It will not affect hiring decisions.*") + + with gr.Row(): + race_ethnicity = gr.Dropdown( + label="Race/Ethnicity (Optional)", + choices=[ + "Prefer not to answer", + "American Indian or Alaska Native", + "Asian", + "Black or African American", + "Hispanic or Latino", + "Native Hawaiian or Other Pacific Islander", + "White", + "Two or More Races" + ], + value=eeo_info.get("race_ethnicity", "Prefer not to answer"), + allow_custom_value=False + ) + gender = gr.Dropdown( + label="Gender (Optional)", + choices=[ + "Prefer not to answer", + "Male", + "Female", + "Non-binary", + "Other" + ], + value=eeo_info.get("gender", "Prefer not to answer"), + allow_custom_value=False + ) + + with gr.Row(): + veteran_status = gr.Dropdown( + label="Veteran Status (Optional)", + choices=[ + "Prefer not to answer", + "I am not a protected veteran", + "I identify as one or more of the classifications of protected veteran", + "Recently separated veteran", + "Armed forces service medal veteran", + "Disabled veteran", + "Other protected veteran" + ], + value=eeo_info.get("veteran_status", "Prefer not to answer"), + allow_custom_value=False + ) + disability_status = gr.Dropdown( + label="Disability Status (Optional)", + choices=[ + "Prefer not to answer", + "No, I do not have a disability", + "Yes, I have a disability (or previously had a disability)", + "I don't wish to answer" + ], + value=eeo_info.get("disability_status", "Prefer not to answer"), + allow_custom_value=False + ) + + gr.Markdown("## šŸ“„ Documents") + + resume_file = gr.File( + label="Upload Resume", + file_types=[".pdf", ".doc", ".docx"], + type="filepath" + ) + + if documents.get("resume_path"): + gr.Markdown(f"**Current Resume:** `{documents.get('resume_path')}`") + + cover_letter_template = gr.Textbox( + label="Cover Letter Template", + lines=6, + value=documents.get("cover_letter_template", ""), + placeholder="Dear Hiring Manager,\n\nI am writing to express my interest in the [POSITION] role at [COMPANY]...\n\n[Your customizable cover letter template]" + ) + + # Save button and status + with gr.Row(): + save_button = gr.Button("šŸ’¾ Save Profile", variant="primary", scale=1) + clear_button = gr.Button("šŸ—‘ļø Clear All", variant="secondary", scale=1) + + status_message = gr.Textbox( + label="Status", + interactive=False, + visible=False + ) + + # Store components + tab_components.update({ + "full_name": full_name, + "email": email, + "phone": phone, + "address": address, + "linkedin_url": linkedin_url, + "portfolio_url": portfolio_url, + "current_job_title": current_job_title, + "current_company": current_company, + "current_start_date": current_start_date, + "current_end_date": current_end_date, + "current_work_description": current_work_description, + "prev_job_1_title": prev_job_1_title, + "prev_job_1_company": prev_job_1_company, + "prev_job_1_start": prev_job_1_start, + "prev_job_1_end": prev_job_1_end, + "prev_job_1_description": prev_job_1_description, + "prev_job_2_title": prev_job_2_title, + "prev_job_2_company": prev_job_2_company, + "prev_job_2_start": prev_job_2_start, + "prev_job_2_end": prev_job_2_end, + "prev_job_2_description": prev_job_2_description, + "prev_job_3_title": prev_job_3_title, + "prev_job_3_company": prev_job_3_company, + "prev_job_3_start": prev_job_3_start, + "prev_job_3_end": prev_job_3_end, + "prev_job_3_description": prev_job_3_description, + "years_experience": years_experience, + "skills_text": skills_text, + "education_text": education_text, + "target_roles_text": target_roles_text, + "target_locations_text": target_locations_text, + "salary_min": salary_min, + "work_authorization": work_authorization, + "visa_status": visa_status, + "availability": availability, + "remote_preference": remote_preference, + "race_ethnicity": race_ethnicity, + "gender": gender, + "veteran_status": veteran_status, + "disability_status": disability_status, + "resume_file": resume_file, + "cover_letter_template": cover_letter_template, + "save_button": save_button, + "clear_button": clear_button, + "status_message": status_message + }) + + webui_manager.add_components("profile_settings", tab_components) + + # Event handlers + save_button.click( + fn=save_profile_handler, + inputs=[ + full_name, email, phone, address, linkedin_url, portfolio_url, + current_job_title, current_company, current_start_date, current_end_date, current_work_description, + prev_job_1_title, prev_job_1_company, prev_job_1_start, prev_job_1_end, prev_job_1_description, + prev_job_2_title, prev_job_2_company, prev_job_2_start, prev_job_2_end, prev_job_2_description, + prev_job_3_title, prev_job_3_company, prev_job_3_start, prev_job_3_end, prev_job_3_description, + years_experience, skills_text, education_text, + target_roles_text, target_locations_text, salary_min, work_authorization, visa_status, availability, remote_preference, + race_ethnicity, gender, veteran_status, disability_status, + resume_file, cover_letter_template + ], + outputs=[status_message] + ) + + def clear_all_fields(): + """Clear all form fields""" + return [ + "", # full_name + "", # email + "", # phone + "", # address + "", # linkedin_url + "", # portfolio_url + "", # current_job_title + "", # current_company + "", # current_start_date + "Present", # current_end_date + "", # current_work_description + "", # prev_job_1_title + "", # prev_job_1_company + "", # prev_job_1_start + "", # prev_job_1_end + "", # prev_job_1_description + "", # prev_job_2_title + "", # prev_job_2_company + "", # prev_job_2_start + "", # prev_job_2_end + "", # prev_job_2_description + "", # prev_job_3_title + "", # prev_job_3_company + "", # prev_job_3_start + "", # prev_job_3_end + "", # prev_job_3_description + 0, # years_experience + "", # skills_text + "", # education_text + "", # target_roles_text + "", # target_locations_text + 0, # salary_min + None, # work_authorization + "", # visa_status + "", # availability + None, # remote_preference + "Prefer not to answer", # race_ethnicity + "Prefer not to answer", # gender + "Prefer not to answer", # veteran_status + "Prefer not to answer", # disability_status + None, # resume_file + "", # cover_letter_template + gr.update(value="All fields cleared", visible=True) # status_message + ] + + clear_button.click( + fn=clear_all_fields, + inputs=[], + outputs=[ + full_name, email, phone, address, linkedin_url, portfolio_url, + current_job_title, current_company, current_start_date, current_end_date, current_work_description, + prev_job_1_title, prev_job_1_company, prev_job_1_start, prev_job_1_end, prev_job_1_description, + prev_job_2_title, prev_job_2_company, prev_job_2_start, prev_job_2_end, prev_job_2_description, + prev_job_3_title, prev_job_3_company, prev_job_3_start, prev_job_3_end, prev_job_3_description, + years_experience, skills_text, education_text, + target_roles_text, target_locations_text, salary_min, work_authorization, visa_status, availability, remote_preference, + race_ethnicity, gender, veteran_status, disability_status, + resume_file, cover_letter_template, status_message + ] + ) \ No newline at end of file diff --git a/src/webui/interface.py b/src/webui/interface.py index 083649e6..20c8adc0 100644 --- a/src/webui/interface.py +++ b/src/webui/interface.py @@ -1,9 +1,10 @@ import gradio as gr from src.webui.webui_manager import WebuiManager -from src.webui.components.agent_settings_tab import create_agent_settings_tab +from src.webui.components.profile_settings_tab import create_profile_settings_tab from src.webui.components.browser_settings_tab import create_browser_settings_tab -from src.webui.components.browser_use_agent_tab import create_browser_use_agent_tab +from src.webui.components.job_application_tab import create_job_application_tab +from src.webui.components.application_history_tab import create_application_history_tab from src.webui.components.deep_research_agent_tab import create_deep_research_agent_tab from src.webui.components.load_save_config_tab import create_load_save_config_tab @@ -57,39 +58,31 @@ def create_ui(theme_name="Ocean"): ui_manager = WebuiManager() with gr.Blocks( - title="Browser Use WebUI", theme=theme_map[theme_name], css=css, js=js_func, + title="ApplyAgent.AI", theme=theme_map[theme_name], css=css, js=js_func, ) as demo: with gr.Row(): gr.Markdown( """ - # 🌐 Browser Use WebUI - ### Control your browser with AI assistance + # šŸ¤– ApplyAgent.AI + ### Automatically applies to jobs based on your profile """, elem_classes=["header-text"], ) with gr.Tabs() as tabs: - with gr.TabItem("āš™ļø Agent Settings"): - create_agent_settings_tab(ui_manager) + with gr.TabItem("šŸ‘¤ Profile Settings"): + create_profile_settings_tab(ui_manager) with gr.TabItem("🌐 Browser Settings"): create_browser_settings_tab(ui_manager) - with gr.TabItem("šŸ¤– Run Agent"): - create_browser_use_agent_tab(ui_manager) + with gr.TabItem("šŸš€ Apply to Jobs"): + create_job_application_tab(ui_manager) - with gr.TabItem("šŸŽ Agent Marketplace"): - gr.Markdown( - """ - ### Agents built on Browser-Use - """, - elem_classes=["tab-header-text"], - ) - with gr.Tabs(): - with gr.TabItem("Deep Research"): - create_deep_research_agent_tab(ui_manager) + with gr.TabItem("šŸ“‹ Application History"): + create_application_history_tab(ui_manager) - with gr.TabItem("šŸ“ Load & Save Config"): + with gr.TabItem("šŸ“ Settings & Config"): create_load_save_config_tab(ui_manager) return demo diff --git a/src/webui/streamlit_components/__init__.py b/src/webui/streamlit_components/__init__.py new file mode 100644 index 00000000..8e424796 --- /dev/null +++ b/src/webui/streamlit_components/__init__.py @@ -0,0 +1 @@ +# Streamlit components package \ No newline at end of file diff --git a/src/webui/streamlit_components/application_history.py b/src/webui/streamlit_components/application_history.py new file mode 100644 index 00000000..5270eab0 --- /dev/null +++ b/src/webui/streamlit_components/application_history.py @@ -0,0 +1,171 @@ +import streamlit as st +import json +import os +import pandas as pd +from datetime import datetime +from src.webui.streamlit_manager import StreamlitManager + +def create_application_history_page(manager: StreamlitManager): + """Create the application history page in Streamlit""" + + st.markdown("## šŸ“‹ Application History") + st.markdown("View and manage your job application history.") + + # Load application history + applications_file = "data/applications/applications.json" + applications = [] + + if os.path.exists(applications_file): + try: + with open(applications_file, 'r') as f: + applications = json.load(f) + except Exception as e: + st.error(f"Error loading applications: {str(e)}") + + if not applications: + st.info("šŸ“­ No job applications found yet. Start applying to jobs to see your history here!") + return + + # Statistics + st.markdown("### šŸ“Š Application Statistics") + + col1, col2, col3, col4 = st.columns(4) + + total_apps = len(applications) + successful_apps = len([app for app in applications if app.get("status") == "submitted"]) + failed_apps = len([app for app in applications if app.get("status") == "failed"]) + pending_apps = len([app for app in applications if app.get("status") == "pending"]) + + with col1: + st.metric("Total Applications", total_apps) + + with col2: + st.metric("Successful", successful_apps) + + with col3: + st.metric("Failed", failed_apps) + + with col4: + st.metric("Pending", pending_apps) + + # Filter options + st.markdown("### šŸ” Filter Applications") + + col5, col6 = st.columns(2) + + with col5: + status_filter = st.selectbox( + "Filter by Status", + ["All", "submitted", "failed", "pending", "skipped"], + key="status_filter" + ) + + with col6: + company_filter = st.text_input( + "Filter by Company", + placeholder="Enter company name...", + key="company_filter" + ) + + # Filter applications + filtered_apps = applications + + if status_filter != "All": + filtered_apps = [app for app in filtered_apps if app.get("status") == status_filter] + + if company_filter: + filtered_apps = [app for app in filtered_apps if company_filter.lower() in app.get("company", "").lower()] + + # Display applications + st.markdown("### šŸ“‹ Application List") + + if filtered_apps: + # Convert to DataFrame for better display + df_data = [] + for app in filtered_apps: + df_data.append({ + "Date": app.get("applied_date", ""), + "Job Title": app.get("job_title", ""), + "Company": app.get("company", ""), + "Status": app.get("status", ""), + "Location": app.get("location", ""), + "Notes": app.get("notes", "")[:100] + "..." if len(app.get("notes", "")) > 100 else app.get("notes", "") + }) + + df = pd.DataFrame(df_data) + + # Display as interactive table + st.dataframe( + df, + use_container_width=True, + hide_index=True + ) + + # Show detailed view for selected application + if len(df) > 0: + st.markdown("### šŸ“„ Application Details") + + # Select application to view + app_index = st.selectbox( + "Select application to view details", + range(len(filtered_apps)), + format_func=lambda x: f"{filtered_apps[x].get('job_title', 'Unknown')} at {filtered_apps[x].get('company', 'Unknown')}", + key="app_detail_selector" + ) + + selected_app = filtered_apps[app_index] + + with st.expander("View Full Application Details", expanded=True): + col7, col8 = st.columns(2) + + with col7: + st.write(f"**Job Title:** {selected_app.get('job_title', 'N/A')}") + st.write(f"**Company:** {selected_app.get('company', 'N/A')}") + st.write(f"**Status:** {selected_app.get('status', 'N/A')}") + st.write(f"**Applied Date:** {selected_app.get('applied_date', 'N/A')}") + + with col8: + st.write(f"**Location:** {selected_app.get('location', 'N/A')}") + st.write(f"**Job URL:** {selected_app.get('job_url', 'N/A')}") + if selected_app.get('salary_range'): + st.write(f"**Salary Range:** {selected_app.get('salary_range', 'N/A')}") + + if selected_app.get('notes'): + st.write("**Notes:**") + st.write(selected_app.get('notes', '')) + + if selected_app.get('application_data'): + st.write("**Application Data:**") + st.json(selected_app.get('application_data', {})) + + else: + st.info("No applications match your current filters.") + + # Export functionality + st.markdown("### šŸ“¤ Export Data") + + col9, col10 = st.columns(2) + + with col9: + if st.button("šŸ“Š Export to CSV"): + if applications: + df_export = pd.DataFrame(applications) + csv = df_export.to_csv(index=False) + st.download_button( + label="šŸ’¾ Download CSV", + data=csv, + file_name=f"job_applications_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", + mime="text/csv" + ) + else: + st.warning("No applications to export!") + + with col10: + if st.button("šŸ—‘ļø Clear All History"): + if st.checkbox("āš ļø I understand this will delete all application history", key="confirm_delete"): + if os.path.exists(applications_file): + os.remove(applications_file) + st.success("āœ… Application history cleared!") + st.rerun() + else: + st.warning("No application history file found.") \ No newline at end of file diff --git a/src/webui/streamlit_components/browser_settings.py b/src/webui/streamlit_components/browser_settings.py new file mode 100644 index 00000000..63d3d536 --- /dev/null +++ b/src/webui/streamlit_components/browser_settings.py @@ -0,0 +1,115 @@ +import streamlit as st +from src.webui.streamlit_manager import StreamlitManager + +def create_browser_settings_page(manager: StreamlitManager): + """Create the browser settings page in Streamlit""" + + st.markdown("## 🌐 Browser Settings") + st.markdown("Configure browser automation settings for job applications.") + + # Browser Configuration + st.markdown("### šŸ”§ Browser Configuration") + + col1, col2 = st.columns(2) + + with col1: + headless = st.checkbox( + "Headless Mode", + value=manager.browser_config.get("headless", False), + help="Run browser in background (faster but not visible)", + key="browser_headless" + ) + + browser_type = st.selectbox( + "Browser Type", + ["chromium", "firefox", "webkit"], + index=0, + help="Choose the browser engine", + key="browser_type" + ) + + with col2: + window_width = st.number_input( + "Window Width", + min_value=800, + max_value=2560, + value=manager.browser_config.get("window_width", 1280), + step=100, + key="browser_width" + ) + + window_height = st.number_input( + "Window Height", + min_value=600, + max_value=1440, + value=manager.browser_config.get("window_height", 1024), + step=100, + key="browser_height" + ) + + # Automation Settings + st.markdown("### ⚔ Automation Settings") + + col3, col4 = st.columns(2) + + with col3: + page_load_timeout = st.number_input( + "Page Load Timeout (seconds)", + min_value=5, + max_value=60, + value=manager.browser_config.get("page_load_timeout", 30), + help="Maximum time to wait for pages to load", + key="browser_timeout" + ) + + action_delay = st.number_input( + "Action Delay (seconds)", + min_value=0.5, + max_value=5.0, + value=manager.browser_config.get("action_delay", 1.0), + step=0.5, + help="Delay between browser actions", + key="browser_delay" + ) + + with col4: + max_retries = st.number_input( + "Max Retries", + min_value=1, + max_value=10, + value=manager.browser_config.get("max_retries", 3), + help="Maximum number of retries for failed actions", + key="browser_retries" + ) + + enable_screenshots = st.checkbox( + "Enable Screenshots", + value=manager.browser_config.get("enable_screenshots", True), + help="Take screenshots during automation for debugging", + key="browser_screenshots" + ) + + # Save Settings + if st.button("šŸ’¾ Save Browser Settings", type="primary"): + browser_config = { + "headless": headless, + "browser_type": browser_type, + "window_width": window_width, + "window_height": window_height, + "page_load_timeout": page_load_timeout, + "action_delay": action_delay, + "max_retries": max_retries, + "enable_screenshots": enable_screenshots + } + + manager.browser_config = browser_config + manager.save_settings("browser_config.json", browser_config) + + st.success("āœ… Browser settings saved successfully!") + st.rerun() + + # Current Settings Display + if manager.browser_config: + st.markdown("### šŸ“Š Current Browser Settings") + with st.expander("View Current Settings"): + st.json(manager.browser_config) \ No newline at end of file diff --git a/src/webui/streamlit_components/config_manager.py b/src/webui/streamlit_components/config_manager.py new file mode 100644 index 00000000..290e5f61 --- /dev/null +++ b/src/webui/streamlit_components/config_manager.py @@ -0,0 +1,243 @@ +import streamlit as st +import json +import os +from datetime import datetime +from src.webui.streamlit_manager import StreamlitManager + +def create_config_manager_page(manager: StreamlitManager): + """Create the config manager page in Streamlit""" + + st.markdown("## šŸ“ Settings & Configuration") + st.markdown("Manage your application settings, import/export configurations, and view system information.") + + # LLM Configuration + st.markdown("### šŸ¤– LLM Configuration") + + col1, col2 = st.columns(2) + + with col1: + llm_provider = st.selectbox( + "LLM Provider", + ["openai", "anthropic", "azure", "ollama"], + help="Choose your AI model provider", + key="llm_provider" + ) + + model_name = st.text_input( + "Model Name", + value=os.getenv("LLM_MODEL", "gpt-4o"), + help="Specify the model to use", + key="llm_model" + ) + + with col2: + temperature = st.slider( + "Temperature", + min_value=0.0, + max_value=2.0, + value=float(os.getenv("LLM_TEMPERATURE", "0.1")), + step=0.1, + help="Control randomness in AI responses", + key="llm_temperature" + ) + + max_tokens = st.number_input( + "Max Tokens", + min_value=100, + max_value=8000, + value=int(os.getenv("LLM_MAX_TOKENS", "2000")), + help="Maximum tokens per response", + key="llm_max_tokens" + ) + + # API Keys Section + st.markdown("### šŸ”‘ API Keys") + st.info("āš ļø API keys are loaded from environment variables. Update your .env file to change them.") + + col3, col4 = st.columns(2) + + with col3: + openai_key_status = "āœ… Set" if os.getenv("OPENAI_API_KEY") else "āŒ Not Set" + st.write(f"**OpenAI API Key:** {openai_key_status}") + + if os.getenv("OPENAI_ENDPOINT"): + st.write(f"**OpenAI Endpoint:** {os.getenv('OPENAI_ENDPOINT')[:50]}...") + + with col4: + anthropic_key_status = "āœ… Set" if os.getenv("ANTHROPIC_API_KEY") else "āŒ Not Set" + st.write(f"**Anthropic API Key:** {anthropic_key_status}") + + if os.getenv("ANTHROPIC_ENDPOINT"): + st.write(f"**Anthropic Endpoint:** {os.getenv('ANTHROPIC_ENDPOINT')[:50]}...") + + # Configuration Export/Import + st.markdown("### šŸ“¤ Configuration Management") + + col5, col6 = st.columns(2) + + with col5: + st.markdown("#### Export Configuration") + + if st.button("šŸ“¦ Export All Settings"): + config_data = { + "profile": manager.profile_data, + "browser_config": manager.browser_config, + "llm_config": { + "provider": llm_provider, + "model": model_name, + "temperature": temperature, + "max_tokens": max_tokens + }, + "export_timestamp": datetime.now().isoformat() + } + + config_json = json.dumps(config_data, indent=2) + + st.download_button( + label="šŸ’¾ Download Configuration", + data=config_json, + file_name=f"job_agent_config_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json", + mime="application/json" + ) + + with col6: + st.markdown("#### Import Configuration") + + uploaded_config = st.file_uploader( + "Upload Configuration File", + type=['json'], + help="Upload a previously exported configuration file", + key="config_upload" + ) + + if uploaded_config: + try: + config_data = json.load(uploaded_config) + + if st.button("šŸ”„ Import Configuration"): + # Import profile data + if "profile" in config_data: + manager.profile_data = config_data["profile"] + + # Import browser config + if "browser_config" in config_data: + manager.browser_config = config_data["browser_config"] + + st.success("āœ… Configuration imported successfully!") + st.rerun() + + except Exception as e: + st.error(f"āŒ Error importing configuration: {str(e)}") + + # System Information + st.markdown("### šŸ–„ļø System Information") + + with st.expander("View System Details"): + col7, col8 = st.columns(2) + + with col7: + st.write("**Environment Variables:**") + env_vars = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_PROVIDER", "LLM_MODEL", "LLM_TEMPERATURE"] + for var in env_vars: + value = os.getenv(var, "Not Set") + if "API_KEY" in var and value != "Not Set": + value = f"{'*' * 20}...{value[-4:]}" if len(value) > 10 else "Set" + st.write(f"- {var}: {value}") + + with col8: + st.write("**File System:**") + directories = ["data/profile", "data/applications", "data/documents", "tmp/webui_settings"] + for directory in directories: + exists = "āœ…" if os.path.exists(directory) else "āŒ" + st.write(f"- {directory}: {exists}") + + # Data Management + st.markdown("### šŸ—‚ļø Data Management") + + col9, col10, col11 = st.columns(3) + + with col9: + if st.button("🧹 Clear Browser Data"): + # Clear browser cache and cookies + browser_data_dir = "./tmp/browser_data" + if os.path.exists(browser_data_dir): + import shutil + shutil.rmtree(browser_data_dir) + st.success("āœ… Browser data cleared!") + else: + st.info("No browser data to clear.") + + with col10: + if st.button("šŸ”„ Reset All Settings"): + if st.checkbox("āš ļø I understand this will reset all settings", key="confirm_reset"): + manager.profile_data = {} + manager.browser_config = {} + st.success("āœ… All settings reset!") + st.rerun() + + with col11: + if st.button("šŸ“Š View Session State"): + with st.expander("Current Session State"): + st.json({ + "profile_data": manager.profile_data, + "browser_config": manager.browser_config, + "application_status": st.session_state.get("application_status", ""), + "session_keys": list(st.session_state.keys()) + }) + + # Application Version Info + st.markdown("### ā„¹ļø Application Information") + + st.info(""" + **ApplyAgent.AI - Streamlit Version** + + šŸ¤– **Features:** + - Intelligent automated LinkedIn job applications + - Smart profile management with MCP-style tools + - Advanced browser automation with visual feedback + - Comprehensive application history tracking + - Easy configuration import/export + + šŸ“ **Note:** This is the Streamlit version of ApplyAgent.AI. + Make sure your environment variables are properly configured in your `.env` file. + """) + + # Quick Actions + st.markdown("### ⚔ Quick Actions") + + col12, col13, col14 = st.columns(3) + + with col12: + if st.button("šŸ” Test LLM Connection"): + try: + from src.utils import llm_provider + provider = os.getenv("LLM_PROVIDER", "openai") + model = os.getenv("LLM_MODEL", "gpt-4o") + + llm = llm_provider.get_llm_model( + provider=provider, + model_name=model, + temperature=0.1 + ) + st.success(f"āœ… LLM connection successful! Using {provider}/{model}") + except Exception as e: + st.error(f"āŒ LLM connection failed: {str(e)}") + + with col13: + if st.button("šŸ“ Open Data Directory"): + st.info("Data is stored in: ./data/") + if os.path.exists("./data"): + files = os.listdir("./data") + st.write("**Files:**", files) + else: + st.warning("Data directory not found.") + + with col14: + if st.button("šŸ” Check Environment"): + required_vars = ["OPENAI_API_KEY", "LLM_PROVIDER", "LLM_MODEL"] + missing_vars = [var for var in required_vars if not os.getenv(var)] + + if missing_vars: + st.warning(f"āš ļø Missing environment variables: {', '.join(missing_vars)}") + else: + st.success("āœ… All required environment variables are set!") \ No newline at end of file diff --git a/src/webui/streamlit_components/job_application.py b/src/webui/streamlit_components/job_application.py new file mode 100644 index 00000000..d7a992af --- /dev/null +++ b/src/webui/streamlit_components/job_application.py @@ -0,0 +1,1240 @@ +import streamlit as st +import asyncio +import json +import logging +import os +import platform +import uuid +from typing import Any, AsyncGenerator, Dict, Optional +from datetime import datetime + +from browser_use.agent.views import ( + AgentHistoryList, + AgentOutput, +) +from browser_use.browser.browser import BrowserConfig +from browser_use.browser.context import BrowserContext, BrowserContextConfig +from browser_use.browser.views import BrowserState +from langchain_core.language_models.chat_models import BaseChatModel + +from src.agent.browser_use.browser_use_agent import BrowserUseAgent +from src.browser.custom_browser import CustomBrowser +from src.controller.custom_controller import CustomController +from src.utils.llm_provider import get_llm_model +from src.webui.streamlit_manager import StreamlitManager + +logger = logging.getLogger(__name__) +def create_llm(): + provider = os.getenv("LLM_PROVIDER", "ollama") + model_name = os.getenv("LLM_MODEL", "qwen3:8b") + temperature = float(os.getenv("LLM_TEMPERATURE", "0.1")) + + kwargs = { + "provider": provider, + "model_name": model_name, + "temperature": temperature, + } + + if provider == "ollama": + kwargs["base_url"] = os.getenv( + "OLLAMA_ENDPOINT", + "http://host.docker.internal:11434", + ) + else: + kwargs["api_key"] = ( + os.getenv("OPENAI_API_KEY") + or os.getenv("ANTHROPIC_API_KEY") + ) + + return get_llm_model(**kwargs) + +def get_chrome_binary_path(): + """ + Get the Chrome binary path based on the operating system. + Returns None if Chrome is not found at the default location. + """ + system = platform.system() + + if system == "Darwin": # macOS + # Try both the executable and the app bundle + possible_paths = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome.app" # User-provided path + ] + chrome_path = None + for path in possible_paths: + if os.path.exists(path): + # If it's the app bundle, get the executable path + if path.endswith(".app"): + executable_path = os.path.join(path, "Contents/MacOS/Google Chrome") + if os.path.exists(executable_path): + chrome_path = executable_path + break + else: + chrome_path = path + break + elif system == "Windows": + # Common Chrome locations on Windows + possible_paths = [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + os.path.expanduser(r"~\AppData\Local\Google\Chrome\Application\chrome.exe") + ] + chrome_path = None + for path in possible_paths: + if os.path.exists(path): + chrome_path = path + break + elif system == "Linux": + # Common Chrome locations on Linux + possible_paths = [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium-browser", + "/snap/bin/chromium" + ] + chrome_path = None + for path in possible_paths: + if os.path.exists(path): + chrome_path = path + break + else: + chrome_path = None + + return chrome_path + + +# System prompt for LinkedIn job applications (copied to avoid Gradio dependency) +LINKEDIN_JOB_APPLICATION_SYSTEM_PROMPT = """ +You are an expert LinkedIn Job Application Automation Agent designed to process LinkedIn job search results and automatically apply to positions using stored profile data. + +## PRIMARY MISSION +Process LinkedIn job search URLs, extract individual job listings, and automatically apply to jobs with "Easy Apply" buttons using the user's MCP-stored profile information. + +## CORE WORKFLOW + +### 1. PROFILE PREPARATION +- ALWAYS start by calling `get_profile()` to fetch the latest user profile data +- Cache profile information for the session - NEVER fabricate missing data +- If critical fields are missing, report what's needed and pause execution + +### 2. LINKEDIN SEARCH URL PROCESSING +- Navigate to the provided LinkedIn job search URL +- Handle LinkedIn authentication if prompted (user should be logged in) +- Scroll through search results to load all job listings (handle pagination) +- Extract from each job card: + * Job title and company name + * Job posting URL/link + * Whether "Easy Apply" button is available + * Location and other basic details +- Build a list of applicable jobs for processing + +### 3. INDIVIDUAL JOB APPLICATION FLOW +For each job with "Easy Apply" available: + +**A. Navigation & Access** +- Click on the job title/card to open job details +- Locate and click the "Easy Apply" button +- Wait for application modal/form to load + +**B. Form Field Population (use profile data ONLY)** +- **Personal Info**: Full name, email, phone, address from profile.personal +- **Current Position**: Use profile.professional.current_position data +- **Work Experience**: Include current and previous positions with dates and descriptions +- **Education**: Use profile.professional.education data +- **Skills**: Use profile.professional.skills data +- **Work Authorization**: Use profile.preferences.work_authorization +- **Visa Status**: Use profile.preferences.visa_status +- **Salary Expectations**: Use profile.preferences.salary_min +- **Availability**: Use profile.preferences.availability +- **EEO Information**: Use profile.eeo_information (race, gender, veteran status, disability) if required + +**C. Resume Upload** +- Locate file upload field for resume +- Upload file from profile.documents.resume_path +- Wait for upload confirmation + +**D. Application Questions Handling** +- Answer standard questions using profile data +- For unknown questions, use LLM reasoning based on profile context +- If personal decision required, skip and note in application log +- Common question types: + * Years of experience → calculate from profile + * Salary requirements → use profile.preferences.salary_min + * Start date → use profile.preferences.availability + * Work authorization → use profile.preferences.work_authorization + * Cover letter → use profile.documents.cover_letter_template if needed + +**E. Submission & Verification** +- Click submit/send application button +- Wait for confirmation message (e.g., "Application submitted", "Thank you for applying") +- Take screenshot of confirmation for verification +- If error occurs, capture error message + +### 4. APPLICATION LOGGING +After each application attempt, call `log_application()` with: +- job_title: Extracted job title +- company: Company name +- job_url: Direct job posting URL +- status: "submitted" | "failed" | "skipped" +- notes: Detailed outcome including any errors or specific reasons + +### 5. PROGRESS TRACKING +- Update progress counter: "Processing job X of Y" +- Report real-time status for each application +- Provide summary statistics at completion + +## ERROR HANDLING & EDGE CASES + +### Rate Limiting & Delays +- Add 3-5 second delays between applications +- If rate limited, wait 30-60 seconds before retrying +- Respect LinkedIn's usage policies + +### Application Failures +- **CAPTCHA Detected**: Mark as failed, log reason, continue to next job +- **Login Required**: Pause and report login needed +- **Job No Longer Available**: Mark as skipped, continue +- **Form Submission Errors**: Retry once, then mark as failed +- **Missing Profile Data**: Use available data, note missing fields + +### Quality Assurance +- Verify form fields are populated before submission +- Confirm resume upload succeeded +- Validate application confirmation message +- Take screenshots for critical steps + +## SUCCESS CRITERIA +- Extract all available Easy Apply jobs from search results +- Successfully submit applications using only verified profile data +- Log comprehensive results for each attempt +- Provide real-time progress updates +- Handle errors gracefully without stopping the entire process + +## IMPORTANT CONSTRAINTS +- NEVER invent or guess profile information +- Only apply to jobs with "Easy Apply" buttons +- Skip jobs requiring external applications or complex processes +- Maintain professional, respectful interaction with all forms +- Stop immediately if instructed by stop button + +Your goal is to efficiently and accurately process LinkedIn job applications while maintaining data integrity and providing comprehensive logging for the user's review. +""" + +def create_job_application_page(manager: StreamlitManager): + """ + Create the Streamlit job application page + """ + + st.markdown("## šŸš€ Automated Job Applications") + st.markdown("Enter LinkedIn job search URLs and let the agent automatically apply using your saved profile.") + + # LinkedIn Credentials Section + st.markdown("### šŸ” LinkedIn Login Credentials") + st.info("*Required for applying to jobs. Your credentials are only used for this session and not stored permanently.*") + + col1, col2 = st.columns(2) + + with col1: + linkedin_email = st.text_input( + "LinkedIn Email", + placeholder="your-email@example.com", + help="Your LinkedIn account email", + key="linkedin_email" + ) + + with col2: + linkedin_password = st.text_input( + "LinkedIn Password", + placeholder="Enter your LinkedIn password", + type="password", + help="Your LinkedIn account password", + key="linkedin_password" + ) + + # Job URLs Input Section + st.markdown("### šŸ“‹ Job Search URLs") + job_urls = st.text_area( + "LinkedIn Job Search URLs (one per line)", + height=150, + placeholder="""https://www.linkedin.com/jobs/search/?keywords=data%20scientist&location=San%20Francisco +https://www.linkedin.com/jobs/search/?keywords=software%20engineer&location=Remote""", + help="šŸ’” Pro tip: Use LinkedIn's job search filters to find your target roles, then paste the results URL here!", + key="job_urls" + ) + + # Optional Overrides Section + st.markdown("### šŸŽÆ Optional Overrides") + + col3, col4 = st.columns(2) + + with col3: + override_role = st.text_input( + "Override Target Role (Optional)", + placeholder="e.g., Senior Software Engineer", + help="Override the target role from your profile for these applications", + key="override_role" + ) + + with col4: + override_location = st.text_input( + "Override Target Location (Optional)", + placeholder="e.g., San Francisco, CA", + help="Override the target location from your profile for these applications", + key="override_location" + ) + + # Action Buttons + col5, col6, col7 = st.columns([1, 1, 1]) + + with col5: + apply_button = st.button("šŸš€ Apply to Jobs", type="primary", use_container_width=True) + + with col6: + test_button = st.button("🧪 Test Setup", use_container_width=True) + + with col7: + stop_button = st.button("šŸ›‘ Stop", use_container_width=True) + + # Initialize session state for application process + if 'application_running' not in st.session_state: + st.session_state.application_running = False + if 'application_logs' not in st.session_state: + st.session_state.application_logs = [] + if 'application_status' not in st.session_state: + st.session_state.application_status = "" + + # Progress Status + if st.session_state.application_status: + st.info(f"⚔ Progress Status: {st.session_state.application_status}") + + # Handle test button + if test_button: + st.info("🧪 Testing setup...") + + # Test environment variables + provider = os.getenv("LLM_PROVIDER", "ollama") + + if provider == "ollama": + st.success("āœ… Using local Ollama (no API key required)") + else: + api_key = os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY") + if api_key: + st.success("āœ… API key found") + else: + st.error("āŒ No API key found") + + # Test LLM provider + try: + provider = os.getenv("LLM_PROVIDER", "openai") + model_name = os.getenv("LLM_MODEL", "gpt-4o") + llm = create_llm() + st.success(f"āœ… LLM provider working: {provider}/{model_name}") + except Exception as e: + st.error(f"āŒ LLM provider error: {str(e)}") + + # Test browser setup + try: + import playwright + st.success("āœ… Playwright installed") + except ImportError: + st.error("āŒ Playwright not installed") + + # Test inputs + if linkedin_email and linkedin_password and job_urls: + st.success("āœ… All required fields filled") + else: + st.warning("āš ļø Please fill in all required fields to test") + + # Handle stop button + if stop_button and st.session_state.application_running: + st.session_state.application_running = False + st.session_state.application_status = "Application process stopped by user" + # TODO: Implement actual stop mechanism for async task + st.warning("šŸ›‘ Stop signal sent. The application process will halt.") + st.rerun() + + # Handle apply button + if apply_button: + if not linkedin_email or not linkedin_password or not job_urls: + st.error("āŒ Please fill in all required fields: LinkedIn email, password, and job URLs.") + else: + # Parse URLs + job_urls_list = [url.strip() for url in job_urls.strip().split('\n') if url.strip()] + linkedin_urls = [url for url in job_urls_list if "linkedin.com" in url] + + if not linkedin_urls: + st.error("āŒ No valid LinkedIn URLs found. Please provide LinkedIn job search URLs.") + return + + # Initialize immediately and show browser + try: + # Setup LLM + provider = os.getenv("LLM_PROVIDER", "openai") + model_name = os.getenv("LLM_MODEL", "gpt-4o") + + + llm = create_llm() + + # Create detailed task for the agent + enhanced_task = f""" + LINKEDIN JOB APPLICATION AUTOMATION TASK + + CREDENTIALS (CRITICAL - USE EXACTLY): + - LinkedIn Email: {linkedin_email} + - LinkedIn Password: {linkedin_password} + + TASK STEPS: + 1. Navigate to LinkedIn.com + 2. Login using the EXACT credentials above (not placeholder emails) + 3. Go to this job search URL: {linkedin_urls[0]} + 4. Find all jobs with "Easy Apply" buttons + 5. For each job: + - Click "Easy Apply" + - Fill out the application form with professional information + - Upload resume if requested + - ALWAYS scroll down to find Submit buttons + - SUBMIT the application completely (never save as draft) + - Wait for "Thank you for applying" confirmation + - Continue to next job + + CRITICAL SUBMISSION RULES: + - ALWAYS scroll down after filling forms to find Submit buttons + - NEVER click "Save" or "Save as Draft" - only click "Submit" + - Look for "Submit Application", "Send Application", "Apply Now" buttons + - If you see a save prompt, click "Discard" and find the Submit button + - Complete EVERY application until you see success confirmation + + IMPORTANT: + - Use REAL credentials provided, not placeholders + - Apply to as many jobs as possible + - Submit applications completely, not as drafts + - Scroll down aggressively to find Submit buttons + """ + + # Start browser automation immediately + st.session_state.application_running = True + st.session_state.application_logs = [{"role": "assistant", "content": f"šŸš€ Starting LinkedIn automation for {len(linkedin_urls)} search URL(s)!"}] + + # Create browser configuration for visible automation + chrome_path = get_chrome_binary_path() + + if chrome_path: + st.info(f"🌐 Using local Chrome browser: {chrome_path}") + # Configure for external Chrome browser (like in browser_use_agent_tab.py) + extra_browser_args = [ + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-web-security", + "--disable-features=VizDisplayCompositor", + ] + browser_config = BrowserConfig( + headless=False, + browser_type="chromium", + browser_binary_path=chrome_path, + extra_browser_args=extra_browser_args, + disable_security=True, + ) + else: + st.warning("āš ļø Chrome not found at default location. Using built-in Chromium browser.") + browser_config = BrowserConfig( + headless=False, + browser_type="chromium", + disable_security=True, + extra_browser_args=[ + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-web-security", + "--disable-features=VizDisplayCompositor", + ] + ) + + # Start automation immediately without extra button click + st.info("🌐 Opening LinkedIn browser window and starting automation...") + + try: + import asyncio + + # Run the automation directly + automation_success = asyncio.run(run_linkedin_automation_async( + enhanced_task, + llm, + linkedin_urls, + browser_config, + manager + )) + + if automation_success: + st.success("āœ… LinkedIn automation started! Check the browser window.") + st.session_state.application_logs.append({ + "role": "assistant", + "content": "🌐 LinkedIn browser opened and automation started automatically!" + }) + st.session_state.application_logs.append({ + "role": "assistant", + "content": "šŸ‘€ The browser is now applying to jobs automatically with improved scrolling." + }) + else: + st.error("āŒ Failed to start LinkedIn automation") + st.session_state.application_logs.append({ + "role": "assistant", + "content": "āŒ Failed to start automation" + }) + except Exception as e: + st.error(f"āŒ Automation error: {str(e)}") + st.session_state.application_logs.append({ + "role": "assistant", + "content": f"āŒ Automation failed: {str(e)}" + }) + + st.rerun() + + except Exception as e: + st.error(f"āŒ Setup error: {str(e)}") + return + + # Application Process Log + st.markdown("### šŸ¤– Application Process Log") + + # Display logs + if st.session_state.application_logs: + log_container = st.container() + with log_container: + for log_entry in st.session_state.application_logs: + if log_entry.get("role") == "assistant": + st.success(log_entry.get("content", "")) + elif log_entry.get("role") == "user": + st.info(log_entry.get("content", "")) + else: + st.write(log_entry.get("content", "")) + else: + st.write("*No logs yet. Click 'Apply to Jobs' to start the automation process.*") + + # Automation starts automatically now - no extra button needed + + # Clear logs button + if st.button("šŸ—‘ļø Clear Log"): + st.session_state.application_logs = [] + st.session_state.application_status = "" + st.session_state.application_running = False + st.rerun() + + +async def run_streamlit_job_application_task( + linkedin_email: str, + linkedin_password: str, + job_urls: str, + override_role: str, + override_location: str, + manager: StreamlitManager +) -> AsyncGenerator[tuple[list, str], None]: + """ + Main function for LinkedIn job application automation with user credentials - Streamlit version + """ + + # Validate inputs + if not linkedin_email or not linkedin_email.strip(): + yield [{"role": "assistant", "content": "āŒ LinkedIn email is required. Please enter your LinkedIn email address."}], "Error: LinkedIn email required" + return + + if not linkedin_password or not linkedin_password.strip(): + yield [{"role": "assistant", "content": "āŒ LinkedIn password is required. Please enter your LinkedIn password."}], "Error: LinkedIn password required" + return + + if not job_urls or not job_urls.strip(): + yield [{"role": "assistant", "content": "āŒ Please enter at least one LinkedIn job search URL."}], "Error: No URLs provided" + return + + # Clean and validate credentials + linkedin_email = linkedin_email.strip() + linkedin_password = linkedin_password.strip() + + yield [{"role": "assistant", "content": f"šŸ” LinkedIn credentials received for: {linkedin_email}\nšŸ”‘ Password length: {len(linkedin_password)} characters\nšŸŽÆ Starting job application automation..."}], "Initializing with your LinkedIn credentials..." + + # Debug: Confirm credentials are properly set + if not linkedin_email or "@" not in linkedin_email: + yield [{"role": "assistant", "content": "āŒ Invalid LinkedIn email format. Please provide a valid email address."}], "Error: Invalid email format" + return + + if len(linkedin_password) < 6: + yield [{"role": "assistant", "content": "āŒ LinkedIn password seems too short. Please check your password."}], "Error: Password validation failed" + return + + yield [{"role": "assistant", "content": f"āœ… Credentials validated successfully!\nšŸ“§ Email: {linkedin_email}\nšŸ”‘ Password: {'*' * len(linkedin_password)}\n\nThese EXACT credentials will be used for LinkedIn login."}], "Credentials validated - ready to start" + + # Parse job URLs + job_urls_list = [url.strip() for url in job_urls.strip().split('\n') if url.strip()] + + # Validate LinkedIn URLs + linkedin_urls = [] + for url in job_urls_list: + if "linkedin.com" in url: + linkedin_urls.append(url) + else: + yield [{"role": "assistant", "content": f"āš ļø Skipping non-LinkedIn URL: {url}"}], "Validating URLs..." + + if not linkedin_urls: + yield [{"role": "assistant", "content": "āŒ No valid LinkedIn URLs found. Please provide LinkedIn job search URLs."}], "Error: No valid LinkedIn URLs" + return + + yield [{"role": "assistant", "content": f"šŸŽÆ Starting LinkedIn job application automation for {len(linkedin_urls)} search URL(s)"}], f"Initializing automation for {len(linkedin_urls)} LinkedIn search(es)..." + + # Initialize LLM with default values or from environment + try: + # Use default provider settings or environment variables + provider = os.getenv("LLM_PROVIDER", "openai") + model_name = os.getenv("LLM_MODEL", "gpt-4o") + temperature = float(os.getenv("LLM_TEMPERATURE", "0.1")) + base_url = os.getenv("OPENAI_ENDPOINT") or os.getenv("ANTHROPIC_ENDPOINT") or None + api_key = os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY") or None + + llm: BaseChatModel = get_llm_model( + provider=provider, + model_name=model_name, + temperature=temperature, + base_url=base_url, + api_key=api_key, + ) + except Exception as e: + yield [{"role": "assistant", "content": f"āŒ LLM configuration error: {str(e)}\n\nPlease check your environment variables:\n- OPENAI_API_KEY or ANTHROPIC_API_KEY\n- Optionally: LLM_PROVIDER, LLM_MODEL, LLM_TEMPERATURE"}], "Error: LLM configuration required" + return + + # Browser configuration - Make it VISIBLE so you can see LinkedIn automation using local Chrome + chrome_path = get_chrome_binary_path() + + if chrome_path: + browser_config = BrowserConfig( + headless=False, # VISIBLE browser window + browser_type="chromium", + browser_binary_path=chrome_path, + user_data_dir=getattr(manager, 'browser_user_data_dir', None), + disable_security=True, # Allow easier LinkedIn automation + extra_browser_args=[ + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-web-security", + "--disable-features=VizDisplayCompositor", + ] + ) + else: + browser_config = BrowserConfig( + headless=False, # VISIBLE browser window + browser_type="chromium", + user_data_dir=getattr(manager, 'browser_user_data_dir', None), + disable_security=True, # Allow easier LinkedIn automation + extra_browser_args=[ + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-web-security", + "--disable-features=VizDisplayCompositor", + ] + ) + + yield [{"role": "assistant", "content": "🌐 Initializing browser and preparing for LinkedIn automation..."}], "Setting up VISIBLE browser for LinkedIn..." + + try: + browser = CustomBrowser(config=browser_config) + await browser.async_start() + + browser_context_config = BrowserContextConfig( + window_width=1280, + window_height=1024, + ) + context = await browser.create_context(config=browser_context_config) + + # Initialize custom controller with MCP tools + controller = CustomController() + manager.bu_controller = controller + + total_applications = 0 + successful_applications = 0 + failed_applications = 0 + + # Create agent with enhanced credentials prompt + enhanced_prompt = f""" + {LINKEDIN_JOB_APPLICATION_SYSTEM_PROMPT} + + ## CRITICAL: Use These Exact LinkedIn Credentials + LinkedIn Email: {linkedin_email} + LinkedIn Password: {linkedin_password} + + When you encounter LinkedIn login: + 1. Enter EXACTLY this email: {linkedin_email} + 2. Enter EXACTLY this password: {linkedin_password} + 3. Do NOT use placeholder emails like "your_email@example.com" + 4. These are the user's REAL LinkedIn credentials + + Override Settings (if provided): + - Target Role: {override_role if override_role else "Use profile default"} + - Target Location: {override_location if override_location else "Use profile default"} + """ + + agent = BrowserUseAgent( + task=enhanced_prompt, + llm=llm, + browser_context=context, + controller=controller, + max_actions_per_step=10, + enable_memory=False,# Increased for faster processing + use_vision=False, + ) + + yield [{"role": "assistant", "content": f"šŸ¤– Agent initialized with enhanced credentials prompt\nšŸ“§ Will use: {linkedin_email}\nšŸ”‘ Will use: {'*' * len(linkedin_password)}\n\nšŸš€ Starting job application process..."}], "Agent ready with your credentials" + + # Process each LinkedIn search URL + for url_index, linkedin_url in enumerate(linkedin_urls, 1): + yield [{"role": "assistant", "content": f"šŸ” Processing search URL {url_index}/{len(linkedin_urls)}: {linkedin_url}"}], f"Processing URL {url_index}/{len(linkedin_urls)}" + + try: + # Start the application process for this URL + result = await agent.run(f"Process this LinkedIn job search URL and apply to all Easy Apply jobs: {linkedin_url}") + + # Extract results from agent execution + action_count = len(result.history) + yield [{"role": "assistant", "content": f"āœ… Completed processing URL {url_index}/{len(linkedin_urls)}\nšŸ“Š Actions taken: {action_count}\nšŸŽÆ Check browser for application results"}], f"URL {url_index} completed - {action_count} actions" + + total_applications += 1 # This is a rough count - the agent logs actual applications + + except Exception as e: + failed_applications += 1 + error_msg = f"āŒ Error processing URL {url_index}: {str(e)}" + yield [{"role": "assistant", "content": error_msg}], f"Error on URL {url_index}" + continue + + # Final summary + yield [{"role": "assistant", "content": f"šŸŽ‰ Job application automation completed!\n\nšŸ“Š Summary:\n- URLs processed: {len(linkedin_urls)}\n- Total operations: {total_applications}\n- Errors: {failed_applications}\n\nāœ… Check your LinkedIn account for application confirmations\nšŸ“‹ Review the application history in the next tab"}], "Automation completed successfully" + + except Exception as e: + error_msg = f"āŒ Critical browser error: {str(e)}" + yield [{"role": "assistant", "content": error_msg}], f"Browser error: {str(e)[:50]}..." + + finally: + # Cleanup + try: + if 'browser' in locals(): + await browser.close() + except: + pass + + +def run_job_application_process(job_inputs: dict, manager: StreamlitManager): + """ + Streamlit-compatible job application process runner + """ + + linkedin_email = job_inputs["linkedin_email"] + linkedin_password = job_inputs["linkedin_password"] + job_urls = job_inputs["job_urls"] + override_role = job_inputs["override_role"] + override_location = job_inputs["override_location"] + + # Show progress + progress_bar = st.progress(0) + status_placeholder = st.empty() + + try: + # Update status + status_placeholder.info("šŸ” Validating LinkedIn credentials...") + progress_bar.progress(10) + + # Validate inputs + if not linkedin_email or not linkedin_email.strip(): + st.error("āŒ LinkedIn email is required.") + st.session_state.application_running = False + return + + if not linkedin_password or not linkedin_password.strip(): + st.error("āŒ LinkedIn password is required.") + st.session_state.application_running = False + return + + if not job_urls or not job_urls.strip(): + st.error("āŒ Please enter at least one LinkedIn job search URL.") + st.session_state.application_running = False + return + + # Clean and validate credentials + linkedin_email = linkedin_email.strip() + linkedin_password = linkedin_password.strip() + + status_placeholder.success(f"āœ… Credentials validated for: {linkedin_email}") + progress_bar.progress(20) + + # Parse job URLs + job_urls_list = [url.strip() for url in job_urls.strip().split('\n') if url.strip()] + + # Validate LinkedIn URLs + linkedin_urls = [] + for url in job_urls_list: + if "linkedin.com" in url: + linkedin_urls.append(url) + else: + st.warning(f"āš ļø Skipping non-LinkedIn URL: {url}") + + if not linkedin_urls: + st.error("āŒ No valid LinkedIn URLs found.") + st.session_state.application_running = False + return + + status_placeholder.info(f"šŸŽÆ Found {len(linkedin_urls)} LinkedIn search URL(s)") + progress_bar.progress(30) + + # Initialize LLM and browser immediately to show the window + try: + # Initialize LLM + provider = os.getenv("LLM_PROVIDER", "openai") + model_name = os.getenv("LLM_MODEL", "gpt-4o") + temperature = float(os.getenv("LLM_TEMPERATURE", "0.1")) + + + llm = create_llm() + + status_placeholder.success("āœ… LLM initialized successfully") + progress_bar.progress(50) + + # Create a unique task description + task_description = f""" + You are a LinkedIn Job Application Assistant. Your task is to: + + 1. Open and login to LinkedIn using these EXACT credentials: + - Email: {linkedin_email} + - Password: {linkedin_password} + + 2. Navigate to this job search URL: {linkedin_urls[0] if linkedin_urls else 'No URL provided'} + + 3. Find all jobs with "Easy Apply" buttons and apply to them automatically + + 4. For each application: + - Click "Easy Apply" + - Fill out the application form + - Upload resume if needed + - Submit the application + - Move to the next job + + IMPORTANT: Use the EXACT credentials provided. Do not use placeholder emails. + """ + + # Store task in session state to be executed + st.session_state.automation_task = { + "description": task_description, + "llm": llm, + "linkedin_urls": linkedin_urls, + "status": "ready_to_start" + } + + status_placeholder.success("šŸš€ Ready to start browser automation!") + progress_bar.progress(70) + + # Show the start automation button + st.success("āœ… Setup complete! Click below to start browser automation.") + + if st.button("🌐 Open Browser & Start LinkedIn Automation", type="primary"): + st.session_state.automation_task["status"] = "starting" + st.rerun() + + except Exception as e: + st.error(f"āŒ Setup error: {str(e)}") + st.session_state.application_running = False + + except Exception as e: + st.error(f"āŒ Error starting job application process: {str(e)}") + st.session_state.application_running = False + st.session_state.application_status = f"Error: {str(e)}" + + +async def run_streamlit_job_application_task_sync( + linkedin_email: str, + linkedin_password: str, + job_urls: str, + override_role: str, + override_location: str, + manager: StreamlitManager, + progress_bar, + status_placeholder +): + """ + Streamlit-compatible async job application task + """ + + try: + # Initialize LLM + provider = os.getenv("LLM_PROVIDER", "openai") + model_name = os.getenv("LLM_MODEL", "gpt-4o") + temperature = float(os.getenv("LLM_TEMPERATURE", "0.1")) + base_url = os.getenv("OPENAI_ENDPOINT") or os.getenv("ANTHROPIC_ENDPOINT") or None + api_key = os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY") or None + + llm: BaseChatModel = create_llm() + + # Browser configuration using local Chrome + chrome_path = get_chrome_binary_path() + + if chrome_path: + browser_config = BrowserConfig( + headless=False, # VISIBLE browser window + browser_type="chromium", + browser_binary_path=chrome_path, + user_data_dir=getattr(manager, 'browser_user_data_dir', None), + disable_security=True, + extra_browser_args=[ + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-web-security", + "--disable-features=VizDisplayCompositor", + ] + ) + else: + browser_config = BrowserConfig( + headless=False, # VISIBLE browser window + browser_type="chromium", + user_data_dir=getattr(manager, 'browser_user_data_dir', None), + disable_security=True, + extra_browser_args=[ + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-web-security", + "--disable-features=VizDisplayCompositor", + ] + ) + + add_log_entry("assistant", "🌐 Initializing browser for LinkedIn automation...") + progress_bar.progress(50) + + browser = CustomBrowser(config=browser_config) + await browser.async_start() + + browser_context_config = BrowserContextConfig( + window_width=1280, + window_height=1024, + ) + context = await browser.create_context(config=browser_context_config) + + # Initialize custom controller + controller = CustomController() + manager.bu_controller = controller + + # Create enhanced prompt with credentials + enhanced_prompt = f""" + {LINKEDIN_JOB_APPLICATION_SYSTEM_PROMPT} + + ## CRITICAL: Use These Exact LinkedIn Credentials + LinkedIn Email: {linkedin_email} + LinkedIn Password: {linkedin_password} + + When you encounter LinkedIn login: + 1. Enter EXACTLY this email: {linkedin_email} + 2. Enter EXACTLY this password: {linkedin_password} + 3. Do NOT use placeholder emails like "your_email@example.com" + 4. These are the user's REAL LinkedIn credentials + + Override Settings (if provided): + - Target Role: {override_role if override_role else "Use profile default"} + - Target Location: {override_location if override_location else "Use profile default"} + """ + + agent = BrowserUseAgent( + task=enhanced_prompt, + llm=llm, + browser_context=context, + controller=controller, + max_actions_per_step=10, + enable_memory=False, + use_vision=False, + ) + + add_log_entry("assistant", f"šŸ¤– Agent initialized with your LinkedIn credentials\nšŸ“§ Email: {linkedin_email}\nšŸ”‘ Password: {'*' * len(linkedin_password)}") + progress_bar.progress(60) + + # Parse job URLs + job_urls_list = [url.strip() for url in job_urls.strip().split('\n') if url.strip()] + linkedin_urls = [url for url in job_urls_list if "linkedin.com" in url] + + # Process each LinkedIn search URL + for url_index, linkedin_url in enumerate(linkedin_urls, 1): + add_log_entry("assistant", f"šŸ” Processing search URL {url_index}/{len(linkedin_urls)}: {linkedin_url}") + progress_bar.progress(60 + (30 * url_index / len(linkedin_urls))) + + try: + # Start the application process for this URL + result = await agent.run(f"Process this LinkedIn job search URL and apply to all Easy Apply jobs: {linkedin_url}") + + # Extract results + action_count = len(result.history) + add_log_entry("assistant", f"āœ… Completed processing URL {url_index}/{len(linkedin_urls)}\nšŸ“Š Actions taken: {action_count}\nšŸŽÆ Check browser for application results") + + except Exception as e: + add_log_entry("assistant", f"āŒ Error processing URL {url_index}: {str(e)}") + continue + + # Final summary + add_log_entry("assistant", f"šŸŽ‰ Job application automation completed!\n\nšŸ“Š Summary:\n- URLs processed: {len(linkedin_urls)}\nāœ… Check your LinkedIn account for application confirmations") + progress_bar.progress(100) + + except Exception as e: + add_log_entry("assistant", f"āŒ Critical error: {str(e)}") + + finally: + # Cleanup + try: + if 'browser' in locals(): + await browser.close() + except: + pass + + # Update session state + st.session_state.application_running = False + st.session_state.application_status = "Automation completed" + + +def run_browser_automation(manager: StreamlitManager): + """ + Run browser automation with visible browser window + """ + + if 'automation_task' not in st.session_state: + st.error("āŒ No automation task found") + return + + task = st.session_state.automation_task + + st.info("🌐 Opening browser window for LinkedIn automation...") + + try: + # Create browser configuration for VISIBLE automation using local Chrome + chrome_path = get_chrome_binary_path() + + if chrome_path: + browser_config = BrowserConfig( + headless=False, # VISIBLE browser + browser_type="chromium", + browser_binary_path=chrome_path, + disable_security=True, + extra_browser_args=[ + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-web-security", + "--disable-features=VizDisplayCompositor", + ] + ) + else: + browser_config = BrowserConfig( + headless=False, # VISIBLE browser + browser_type="chromium", + disable_security=True, + extra_browser_args=[ + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-web-security", + "--disable-features=VizDisplayCompositor", + ] + ) + + # Start automation in a way that's compatible with Streamlit + import asyncio + + # Check if there's already an event loop running + try: + loop = asyncio.get_running_loop() + except RuntimeError: + # No event loop running, create a new one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # Run the browser automation + browser_started = loop.run_until_complete(start_browser_automation( + task["description"], + task["llm"], + task["linkedin_urls"], + browser_config, + manager + )) + + if browser_started: + st.success("āœ… Browser automation started! Check the browser window.") + st.session_state.automation_task["status"] = "running" + st.session_state.application_logs.append({ + "role": "assistant", + "content": "🌐 Browser window opened! LinkedIn automation is now running in the visible browser." + }) + else: + st.error("āŒ Failed to start browser automation") + st.session_state.automation_task["status"] = "failed" + + except Exception as e: + st.error(f"āŒ Browser automation error: {str(e)}") + st.session_state.automation_task["status"] = "failed" + st.session_state.application_logs.append({ + "role": "assistant", + "content": f"āŒ Browser automation failed: {str(e)}" + }) + + +async def start_browser_automation(task_description: str, llm, linkedin_urls: list, browser_config, manager: StreamlitManager): + """ + Start the actual browser automation + """ + + try: + # Initialize browser + browser = CustomBrowser(config=browser_config) + await browser.async_start() + + # Create browser context + browser_context_config = BrowserContextConfig( + window_width=1280, + window_height=1024, + ) + context = await browser.create_context(config=browser_context_config) + + # Initialize controller + controller = CustomController() + manager.bu_controller = controller + + # Create browser agent + agent = BrowserUseAgent( + task=task_description, + llm=llm, + browser_context=context, + controller=controller, + max_actions_per_step=10, + enable_memory=False, + use_vision=False, + ) + + # Start the agent (this will open LinkedIn and begin automation) + result = await agent.run(task_description) + + # The browser will remain open for manual inspection + # Don't close the browser so user can see the results + + return True + + except Exception as e: + print(f"Browser automation error: {str(e)}") + return False + + +# Removed old start_linkedin_automation function - automation now starts automatically + + +async def run_linkedin_automation_async(task_description: str, llm, linkedin_urls: list, browser_config, manager: StreamlitManager): + """ + Async function to run LinkedIn automation with improved scrolling and complete application flow + """ + + browser = None + try: + print(f"šŸ” Starting LinkedIn automation with enhanced scrolling...") + print(f"šŸ“‹ Task preview: {task_description[:200]}...") + + # Initialize browser + browser = CustomBrowser(config=browser_config) + await browser.async_start() + print("āœ… Browser started successfully") + + # Create browser context + context = await browser.create_context() + print("āœ… Browser context created") + + # Initialize controller + controller = CustomController() + manager.bu_controller = controller + print("āœ… Controller initialized") + + # Create browser automation agent with enhanced task + enhanced_task_with_scrolling = f""" + {task_description} + + CRITICAL INSTRUCTIONS FOR COMPLETE APPLICATION SUBMISSION: + + šŸ”„ MANDATORY SCROLLING BEHAVIOR: + - At EVERY step, if you don't see the "Next", "Submit", "Review", or "Continue" button immediately + - ALWAYS scroll down to the bottom of the page using scroll_down action + - Keep scrolling until you find the correct button + - Look specifically for buttons with text containing "Submit", "Send Application", "Apply", or "Finish" + + ā›” NEVER SAVE AS DRAFT: + - NEVER click buttons with text "Save", "Save as Draft", or "Save for Later" + - If you see both "Save" and "Submit" options, ALWAYS choose "Submit" + - If you accidentally see a save prompt, click "Discard" and try again + - The goal is to SUBMIT applications, not save them + + šŸ“ STEP-BY-STEP SUBMISSION PROCESS: + 1. Fill out contact information → Click "Next" + 2. Select/upload resume → Click "Next" + 3. Answer additional questions → Scroll down → Look for "Review" or "Submit" + 4. Review application → Scroll down → Look for "Submit Application" button + 5. Submit → Wait for confirmation message + 6. Move to next job + + šŸŽÆ SUBMIT BUTTON FINDING STRATEGY: + - After answering questions, scroll down multiple times if needed + - Look for buttons at the very bottom of forms + - Search for text like "Submit Application", "Send Application", "Apply Now" + - If you see "Review your application", click it, then scroll down for Submit + + šŸ† SUCCESS CONFIRMATION: + - You have successfully applied when you see: + * "Thank you for applying" + * "Application submitted successfully" + * "Your application has been sent" + * "Application complete" + * Confirmation page with success message + - ONLY move to the next job after seeing these confirmations + """ + + agent = BrowserUseAgent( + task=enhanced_task_with_scrolling, + llm=llm, + browser_context=context, + controller=controller, + enable_memory=False, + use_vision=False, + ) + print("āœ… Agent created with enhanced scrolling instructions") + + # Start the automation (this opens LinkedIn and begins applying) + print("šŸš€ Starting enhanced LinkedIn automation...") + result = await agent.run() + + # Keep browser open for user to see results + action_count = len(result.history) if hasattr(result, 'history') else 0 + print(f"āœ… LinkedIn automation completed with {action_count} actions") + print("šŸŽ‰ Browser window will remain open for you to review the results") + + return True + + except Exception as e: + print(f"āŒ LinkedIn automation error: {str(e)}") + print(f"Error type: {type(e)}") + import traceback + traceback.print_exc() + + # Clean up browser if there was an error + if browser: + try: + await browser.close() + except: + pass + + return False + + +def add_log_entry(role: str, content: str): + """Helper function to add log entries""" + if 'application_logs' not in st.session_state: + st.session_state.application_logs = [] + + st.session_state.application_logs.append({ + "role": role, + "content": content, + "timestamp": datetime.now().isoformat() + }) \ No newline at end of file diff --git a/src/webui/streamlit_components/profile_settings.py b/src/webui/streamlit_components/profile_settings.py new file mode 100644 index 00000000..6da6ed26 --- /dev/null +++ b/src/webui/streamlit_components/profile_settings.py @@ -0,0 +1,378 @@ +import streamlit as st +import json +import os +from typing import Dict, Any +from src.webui.streamlit_manager import StreamlitManager + +def create_profile_settings_page(manager: StreamlitManager): + """Create the profile settings page in Streamlit""" + + st.markdown("## šŸ‘¤ Profile Settings") + st.markdown("Configure your professional profile for job applications.") + + # Load existing profile data + profile_data = manager.profile_data + + # Personal Information Section + st.markdown("### šŸ“‹ Personal Information") + + col1, col2 = st.columns(2) + + with col1: + first_name = st.text_input( + "First Name", + value=profile_data.get("personal", {}).get("first_name", ""), + key="profile_first_name" + ) + + email = st.text_input( + "Email", + value=profile_data.get("personal", {}).get("email", ""), + key="profile_email" + ) + + city = st.text_input( + "City", + value=profile_data.get("personal", {}).get("city", ""), + key="profile_city" + ) + + with col2: + last_name = st.text_input( + "Last Name", + value=profile_data.get("personal", {}).get("last_name", ""), + key="profile_last_name" + ) + + phone = st.text_input( + "Phone", + value=profile_data.get("personal", {}).get("phone", ""), + key="profile_phone" + ) + + state = st.text_input( + "State", + value=profile_data.get("personal", {}).get("state", ""), + key="profile_state" + ) + + # Add address fields + col3, col4 = st.columns(2) + + with col3: + address = st.text_input( + "Street Address", + value=profile_data.get("personal", {}).get("address", ""), + key="profile_address" + ) + + zip_code = st.text_input( + "ZIP Code", + value=profile_data.get("personal", {}).get("zip_code", ""), + key="profile_zip_code" + ) + + with col4: + country = st.text_input( + "Country", + value=profile_data.get("personal", {}).get("country", ""), + key="profile_country" + ) + + linkedin_profile = st.text_input( + "LinkedIn Profile URL", + value=profile_data.get("personal", {}).get("linkedin_profile", ""), + key="profile_linkedin" + ) + + # Professional Information Section + st.markdown("### šŸ’¼ Professional Information") + + current_position = st.text_input( + "Current Position", + value=profile_data.get("professional", {}).get("current_position", ""), + key="profile_current_position" + ) + + col5, col6 = st.columns(2) + + with col5: + current_company = st.text_input( + "Current Company", + value=profile_data.get("professional", {}).get("current_company", ""), + key="profile_current_company" + ) + + experience_years = st.number_input( + "Years of Experience", + min_value=0, + max_value=50, + value=profile_data.get("professional", {}).get("experience_years", 0), + key="profile_experience_years" + ) + + with col6: + industry = st.text_input( + "Industry", + value=profile_data.get("professional", {}).get("industry", ""), + key="profile_industry" + ) + + current_salary = st.number_input( + "Current Salary ($)", + min_value=0, + value=profile_data.get("professional", {}).get("current_salary", 0), + step=1000, + key="profile_current_salary" + ) + + skills = st.text_area( + "Skills (comma-separated)", + value=", ".join(profile_data.get("professional", {}).get("skills", [])), + height=100, + key="profile_skills" + ) + + # Education Section + st.markdown("### šŸŽ“ Education") + + col7, col8 = st.columns(2) + + with col7: + education_level = st.selectbox( + "Highest Education Level", + ["High School", "Associate's Degree", "Bachelor's Degree", "Master's Degree", "PhD", "Other"], + index=0, + key="profile_education_level" + ) + + degree_field = st.text_input( + "Field of Study", + value=profile_data.get("education", {}).get("degree_field", ""), + key="profile_degree_field" + ) + + with col8: + university = st.text_input( + "University/School", + value=profile_data.get("education", {}).get("university", ""), + key="profile_university" + ) + + graduation_year = st.number_input( + "Graduation Year", + min_value=1950, + max_value=2030, + value=profile_data.get("education", {}).get("graduation_year", 2020), + key="profile_graduation_year" + ) + + # Work Experience Section + st.markdown("### šŸ’¼ Work Experience") + + work_experience = st.text_area( + "Previous Work Experience (describe your key roles and achievements)", + value=profile_data.get("professional", {}).get("work_experience", ""), + height=150, + key="profile_work_experience" + ) + + # Work Preferences Section + st.markdown("### šŸŽÆ Work Preferences") + + col3, col4 = st.columns(2) + + with col3: + work_authorization = st.selectbox( + "Work Authorization", + ["US Citizen", "Green Card", "H1B", "F1 OPT", "Other"], + index=0, + key="profile_work_auth" + ) + + salary_min = st.number_input( + "Minimum Salary ($)", + min_value=0, + value=profile_data.get("preferences", {}).get("salary_min", 0), + step=1000, + key="profile_salary_min" + ) + + with col4: + availability = st.selectbox( + "Availability", + ["Immediately", "2 weeks", "1 month", "2 months", "3+ months"], + key="profile_availability" + ) + + remote_preference = st.selectbox( + "Remote Work Preference", + ["Remote", "Hybrid", "On-site", "No preference"], + key="profile_remote" + ) + + # Additional preferences + col9, col10 = st.columns(2) + + with col9: + willing_to_relocate = st.selectbox( + "Willing to Relocate", + ["Yes", "No", "Maybe"], + key="profile_relocate" + ) + + security_clearance = st.selectbox( + "Security Clearance", + ["None", "Public Trust", "Secret", "Top Secret", "Other"], + key="profile_clearance" + ) + + with col10: + visa_sponsorship = st.selectbox( + "Need Visa Sponsorship", + ["No", "Yes", "In the future"], + key="profile_visa" + ) + + notice_period = st.selectbox( + "Notice Period", + ["Immediately", "1 week", "2 weeks", "1 month", "2 months", "3+ months"], + key="profile_notice" + ) + + # EEO Information Section (Optional) + st.markdown("### šŸ“Š EEO Information (Optional)") + st.markdown("*This information is optional and used only for compliance reporting.*") + + col11, col12 = st.columns(2) + + with col11: + gender = st.selectbox( + "Gender", + ["Prefer not to answer", "Male", "Female", "Non-binary", "Other"], + key="profile_gender" + ) + + ethnicity = st.selectbox( + "Ethnicity", + ["Prefer not to answer", "White", "Black or African American", "Hispanic or Latino", + "Asian", "American Indian or Alaska Native", "Native Hawaiian or Pacific Islander", "Two or more races"], + key="profile_ethnicity" + ) + + with col12: + veteran_status = st.selectbox( + "Veteran Status", + ["Prefer not to answer", "Not a veteran", "Veteran", "Disabled veteran"], + key="profile_veteran" + ) + + disability_status = st.selectbox( + "Disability Status", + ["Prefer not to answer", "No disability", "Yes, I have a disability"], + key="profile_disability" + ) + + # Cover Letter Section + st.markdown("### šŸ“ Cover Letter Template") + + cover_letter = st.text_area( + "Cover Letter Template (use {company} and {position} as placeholders)", + value=profile_data.get("documents", {}).get("cover_letter_template", ""), + height=200, + placeholder="Dear Hiring Manager,\n\nI am writing to express my interest in the {position} role at {company}...", + key="profile_cover_letter" + ) + + # Resume Upload Section + st.markdown("### šŸ“„ Documents") + + uploaded_resume = st.file_uploader( + "Upload Resume", + type=['pdf', 'doc', 'docx'], + help="Upload your resume file", + key="profile_resume_upload" + ) + + if uploaded_resume: + # Save uploaded file + os.makedirs("data/documents", exist_ok=True) + resume_path = f"data/documents/{uploaded_resume.name}" + with open(resume_path, "wb") as f: + f.write(uploaded_resume.getbuffer()) + st.success(f"āœ… Resume uploaded successfully: {uploaded_resume.name}") + + # Save Profile Button + if st.button("šŸ’¾ Save Profile", type="primary"): + # Compile profile data + updated_profile = { + "personal": { + "first_name": first_name, + "last_name": last_name, + "email": email, + "phone": phone, + "city": city, + "state": state, + "address": address, + "zip_code": zip_code, + "country": country, + "linkedin_profile": linkedin_profile + }, + "professional": { + "current_position": current_position, + "current_company": current_company, + "industry": industry, + "current_salary": current_salary, + "experience_years": experience_years, + "work_experience": work_experience, + "skills": [skill.strip() for skill in skills.split(",") if skill.strip()] + }, + "education": { + "education_level": education_level, + "degree_field": degree_field, + "university": university, + "graduation_year": graduation_year + }, + "preferences": { + "work_authorization": work_authorization, + "salary_min": salary_min, + "availability": availability, + "remote_preference": remote_preference, + "willing_to_relocate": willing_to_relocate, + "security_clearance": security_clearance, + "visa_sponsorship": visa_sponsorship, + "notice_period": notice_period + }, + "eeo_information": { + "gender": gender, + "ethnicity": ethnicity, + "veteran_status": veteran_status, + "disability_status": disability_status + } + } + + # Always include documents section + updated_profile["documents"] = { + "cover_letter_template": cover_letter + } + + if uploaded_resume: + updated_profile["documents"].update({ + "resume_path": resume_path, + "resume_name": uploaded_resume.name + }) + + # Save to manager + manager.profile_data = updated_profile + manager.save_settings("profile.json", updated_profile) + # Save to file + + + st.success("āœ… Profile saved successfully!") + st.rerun() + + # Display current profile summary + if profile_data: + st.markdown("### šŸ“Š Current Profile Summary") + with st.expander("View Profile Data"): + st.json(profile_data) \ No newline at end of file diff --git a/src/webui/streamlit_manager.py b/src/webui/streamlit_manager.py new file mode 100644 index 00000000..befbe0d4 --- /dev/null +++ b/src/webui/streamlit_manager.py @@ -0,0 +1,211 @@ +import streamlit as st +import json +import os +import asyncio +import time +from datetime import datetime +from typing import Optional, Dict, List +import uuid + +from browser_use.browser.browser import Browser +from browser_use.browser.context import BrowserContext +from browser_use.agent.service import Agent +from src.browser.custom_browser import CustomBrowser +from src.browser.custom_context import CustomBrowserContext +from src.controller.custom_controller import CustomController +from src.agent.deep_research.deep_research_agent import DeepResearchAgent + + +class StreamlitManager: + """ + Streamlit-specific manager that handles session state and agent management + Adapted from WebuiManager to work with Streamlit's session state system + """ + + def __init__(self, settings_save_dir: str = "./tmp/webui_settings"): + self.settings_save_dir = settings_save_dir + os.makedirs(self.settings_save_dir, exist_ok=True) + + # Initialize session state for browser use agent + self._init_browser_use_agent_state() + + # Initialize session state for deep research agent + self._init_deep_research_agent_state() + + # Initialize session state for general settings + self._init_general_state() + + def _init_browser_use_agent_state(self): + """Initialize browser use agent session state""" + if 'bu_agent' not in st.session_state: + st.session_state.bu_agent = None + if 'bu_browser' not in st.session_state: + st.session_state.bu_browser = None + if 'bu_browser_context' not in st.session_state: + st.session_state.bu_browser_context = None + if 'bu_controller' not in st.session_state: + st.session_state.bu_controller = None + if 'bu_chat_history' not in st.session_state: + st.session_state.bu_chat_history = [] + if 'bu_response_event' not in st.session_state: + st.session_state.bu_response_event = None + if 'bu_user_help_response' not in st.session_state: + st.session_state.bu_user_help_response = None + if 'bu_current_task' not in st.session_state: + st.session_state.bu_current_task = None + if 'bu_agent_task_id' not in st.session_state: + st.session_state.bu_agent_task_id = None + + def _init_deep_research_agent_state(self): + """Initialize deep research agent session state""" + if 'dr_agent' not in st.session_state: + st.session_state.dr_agent = None + if 'dr_browser' not in st.session_state: + st.session_state.dr_browser = None + if 'dr_browser_context' not in st.session_state: + st.session_state.dr_browser_context = None + if 'dr_controller' not in st.session_state: + st.session_state.dr_controller = None + if 'dr_chat_history' not in st.session_state: + st.session_state.dr_chat_history = [] + if 'dr_response_event' not in st.session_state: + st.session_state.dr_response_event = None + if 'dr_user_help_response' not in st.session_state: + st.session_state.dr_user_help_response = None + if 'dr_current_task' not in st.session_state: + st.session_state.dr_current_task = None + if 'dr_agent_task_id' not in st.session_state: + st.session_state.dr_agent_task_id = None + + def _init_general_state(self): + """Initialize general application state""" + if 'profile_data' not in st.session_state: + st.session_state.profile_data = self.load_settings("profile.json") + if 'browser_config' not in st.session_state: + st.session_state.browser_config = {} + if 'application_status' not in st.session_state: + st.session_state.application_status = "idle" + if 'job_search_url' not in st.session_state: + st.session_state.job_search_url = "" + if 'application_logs' not in st.session_state: + st.session_state.application_logs = [] + + # Browser Use Agent Properties + @property + def bu_agent(self) -> Optional[Agent]: + return st.session_state.bu_agent + + @bu_agent.setter + def bu_agent(self, value: Optional[Agent]): + st.session_state.bu_agent = value + + @property + def bu_browser(self) -> Optional[CustomBrowser]: + return st.session_state.bu_browser + + @bu_browser.setter + def bu_browser(self, value: Optional[CustomBrowser]): + st.session_state.bu_browser = value + + @property + def bu_browser_context(self) -> Optional[CustomBrowserContext]: + return st.session_state.bu_browser_context + + @bu_browser_context.setter + def bu_browser_context(self, value: Optional[CustomBrowserContext]): + st.session_state.bu_browser_context = value + + @property + def bu_controller(self) -> Optional[CustomController]: + return st.session_state.bu_controller + + @bu_controller.setter + def bu_controller(self, value: Optional[CustomController]): + st.session_state.bu_controller = value + + @property + def bu_chat_history(self) -> List[Dict[str, Optional[str]]]: + return st.session_state.bu_chat_history + + @bu_chat_history.setter + def bu_chat_history(self, value: List[Dict[str, Optional[str]]]): + st.session_state.bu_chat_history = value + + # Deep Research Agent Properties + @property + def dr_agent(self) -> Optional[DeepResearchAgent]: + return st.session_state.dr_agent + + @dr_agent.setter + def dr_agent(self, value: Optional[DeepResearchAgent]): + st.session_state.dr_agent = value + + # General Properties + @property + def profile_data(self) -> Dict: + return st.session_state.profile_data + + @profile_data.setter + def profile_data(self, value: Dict): + st.session_state.profile_data = value + + @property + def browser_config(self) -> Dict: + return st.session_state.browser_config + + @browser_config.setter + def browser_config(self, value: Dict): + st.session_state.browser_config = value + + def save_settings(self, filename: str, data: Dict): + """Save settings to file""" + filepath = os.path.join(self.settings_save_dir, filename) + with open(filepath, 'w') as f: + json.dump(data, f, indent=2) + + def load_settings(self, filename: str) -> Dict: + """Load settings from file""" + filepath = os.path.join(self.settings_save_dir, filename) + if os.path.exists(filepath): + with open(filepath, 'r') as f: + return json.load(f) + return {} + + def add_chat_message(self, role: str, content: str, agent_type: str = "browser_use"): + """Add a message to the chat history""" + message = { + "role": role, + "content": content, + "timestamp": datetime.now().isoformat() + } + + if agent_type == "browser_use": + self.bu_chat_history.append(message) + elif agent_type == "deep_research": + st.session_state.dr_chat_history.append(message) + + def clear_chat_history(self, agent_type: str = "browser_use"): + """Clear chat history for specified agent""" + if agent_type == "browser_use": + self.bu_chat_history = [] + elif agent_type == "deep_research": + st.session_state.dr_chat_history = [] + + def cleanup_agents(self): + """Cleanup all agents and browsers""" + # Cleanup browser use agent + if self.bu_browser: + try: + asyncio.create_task(self.bu_browser.close()) + except: + pass + + # Reset all agent states + self.bu_agent = None + self.bu_browser = None + self.bu_browser_context = None + self.bu_controller = None + self.dr_agent = None + + st.session_state.bu_current_task = None + st.session_state.dr_current_task = None \ No newline at end of file diff --git a/src/webui/webui_manager.py b/src/webui/webui_manager.py index 0a9d5e16..64f4a072 100644 --- a/src/webui/webui_manager.py +++ b/src/webui/webui_manager.py @@ -26,6 +26,10 @@ def __init__(self, settings_save_dir: str = "./tmp/webui_settings"): self.settings_save_dir = settings_save_dir os.makedirs(self.settings_save_dir, exist_ok=True) + + # Initialize agent-specific attributes + self.init_browser_use_agent() + self.init_deep_research_agent() def init_browser_use_agent(self) -> None: """ diff --git a/streamlit_app.py b/streamlit_app.py new file mode 100644 index 00000000..b7ae444a --- /dev/null +++ b/streamlit_app.py @@ -0,0 +1,481 @@ +import streamlit as st +from dotenv import load_dotenv +import asyncio +import os + +# Load environment variables +load_dotenv() + +# Configure Streamlit page +st.set_page_config( + page_title="šŸ¤– ApplyAgent.AI", + page_icon="šŸ¤–", + layout="wide", + initial_sidebar_state="expanded" +) + +# Import Streamlit components +from src.webui.streamlit_components.profile_settings import create_profile_settings_page +from src.webui.streamlit_components.browser_settings import create_browser_settings_page +from src.webui.streamlit_components.job_application import create_job_application_page +from src.webui.streamlit_components.application_history import create_application_history_page +from src.webui.streamlit_components.config_manager import create_config_manager_page +from src.webui.streamlit_manager import StreamlitManager + +# Fresh, Clean & Professional CSS +st.markdown(""" + +""", unsafe_allow_html=True) + +def main(): + """Main Streamlit application""" + + # Initialize session state manager + if 'streamlit_manager' not in st.session_state: + st.session_state.streamlit_manager = StreamlitManager() + + manager = st.session_state.streamlit_manager + + # Clean header layout + # Main title centered at top + st.markdown(""" +
+

+ šŸ¤– ApplyAgent.AI +

+

+ Automatically applies to jobs based on your profile +

+
+ """, unsafe_allow_html=True) + + # Subtitle on left side + st.markdown(""" +
+

+ šŸŽÆ Job Application Agent +

+

+ Intelligent Automation System +

+
+ """, unsafe_allow_html=True) + + # Initialize current page state + if 'current_page' not in st.session_state: + st.session_state.current_page = 'profile' + + # Custom Navigation Buttons + st.markdown(""" + + """, unsafe_allow_html=True) + + # Create navigation buttons + col1, col2, col3, col4, col5 = st.columns(5) + + with col1: + if st.button("šŸ‘¤ My Profile", key="nav_profile", use_container_width=True): + st.session_state.current_page = 'profile' + + with col2: + if st.button("🌐 Browser Setup", key="nav_browser", use_container_width=True): + st.session_state.current_page = 'browser' + + with col3: + if st.button("šŸš€ Start Applying", key="nav_apply", use_container_width=True): + st.session_state.current_page = 'apply' + + with col4: + if st.button("šŸ“Š Application History", key="nav_history", use_container_width=True): + st.session_state.current_page = 'history' + + with col5: + if st.button("āš™ļø Configuration", key="nav_config", use_container_width=True): + st.session_state.current_page = 'config' + + # Content area with white background + st.markdown(""" +
+ """, unsafe_allow_html=True) + + # Display current page content + if st.session_state.current_page == 'profile': + create_profile_settings_page(manager) + elif st.session_state.current_page == 'browser': + create_browser_settings_page(manager) + elif st.session_state.current_page == 'apply': + create_job_application_page(manager) + elif st.session_state.current_page == 'history': + create_application_history_page(manager) + elif st.session_state.current_page == 'config': + create_config_manager_page(manager) + + st.markdown("
", unsafe_allow_html=True) + + # Enhanced Footer + st.markdown(""" +
+
+
+ """, unsafe_allow_html=True) + + st.markdown(""" +
+
+ šŸ¤– +
+

+ Made with ā¤ļø by ApplyAgent.AI +

+

+ šŸš€ Intelligent Job Application Automation Platform +

+
+ + AI Powered + + + Browser Automation + + + Smart Application + +
+
+ """, unsafe_allow_html=True) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/supervisord.conf b/supervisord.conf index 60107669..c4347f12 100644 --- a/supervisord.conf +++ b/supervisord.conf @@ -66,7 +66,7 @@ startsecs=3 depends_on=x11vnc [program:webui] -command=python webui.py --ip 0.0.0.0 --port 7788 +command=python -m streamlit run streamlit_app.py --server.port=8501 --server.address=0.0.0.0 directory=/app autorestart=true stdout_logfile=/dev/stdout @@ -74,7 +74,4 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 priority=400 -startretries=3 -startsecs=3 -stopsignal=TERM -stopwaitsecs=10 \ No newline at end of file +startsecs=5 \ No newline at end of file