From a187ed3121de2dd905d454325206e11ec159fefb Mon Sep 17 00:00:00 2001 From: lowlight Date: Thu, 6 Aug 2026 14:11:13 +0530 Subject: [PATCH] feat: improve provider compatibility and browser automation workflow --- .env.example | 3 - .gitignore | 194 +-- .streamlit/config.toml | 41 + Dockerfile | 32 +- SECURITY_GUIDE.md | 171 +++ STREAMLIT_DEPLOYMENT.md | 271 ++++ data/applications/applications.json | 44 + .../documents/Dinesh_Satram_Resume_DS_.pdf | Bin 0 -> 108615 bytes data/profile/profile.json | 50 + docker-compose.yml | 19 +- env.example | 54 + requirements.txt | 5 +- run_streamlit.py | 55 + src/browser/custom_browser.py | 89 ++ src/controller/custom_controller.py | 382 +++++ src/utils/llm_provider.py | 1 - .../components/application_history_tab.py | 306 ++++ src/webui/components/browser_settings_tab.py | 16 +- src/webui/components/browser_use_agent_tab.py | 9 +- src/webui/components/job_application_tab.py | 575 ++++++++ src/webui/components/profile_settings_tab.py | 687 +++++++++ src/webui/interface.py | 33 +- src/webui/streamlit_components/__init__.py | 1 + .../application_history.py | 171 +++ .../streamlit_components/browser_settings.py | 115 ++ .../streamlit_components/config_manager.py | 243 ++++ .../streamlit_components/job_application.py | 1240 +++++++++++++++++ .../streamlit_components/profile_settings.py | 378 +++++ src/webui/streamlit_manager.py | 211 +++ src/webui/webui_manager.py | 4 + streamlit_app.py | 481 +++++++ supervisord.conf | 7 +- 32 files changed, 5644 insertions(+), 244 deletions(-) create mode 100644 .streamlit/config.toml create mode 100644 SECURITY_GUIDE.md create mode 100644 STREAMLIT_DEPLOYMENT.md create mode 100644 data/applications/applications.json create mode 100644 data/profile/documents/Dinesh_Satram_Resume_DS_.pdf create mode 100644 data/profile/profile.json create mode 100644 env.example create mode 100644 run_streamlit.py create mode 100644 src/webui/components/application_history_tab.py create mode 100644 src/webui/components/job_application_tab.py create mode 100644 src/webui/components/profile_settings_tab.py create mode 100644 src/webui/streamlit_components/__init__.py create mode 100644 src/webui/streamlit_components/application_history.py create mode 100644 src/webui/streamlit_components/browser_settings.py create mode 100644 src/webui/streamlit_components/config_manager.py create mode 100644 src/webui/streamlit_components/job_application.py create mode 100644 src/webui/streamlit_components/profile_settings.py create mode 100644 src/webui/streamlit_manager.py create mode 100644 streamlit_app.py diff --git a/.env.example b/.env.example index 000f11c4d..8d7ceff59 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 a7a55cd1a..398a9f7df 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 000000000..be182e1fc --- /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 d093f8294..5a1498306 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 000000000..9efb79564 --- /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 000000000..0f114e2f5 --- /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 000000000..d6db2c3e4 --- /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 0000000000000000000000000000000000000000..16e446b7fe4a1e946e49d50426a8d8eafd52912e GIT binary patch literal 108615 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

$IbXY7NH_u}BRc_|^BlL26 zWmEKsLpU~FrL!rAcl(AJ&br>24?NVj--yr8Q+k-sd+LPngSXv@Z=_HHO@_Q3Y%949 zFyns7Byd+=9E~LEOatL>yS)ha2xF&(cZxm=30#AqvsZS=%0NgGLP!!%_hybdGC+pn zTYPbu={FUSEANM#w*|ON>}BNjHw)-I3LLayvIJ9R@Y_}*x!sPmV&$FD>56JvNfhy7 z=dpsDjwl%U;a)!c=SupX!BMo~1t)xd-<#>7ID119p@*|#WbA5o&yucs3zwWZ4Wafn z50@N3Z`W&z;>yVsDZk?jD~n5B$m__%$)eZbwM|0~q+T|!Bl7(9-+6ALCtrq}87?p9 z0O=i8%~TuSQo3eiHL~WWy7?*jIF8SycF^sJCvJJo+PqyFb>h@d19lq7Cr1Gk!E#bu zTYbJ0mZ@|UwCa#6u^c~Bj+slm`^fbonc|cRb#}*2J_t{pjy5w=o;P>yD*EK7<_T0x zIm#`WOYhJV)6u&yuDj(xxnZC0!R{7x&qN<9u;xNwOY&eR0`G27 zfw{kVyAOpA2MXRtVk8l>b96)I2k%N8jpmvLh~bG(JZmCD{NF?PZ63 zVCS{UrG@mhg2)}N=WV|-I64jWU>B5;cVn2YD`-{EO(tz&k z(ayK0=8sUN`Ih=Q3iWWhZMgeXL7i^rXZ+2M{Y#vlyLB$}r@9>fJz zQyH3M;|-W9g*goz*{uf6iznmkqvJfyL+EV&H^D5S;nT2RtcXJkza^|j)J$u)WRT3C zE-d)dsma;so~8qoes74y8-6yg8nuaUEzb^OryH6589wQ+N3@b^87m1Nd_dgw^LHvd z@%gtj*5wM8Gp3w<7EgRvxPdX_bKADPd>1bCR-sS6bNXBGNbuT! zAGZ*9WODj+i4o}d+{&Fjmbj`?+(7%Ik+r^$Ym?@A^xh;<@FP8A;nGL{0wlXvAwne= zW5jl!$3c)JcvpKf1R|!Xvz|Srf<>KIH<&c*vb~8Ns*&;0o=ob3p~k@C4VavpujZdO z1%LkC3NyR^Q})^iH^*89y?1li_UtdqDC)VxH)J08@_3WIOHuyk6VsobiGed4ZiBHo zTFf((afOcgN{ryL;<^^{x~wu07{_d1?C9$e_j6B;W6ru2Y}Du-6zVR;k{LrT(_RS` z3o3@@#825l1oT=_X>_+WU%h<!tBB?!@g_i_#MhX90Ol2Vjl@z#+DUJpm_vc5 z40K;%_->5auTek2Q1jG1?l{BvXyM?d z2XLhtw>L7#nwJ&L$32N=gdr&CeKd%zEHBWi!LF0CoY{rFQbhuP8I(McIyoT!S8`*A>Ziz7Gp<@-6y zw;$F$aux0Sd~4#wnNBnN>5)+Iy*^aF0ovdGxR=YYknjJjF!Z=)=E$KYt@~>@Ecyer z88yZc|B%Frz+8qzD*&qTa<;9gv)}u2gvvkf^iezdl}|K8Z)$;>e}p(QRBt|wq#yY%Rv9nRy;NFmKHxg zKVm@5O?4pv_&>6cu+c(fKTF#@XNm~e;}#Q`PHM5e?7bo4%W%Asqp?QslxE)|yAS8P z;KZKmn}00_p5eq3Z`RD#Qa#4k=zVXZsA$=jfFBm^XLTcLcBG)) zL9BzHu2E4UM8et-!+5^)Kv7xg!_G2_O&8`tB8d`&lmJKkiXo3a=CtN`f4w>wkkrkk zfa3fWONlsQ!9sylc34lH{upmc?{HGET+xAk+Aw97nm_M~2ONZP|=!dKE##D}x3Q~0%g|sVePqp`Zr+aYJ84k3Or1983ZGPrjUz9Q){hEb{;e` zXw1=RU2TVdRm7CXaTyNfY{BKTC(RSIs}3v^k&-Ts&rZQ*PLcy?Ryt4o;zjFq?YnHVtWJ%VTIxA?~b>^q>AU|^VXErDHWbJ z4LEjzcE%=`^h962a~>dye^<6dU9GXj%|Y6Za|;3QFz`!vPIte$UdaFfcRcSLc|bUK zp4m@(aCT$T%}K>DqW5X-sr=iQO5XW8y(P>$9&YYVWom717gjF<_}cl}U}?qYK#9pB z{d#zy#1jqoRAAb-9;?J3>AI_)z#CjobN6pkx|vD>&QB&0>}aH?32o^e{-v6@`E%T~ zcConBOJA%O+cB69vj!gQOOKAn0~+1_UI7d$0N56D{nMFtf8oYqk(NDYn?`)qoJ?qq ze+zH|_@00&({^2wJX(xT zS0HAS@UjUKuRUv&rKv*Q>f6itL&Jz-{c7m;J*>`NMUPr;Hw-ra0`W(n;`c998k$G! zpln4~0iG7S9~Z149=o~}8_jNqpvCaK|X|2;EX137|$)>3%YS2+ix)PQPBU-MrX~vfat_!)-9oK^glS5FbzrTu+5t zZO%q1P@fE$btI99q*MKF`B8&&XW5j*xODB|P9kJGTRMW-qa4Df#B@>lbZbjHKR$-g z2%V;J(<+p{aXTNM`I?-*NH+x9BhV`jKPByL?!42GsaH3TmvW5v>vzxlEB!Gui^)@Z zM0a5acSUHmEj;V!rk|%lnsRekUJr@UW>A+kb?S!g1ZG(n>RC4)*?!r+18Q9QYY74) z13PO%nYVghCi+1HU=+=$>e~DOFp$(MyO0Xox_u*iSp#^EQz&e>wqj^eP@`p@)oW+z z3S8hlc1(a?=ZlFmMbiCQM_yz1%MCIo!o!@xYd3vXyt?Hq^m??+9OtgGkf+rgG)5a$ z)8eDp98sVYWPXJX&+hMpQz%cqu&gXg*l7{qY2qAm_n{)&pg2=euX=W)agWLT$$Ko$ znbWtf7p2n7n@o89^WoP1uMc6QK<*pa2|^rS;L8GxkLpOR$Dn7ON$my>!crm|HNM_*!qZcZOkPsFY_=`TUERec6S81p+F}X$8a~8ZG`9%*;RA2V? zb=OjY0X`e)?&5#r3~A$d|M)Q5uTZLUH1|c8@M@`WHEOC=GD5&CHog7%m}7S?zv-T# zyi!zBXgTeYvz=e}G(nOnJbfRL!g<1buZ@qzp;M`eGiAiy9pus~INjnfWY8)O^DE1w zwcg>CtB`sHk5PXqS1W}Zg;G1)0r&H2)6uYET0xB_1EFsF;f^P4gR#1Um%kT{Z9x~^ z-mL)fRn+&NnPrMLv3~31X=^#z=y$rZ=NrkYHVA=dA@P%VC@YAaQ#tzGCCw83AVtBf zh(x5YL?n5xDxB`LU+D7qndvIWOZ$`w4xug-yv6RK<3tp1#KcVKBVLpzQrP~3LbS3R z`X^&ihzE2sm#OlFLlb$=_C&$QMo!MNOUDyKN`izpV{Re*D!9^TgKufWO6RS#FH}hd zanKb(FGxD}&stf;4(lOq!4oI``vKn~x3L($aWt406+5^GPgB;}eKE-Y-4^JXz29h* zlC4~wDyHeTaPC-hNnCqjq+8l*&j=_5ZFEmcz zRU4GnQE!IdU?H&@(2Oq1LJ6n^nivRP+ zC~59wZ{cCjEaB>810%;x4vM*6+C9_sdHBLj`7LXX9Yu z=HzDsGYXj5`8oe-hKG!gkBjBKIZiNffSZM%o0p9f%mIF{299^P<^bovvj+Z)B*Xs> zLHM6W`X4p^YrJIt2m;1ENtk37`CRjh1WljU`ctY_hRoM{2bts$xCUu%F0VA zo4bK=%2r?v?7jT|Ij!mM36|ks@Bj^KTYFD04}UT`aVu8~YkDvw*Tc%%!~Q+2|6kR> zCpEp@+`t&&cPZcDKdid%fCWQtHlF_w4Ll$)y_AE8g^ic%oruczFCE@t$%foKY%JVd zJYXHTIrv#P_}^1t;3X#;3nwo-+xxt8@$s^NS{a0}gE^e?M++5(oU||ipc=^E{X9L4q{+$74a)8@= z_tF3HDqwNB`FX)W-FHaFyAc1?12!;GhMNUEX<$7$`N7k{!Tk@rhZC$Z4=?|_+W#sI zuKhmCT)dnt>|9*$R5oyj{zE1%Zhr7}!4n1+5j=2Cj(^}F;I{dB!ELkggJpQ15-@6( zm*pR|z+S}54W2|kes*vcxWC|LxmnoWJNb`(oM3s`-}M74$;ZO>?o{A%VDK0_xBw?R zJIi|>7Z{TP7J`?Ji-nsH>_^}d{G8x_u>bwrVL8B)X!u`kfM2juy8pQLe^M;?-=QS` z`6B(_VJpDC_TQlu#%b#=AH>i;{`QaSZo!RO-&e(y_)rCp`y%g53Sr(tx)$81)jmEvq(^05gdhS=h(qB{4nvEOd zEA#ItUi1^yH?vxBZ*$TO5yXQOuAP+Key?55BW(vvIGck%uC{+Y^m>tV!W~=Z*d6Vq zo7BF3=}f--=A_TVvVpQYyCB5bGgJ_1qmx~6LC|@xt=z_8d#!O?pi~z4U`3L1U}`jF zJ*@ensjq@+)R}HzqeQM*B^IbQQiermG3$9i(qbN0gn^~Ru4M$N385f1jkHK+Cu4}l zW$7hXlrvD2Z~sxrIE>9UY>8kUbKqCZF|(#fPHFj1Cc;m8<61?MVft-;ln)kZpPEfw z&~5${_>|@ih!sa^Cu{h1p*=gmN^>Ez*U1<|xKe_*;7ltLlJM{7s%(_yOVHsPXuJXr zP2f;wnw9bOR$h{ysZzlSCW~B!;~3eOb7TIePl&|26p_8iUtc0>fxiy2n^nah5y_Uf z4nBn!cd334Pwpfo_Xnl6D(VK`>*t|xEJ3e(=LyRtv;SV zsg-D{*b=#iI>=7Oj<1Xl0%gJgEIr{yhp%_fYxHpLXhVaJi%v^?7vg~smC1h`7g

&l!$YJMcmBsKJs$~eQ#6ef?R#Igbz2dr*{LeUO(a`A;TZ_uPyNu-2^2{MapQ)ze zgSPx(6exlu^tnxVOltY45BQ#L2nlY62TMt9dpi5{o>ccVVtEr^{D)QH-)5|%4?bm4 z^U|r%hro$*r+*0Xd!-KIOjn@ZWMvI2yjGv=O}uxL`^ZdvoYrVT`XZK9)M`g;ki=6oaXF^YfvS;Q z4aq8DNUda;Vu2Dci~xUQ@r6E9Jg6daKTI#+Bc3SYZOq>x4=~>)N&{Ei-V^*p61N@Z zKz*bsqS*2cc|A^92gVN0Ps}lO!fCQ1?#o*Ox;vT2!9IU`Ii;R#Q*=2G_IW4FHR7AwFJbBxla2lyo5x4Q4 zlYd^r4dFHyI~E@^V}Fw`8!azhN5%ezl#a5f<){;?7pi9vJ`}7Y&}pv`UHo1$u|Q&J zYMgly_#AT^e7e?P-nz}$uRCOz&<^*6x!GS4J&u0!V%wV;v)_@?-s8GzwQ6-Z^|WE# zJEp$nH~WpGhqTM5siAA6^kFUUXY6M9+fkq+D0;)l^-#KZ^*d_pCaIt3^>LYN`|s`e z&=*ChPn(x7Ox)CWtrC;=Keu(=YKrqgK_!;i_=qol9&w`E4r2cXF!TK?sa58s02=oMvEuY-@|2L+RP zA}UhaKSYJhxmGIJRn@D62w7QVB%}V%*h31hdr@d!zRoY49Z-{J+LvrxG~%I&tsQdV#&$?lQ>R#vrgB%OxHi1)h1NRN6>69GMYRCU`wgQ=rAP)G zRQC(O3$HH`5Ate}=2Q<@D%Pb(f_-%f_=^oG93n=qJu^!kgASIo3E7sMSR@B{~b zf*sn{(j_8U8i$XNlO-WE$YgDJbam+CUe(V8uASz93*?v=K@!5ZxLTU+D+`Q+dPpa% z!bdHC)9akQ(!;&p1~8Tw@L!?=6}LN~B@oFLiuh7l z^U@tYab-G3vb%8xEynG#Ab-ihJLN5AzFd!Efni=n5gD_d^^V7D23$B{_8W%#l#CTy z*Nf@K#kdnR6B@$=hoW`@jpOV8WEG#%Oq+NKcS5c_$y_@1!2cGc_2LY26>t9_vW6nO zuX5`=9p5!0d^i}Q2!_n8!-mXJ?tpjyJP1Kg>6$7fE$Kh9zeNhrN#7@v`=k&#Y8^}0 z-%1uitk%Dtrl7RnZY^OTSbS0D5?G$=^6`<8R`{!aDy954LXBA?JHccl7C}`EUP)p) z5kpFXPRY8r<)u-LC*7|~Bo9id( zlcM!+if>AzN*W3=Pz>3RQ-+aBCy8%{KoN(zpO4D>ag_YEzfE8AkhH@K+Ko6uYuRf) zf+N7*eOu^`A0$oRk)^RnEIFKJpHBW=ylZCPgKCO%12xc1H&}qSYxnr)sFzGhV`dt4 zX3VTOBq8KONSukJXxMb^&Ydh;0C9JCNaDti05MU?&=0400miQ)enXl+I7DDQdSEAH zZ!qU@n6o!|T`%%C3NCVtKNEAVRJOOaf#1WbI94^(e#yzLs?XKXS2xiy&@f6*`<#(O z0$=%2<3*)bp6#TSpjDwdxgQCxbo^OOGSrN2R+K6}l7O&Tj*klh9gs%x91VrG;O3HB zEY_RNWpWWC(MCHv8Clif&$M`F*t=?9vKNy}eB|g4u=P|x3H7K*wj`T@ z{Z(N&_ru=t67j%;JESYX<+Mpp$nOouD5a}ny($;Fw)0?GE&nSuo2vQ{I+73L1_HDp z5U8X55_F9Zg>VR@0xwyfmcTMRX}w2UiexQ!2_?5eb7<=b42hQy;u@K!vY2+{&{00# zx6^JA%(n?eQYznlV@_#(6PpFwTs&-XyEw1>Lh0mkYkEwd+x>Gq?G}HE>CniT{>6<7 zbrWS9kbTBQVWA8%yV_7QCeBzLFDRBG4jTy5T72GKBa#!HM$OPu8JM2oOvf}3v_f8( zhDJjtb8h@zD;p#g;51?&82JoaUK!MrolsU4VgQk+;&T4nZk-p#zvvk?Nj z#6%;JJdl}7p)AC8s)SndHDrondWt%nMAeOM265*U`Hty=`U@6Dltty%;fEd8I92iW zNyQ0_v|Psi8Zp$v?Lc`)aj7Cmmf~E64stQiSn=5s?u>AfGjS&Tzj5gwiaf8wAxP_; zsoMRn?VL+#mk7%WL)EF0v}pYO^3F+MCl=YqdX;1RR2&VzM*|^C*v|RKfcxIHlD6wb^&H%afn7YAx=VOFc zukmAaL&MqBoWsbY7Yzae3Gt2zC?n=0Gs(67Tx}#(%zn%tLpLtGUsuDsYt$4MO;Y{k zKxi=UtP|{%ittm#iI3CVgngYK34juiv&UBSMzi-iJ;+UU-46DP(W(qF)+sbS1#x_^S zq9<3Fnnc3B98)Y+I0Srhz5sa<##~?`4cZFzMn){Ug`XQ1CA-St4jTc{zFmIb4qr^# zOh+W1tjM(7q&1lZTl-zObeX@;Ez9carlJ6DlceTPQK3jlZMzsmZDdQ8V#3)-oJAyi zNp7m1+yUJ=KbTnE4;E&Ez-_d=mUdBS9|Wu#SbsKc=>jW!JYv$qWlW)C{&hlV?L1hn zN?SQ%*i}|kUdc}T$Y=6^mFfB)kQylS>yic9QF*{XacBLgz2uIp zE_SM_jUMtNgA@Iw__1tWd=ysF&9PAwVm7n*2aB4wgCaKxK{K9G2~(3E)hF4`Sh?26 zMx6$H4O_{m%Oo>rp5xT(#-{cx91|pCO6sspoTwJHk`XF(1=KLFEgejH#J^p@IEB<6 zsitE*>CeSq%>IF$q`L*mk-88-?5Y+nN+?yO3)fNbNHFfZe;8trpYSI06%q3E`a3gS z{zJa7ZX6AtD8_V z6R!RrJWu$S7qP*}NBro>qQ88EmC!U?HnE=2UsfA^vX8=~Psf~)k0QQm6+0{T|@PI)iEOxaKF-X7@(jI&Am>3FR8Oe%0 zc~mSv90_QFRK|&BP4-hPJDdwBfmFtUW<}N)RtQN7Fac`dU9xT2^bLlkLBaxjfKEty z_>F94HhlwOePJn(06+lH3&{?Dnytu2Y%nYp5(eM_v_iT>azio$CPQEH0px*0czpeR zi%3C(eOX9BKl%=lf`6Md$5kDLHf_Dfozh}9MV z_&U-@isx4tzR7vHiS&KAj~*B@+}DNlH#2;b?a~n#GT6t8{|E#4&34HHaAm)w0f^de zAp=8(`c9F)5B1d|xmK`pML}X+8cr5}NF@ zL=pn^Q6dSA_xa*Kq5x3YFUbI?Y;K}}2X;4Bzyq5bFTj_*u`t|~^O6~OVYP(1?GZWu3v)!pfH1j&Ppho2N;Sk zdw??4+tQn;De1lYaOS2dKtOs!xn&6JmvrVLCz0U#q^JXRo~$PR^gE4TlFLpY8}(nx z%Sxb5y3?|{h$j5>R!yGIO*|k2ddK2A|A9H^y4a>6bXCqq`*iLEKZCG^BtL`j1$7-o zedKQ543Vs&x?B-m>18YsgFOfV1UlZePS4QLkMw&Zj?%yr1Tjk1myI?L=G#*WplEcus}lT-(=V3!oSgZ zCU&fb^V2oY$q`!X5ZD)wE9Jy)VA65U$^oncKwh^6nHCq}n~VwdqWt>FhLada%l}ZTgGn06*rEs7$_b->Zsb zXz7_INTm4TRY8(e(g$6vwX@m>X_cG zK&h&6uL%l~SWdD})usD`{wXM@iB#hr08&xAH|5%Yus@;(C2HPNbUz4#Oo~#bG<_19 z4(S9Hmg$uJBclq+&)BS^=>+*K8h|_;7KMmT*-hl2g5ooKP(jI=JP1YYejtYUj5{R% z;2O_5X*MuP?Vingv;0gjU07;4B3)Q)`Gu?bj~&Z{h7W4DFR!()dRt+W>l|#4w4|?w z53F_jTwwc*kxTg*PQY&)kW0y#4Cr3n2cYaPjC@{xCIG50KO+F$CoBQOjD;->;m#O9 zK1oZN5M8NFj51E^eLkO-`tWwgWSs{4d^CLUgM(m$T?DOHWt`%}j1@>X6_#;8JeqB> zO`p=8i}V{RlNw5$&SRX>mI!i}9dbRup%?UBkxkg?%#~BjrB3wB%NWm(79gCN^oVrk zGN-Vn5D<bjC-Rj@T#y`aBs*LYX?j@P#D321p%Vi*6#l^B8a3 z#$0qyKx1_}HC=5*ox=dqu3&+LWCmr0i5Yf!MpAZ+L%O<-fJH8_&T3#DQ0F(m1*?bN ztt+ShBZeMRPeeI~*Mb6jjF>&8Cr47BCs-){RRZkzx%xnB_FR1dYjqGs`bSwMDFyrV zk9GysTvKEdOk$o_ng^v*{`88)3hSX^N0Fk##mlIvy{}Ev5ni>~r}m z=H*$ac*rJf06ZOg2vI#NR50oh%(}qaP3PUKWhe6Mp%>wcY`9E#qS)VNvFPm$@(y3L z9htDG@TjnY8IzMxgRY6Ubf4%q`E%=6z{=-W+AtE>0Hh=JBH1vKUp7y@h<^peP=9cu zLWPP2#=eF0JwW#09+C9+(zKVYAHs zVY5pAWV6zLDKz3h{cRy=DHPlEWb>2ai>}Y_lb)Tv`irqH*3%9DttOXqYrVjn!=}u! zHdCLiCR5W)HbySN&USBC(Z(--W-pl64N;wrLlRdbGTn4(8M=5$m*$yQt=3!IG2fDu zANy#G+TjmfeiE%vIUzXhcoTLl`nvKp_7Hw4{2H?OTzuCQSA19b%hOKyS3)AGXvfB` zBSgW{R})sQx>J+6?)$#niv>j;?}jia7FOex?z6tx3+nRtPFzyGd11ZI#=xSM-av7C z(}%x+;Po4YL$4`ew#u&dmdZe$vO;Zv=B__|;}`X_i5HUq;`Q_LxvomdAVYga-_!8S zi-XyriweM}^*IH?&eP&+I%zrhvP&rXzFRCh`0K4)Bh{atpqp6PC?Uy$U6KtauRrTX z<3~SR&sn|?d4D^l!F2_L68A7B^=u+$u5x#M6MF@$G9xK7*Tc>}2%8-`m$}@xYC(han^9!u5zu^eut# zF_OHY>_^qZDZdB);>~>|iG^IE`g%eRvdkCLF>hVR)m9@3L*v!tX`6i>G7?xe89j)h zS~>d0iA{)?=OW@U_JqTJ&aR!SOm*xNCwz^GY{AF|Vg%n5#}doQZ-X>OXu-BaoVF=a79$Mjf%pP2Kx_fp zaZfW8Wr$@#e1R}PX+g51pT;N}6pIf7hT}jXL)-zFA#zdYVYyK4Sf+`JOo~W~mc^RG zI>T+Cydk}zydfqa9AI-%=7IAF^HB3h^U(8fT*y_BRU}m?RX9~ZI|enVDuOBmI|Maq zH5fI1HGDOCHF!04HFPy{HApoPH4-%zH54@-H5@e>H6l6y9WWgQ3nmWYWB6>Cgjlth zco9T0=?Lr|i~{5}#J6xGF%>e{Wa1H^6-*OEPPmd7R5JPqL>cTXqO?9aWo_90HirtB#wDZzkCsU5vQ0bAQd35W;@eq7AjemnO`Sx_8w|%En>+@zcaq+CbH2dlsmOE9#6Yu2s_UbU!+N9cD9n`le=av&}50*ju$r>a? zW6%DKWfSTKtxJy#j_xv=R%`3vlZJGfhKURAS}mJ~^#gAD;SgrtpgZ1g+ouN0pa$1N zZ0wtwxkpz=t$d|oygPnleC!_;`ZDoka|Y09#^qdjUdP@yL-$J>awPOTgcFW+$O9Mz zZwhUiF4EO>A1;LBW`{(D$}rs=?X;j$X0B9G>>=&&!}u3y;d#Z)!;S8Cv}sK+kNK2b zY#rPU4d^glqg84>D*_}5gxkivs6hdD@Hn*X>*IV z9_cv{xd@SSPZdzfe&3ULc>PYzyNt=>gg~(V08)idQgMoG8O@UTI}uIF3`~(PuZ_~o zhMf1q*x!dR6t1E^>3x*73ecU$9dRSHTD|&x`B1qe48(7kuXdNd#%?uXP9qfZS&sQt zwBw(l-sS2f=Jc7U6(R}vks37Z8mpK%sJHM`yhz1B{> z)iY5sK^4m>zoV4Omqs-SP8I@sVLy?IBH>9Rh|R*1Q(vm05K!0SKH0*0Q@mXkWxA=z z9g$9xEP5FD-je$JgSg!H%S+v`9HTQh)$fiS)LqBiR5k-krmr^>G$CfL{O||Kv}(n1 z5GrKils*o^h$k0(I%-ZD{$M_tBosIf!kmMKbE8&H9?wTjDKf4x%Mkl*wdg|UvvIj zZa@DQY5LI~HR(S~u491=FQc>RsVb2(eUG#ppVwf!{kDH#X`yG8VPus-{biM6=o6*U zh*i{x4MqJc2k_V-!jeMI=eIacCGN+sg4^FYGrwaxnsqIpiS)F)``E6!w;%NdI9TRV z-6+4F?q!y@Fq{&A*_wLH_YIV**Vt`;sOsWy+IeG}l~tcTx1~PnpQBLAvDe<wI<$Vbw210E8&&Y~8TElxG)VgfUvr^8YozN|jWZ-V|NEbA z|0UuK11^RPfq$C-CaAZcBmLAvj zfGV9tI~i#CKZ8C8EYr7Y(_SQdz9U=#3ro&rQEM0(Ce&H|)YW~I3~b08+*`*)I>+2i z_BKiS(vGzpfps=X@)<_OI2ueg?L|rl)k-8^H+D_0IEy#bxm@+^Q%;=!yM+gk{t8T) zs*S86ZqYM}Q_l}!YwedtD&wNq&u7a}o<}iN!{=FPJh|p9wg6GdsltE6b(gS4?)Y4- zgRRvno*-;49}XTROwz;-d-d>Z(LCe6N5@D3P3vtc!36P}UYj6xy09wr*mI1|v&pP|Y0cw4xb{cH-Cuvo`aOOU z`1f{VLYJl9*U{>R*(9DJl{ly8A<)r>sJCYFbTF%;A{Bp2FWrGk`7?PQq@@cAfw0!+Xh{Kiseeq`D>d$j3jH|7O_RvpfFhVVr6*I)RI#jq z`d(ad2o6?Z$;?b%Z$z3cT(mIk&xknA!4`0P>Zh=*3@s8_4*ae=aforW_hI7Dbg_!A zB_8(%l|_cuh12i}qH5KO)t6G8IiNcd$fl}Jyi(9i`x7zr9z||tQ-y%Bcx{{`rZ;*r zVHqkzrKY>K<)*FPJ!8~R&kh!n;tS$K*k32Rp%e$%u3dcCBr0?Y`A9ho@>Ho9D)f@W zJ{pIiBvMFuyGWI-KWa5z^PX5y523w$ zCUlAL?f%HcDtGMpNla!<&C&a_OE8~BMC|uy$~D=1HEso`GH+kfJGnp8Q!iH=l;`i7 zM{0bNSbm*f3lpm~Q{RnhGxofmmq}Bx^~gEjQY4s=(exSCdKJ+iokgVja|bhD2EnY9 zeJ$5MNxhOGsisCLZ$PQ7{BM&?*S3xK0djw+)Z{xo)NbB}y@jjS+DVbaGL=f9dYH7) zrekkgePT`jrKL(YSFcp@xk34H7~u!HR~G=Md?WwtXG3l}{XjY+G_vo+QaPr~d7V!8 zfOf67LJkqdt;_Jz*tjhs7L|-oK^Gcli&Zqy^TWUl#23!jN@V+8Z1wL>^Ot5E4eUbA zZj=OJxo0f=_JC$)VA1b$bT+A-uwC#Nwa0Uc8iW6of2@x)qU>37g~Pv5>ivhZURx$f z+PBS<<>lF@TxN8vk3PbF_BHlVmSfiI!fPsL`7dP;QkIVM)e2SKcTE=w&FKZ$Qkhb1 zjEa#@7>n_LF)5U4sg!EzlnQAC1q%jh2>Y!jQx&6Lbyllz595(4tB)3b(HAK{5ZNOfV0MO(Qb1kkT@P6GEwIkdwu^r~ zrE)d7H@|WIy%BP1xVXr@`Fe5A+x@NK)y?aoeX;)K{)3*?Wc#m%F2@i7-@|U(DBSUo zy=K_^(7W*u`lxR&R|~90qQM8q!A;GKTc?GmR0(wJP#c$=~fWm+!P*rhX^hJ_)^tcp(BLDQ^ z;6DyW!pwY8?^C2jaA22=2GXUnqN!Xam&Fu51Ycl_L2mlt9`|T;71LJ@!+fOX5b;`0aTZhY#%`<|Rrs^#XZKv@?7U$>t(YLqjx6sdX}DS~}U+ zYZOHMdo=2NAC=?G?(!8(59(w^LbvK!V{QJPZzQfZ802Z|=Q*@Qc4*cX_J#y3_8ew@ z3B3ZUrQxD2VCLZ5mfhgIW7!yP4O>3a}PXWzZfjz80!2V%-v;B9L=LJ`Uny<5Zo>J z;_mM5?(VLEkl?VmySuwfkj35I-Q6MY=K0%m&Z+y>xmEYWt(p&0)4e@yGd(-o`|H_} zPy}g^zNPtK>XGdtJr8zCvKl(Yh-9NJb5V?u3zPONWFxmKoBB5NG(2q}HoTGU=E>2- z0v);XXEJO@T|G`KnbVp4(_K}eHb+n0t9uLGx1+gT#C+mK?6Rcyte#5`7T=Mat6{wD zQDyLcUqmVVDpw*!mWdZ;Q>mFyB2|#V+hs(#eDMc`YF(pjnC0&l?jCc?UX=yGf5R6b z#BlEwO6j@AO%&_v@WY>!;rc#7>Oj3ajvXRZKFk}KTLeKy9_>GeO3_6MQ;yh}G9cR= znDs>|WKb7|skujq=Sn>h&snQRcLJhN#yA5z@*buTa;j_mX~EQUo4b~btACR^&HmlE zUX%QVU%eLoNotF!Yk&~kSlW<0y^)TSg`SP(_x-EcVNN)zgmp$H-q!M|oG6Dj!P1$% zncH}baSG>sw{rFNoYHrCjQ4$AUf&33vF?`JO~Ehc2hA_F`(#(1WWYhYU#>m%Q8o!lM$3Aa$hdlIteK>LU*Z45m(J7g> z1aw-jJWrMH4WLpA1qmjKg>>FRgxMwf(qW1{qpZCFa0%O4`8^$e6v_T@mwunavSU-{ zZ*esO$BeL;54-&LEpr=!k(%h5$VYdjNs zfVO;{rR6XtJKe?k$xgw<)yC3taRwPf1jRqOt2H(9tfaW*W|77#IAnWdeedLObm*kn zk@Y-@ds+hWxB)&dCQy~U&7!v|ztDhf6PZocZ6g@pti`?nrByUvmg>Kt#OQQ8j$xwS7V5EcU)GLh%4~FZaYTrA?Fq!?D z0f30*lzlJvj5<^Og@y;SxR+ktJGzJzj|-lvh?tNEItY@1EKDgKqF1q+iJxB;e{2Y( zVZl!AFU85kDb%d_{ptq$rNh#k;~@&t_7ErIBFrOjta7gzE#W)?hLRzZFT*g3lyA3V z3*k(iFe%w^CsvN4it@`vp++1oqJj19>|7NcskzEE_CUwCoI@JG0lgf#^8vurj8h4vK@>f zhRbf*fB31NQ%Ezmahlm;3%b2t?j=oUsNda?)JM^Vyd?PX{?^e6z!GITJrq+I(;0tk zZPB6MOEJIwbBBpD{~Z*S;XFkZp4Kk5RLML6%b@dMHdX5~MG$3G%65O>FOA(@(Z=-S zt&&sx+%Pp?1@W%)(i_R@_WtkueJ39DEy~w?ff{xn5J*4tVf)C)B2=ny!MaSSz)ASf zebs4~Cw$Uk59O1?lxMaSRu2jm;oqGaP@)}08_HkP8FTLNic=yjBkTVxpDjP2f=kz1 zgoJ@^DmRJ=%J9FuRZdHxy8a%w%Dv0h!iqIyV3oS%sJ7nn=sj}DgPggpMpo{$*Yksy zz*Jp0iyng_Wd|M&Rf0DPl?27+XAB~`YYm`J4UQ7Oab4a)s=p2aPXlbmXm#{tu(ewc z@hfG>LPm5in#5e$dOpnPVQ>9+x3yM(zBUOk=p7f(~4EEW6p13W)%A=f6x|V^{5dR%I*xv#=vIVbRg@=^(rK`Y5jU|bAw|Z7z$b_*f`$`c_$66u$ zl}Hu)OTjt*lCsZk4&`20%FE7M#bUhQa<fhD1iyo6Yn_(-dHa7LRD6Dc3GbN9WDRW=N-%vJ_b~QSJ;u3a6stB=UlpB8 z-zo$R_kKAJ34y%5W|c*qd6k!5W8M6irhQmqeK?5K3q9>8mXXokN}hrK zxfq~3CHE{qdq1F-{fSCwp8RDNP5qdXq`fvQtk0x3m6~KpK{PJ^1oQ0+QJheI0tpPY z*ca7^86&5!2D?ZBQ|i75OEpDerUQiiiRq&gvnJ38Hdm@!KZ1{xz1I*!i{wy%xUsE- zP17ivATi=t@~@;XJd#SG@zz7K%Y1GZfs2QaaCRCK9bDOUPa(3!1jX%T71+$5m@O^s zC3A?53}Nf?ZB&=fHUn@Xby&(f5~gbwpVWB{#=7xBTBtGnXlM73EpC6--TA2ia|Lgl ziAf5ZTHIwA?1J zhPM)tMMw)9pu-&T@<==}#d6aQ)Jrr-29WNvMVs!6y$04pjb*)2h$vTe3_i2gDZR1E z#>=dfqjD7yqo(DlHaNvc)`|c{eo{TYD$IkeaRVDMLj|P>*w~AO zA(zka6-~K($H;F}G)j9a`Hr|42o2Wh@itD&aZrgGb__>#w1xSW%LfhNVwFXe%l2-O zyF<&hS{R8@4z%2FXpLeWXxu+VNL+t9^H*VKVW{S*{#=pu@wG{=g6?p5P zT-FoBQV1+;CMk5n`B*NP%PuucZiRJ;0+~6*t7^a1whEV8_r&3mfF9$@2)l3w*$C~0 z6)?32^ym(F-$PA*G&tI)rmUZm_8xfTC_q@ScO}1FvL`Q-g6M^t3o?i*t+zB25PKBN zAGDExOM7H;2pVW0vxCdiSRpf6f3Y$J-H4`xgzmb5F^!k=mwbXM=A4>8bmbOCG^WMr zQVV>kfpzvnNj=D#+mWt0d4R4BXa`E9ZR!f&j|Fna^b-eD?lzVwF;%c zFr`!f+^nxsZ=1?Mh&9p1OvaQQZ1DJs&2pg_+&O00MjRKj-e~1HnALxhr1`SHRG+>W zkjPzQJNp)pEJ#lml^9EKSBN&&n#0068VMOonXJ8%$VJm%?QW}U6p?Es(4YB%q423> zlm=MJ{n8i3XX83{Q?8S&+sd(wF|^05Z7*pjGP`uvW}nkYudbrG;aG1fdl>2eY`N4h zI-*$WJ1DnLgAm7>e@}av$Q&_BSGlKLQKsHvV_DAD7Ge4j%QH|l3Chn zR2=HJDcSrwCzzg^9+S&7JMIS4;)UCutSXrfaF%?nc51qHqz_Qt1{ z1a)@z0qzUq>7WdyQ$#(($vXZ7lmfRl#d2kT+%DYApIAJ^eqrt+B7qK*cgYU%qIQhO zgZS+$DYFZ`1Wod|bp$6rzRL%ni64{z9RQ#ANk^IxR&wV-L%R|^dicfdvO8NBiEd%Q z9Ob-N@AJ9hLoK=!{~kxRBTxsCUgPp`{!0OHjj(e?W<(`4O z%5j?DRkQJeajZ3T#fRiYdBK$s9g?$NU9ZyXvWN{jL<@hY>b{r{+Ftv&A4Xku)R~J> zujae@lYjC`x{nwqDst1HAe-$gI9PTxps;DXZ~0bW%5o-OPWpRaKb{zibtlTXfqGvV z9IT|7ov9({d>sxdn`h|1d6qU#!jV@lBI$M({ar(>rCw0_9xA_yOE`n znsAf^6$4p^g?8VX+k%3rwH9rn(p(eGrIS&aw^~+KW!H@GcKX0hSX`70H7gaZmgQ9r zrRnPbZO;V(-9Hs?a43HT&eO+?h{(GP_y0@l)er}9Azv0y4rxy^ig+6hQ zqG0T7+NPwHVGg-&&tkjlCa%SAoSxWIA8hc;_J{I@-v;iSV)BGp-x)ch7dKi7iE5_r z)QR&s1E)vXO^K~+kntjl?1e*^dS}ytCl%=}2WQw0bKV@-Wyy6m80>D#XB@IZbKQ}c z3d@@2WEdW1(+jx1$+^t!WK6;m#vCZA4&Eo%e5XT~=LvT1mai#6Y8Z(&?v5WXVhX{X zWGOXa9+GF4dI?8u2J(VHm0)#RvG#hxb7S7_* z)bRZryD*+To(d~TD!(l;W1ZEjE}HsMIx4j`JFC=e9>negWwA;kVI|l7aqG%zJFlmg zg|nfT7`vH0naeEeLkVl_SmF1t>2fCfs*8`xNnFd_GE0;WozeqjPG)XSl*wFhHpU~J z4=azj$!N(3L*&yKVv}s+>9cvMkL5$^PDV;`!4y6h36O{2rAZkFkj<<@B|N%bI6Q(S zU^Bzw1H!BkZD7@URT(Bpe@l>0ItxINP3E|4;iIWRL4Pi%=|;}YQ#OmNkQg$Fs~Z!K z8|Jnk`C*Rb=r6ntU;H#aPLkv^P8GK0;UbENxk@X)yj$0mSx*7tLi2CdcIJlXI#R z3{RaXh0&3fa*_Hryh}4)H$ZOF;7w*z6C%kF50KLsTlPm`TfeaT^1Wr7?5rLcEtYj< z05y35ciicL>9Q&$ut}uF|A0z>{==inamJ)W%gK6z`I~i$!4X?I%cZl?>AbO8`QjVV zX&8!@TUAF67;+q`XOp_Yio*oB}o2z$)4had4>E)3|K91CGpviHgOaQ$v8j}tv#T3v&VqdBm!;*+nu14~4Ij@B_)bOaxRlcO zg?*)_D(DEzCPyQz0%mn^WQkdIttSccwvu%eJW(kM9I*m8LVW#xWb1nrlVlI}0^Xt}{@O+-INukWv zT%;aFL8YveW#?6>Z}P z_A`!_vZ-N#`Z!_bXKW{jP=fiDI_#^kCb9U{d^Cywp^QLztArFnR#Q0^xt;q>NQO|_ z8;VGgcUm zVSKZaHG=WP<&BN$)GL@fh%>_{PyNmqFVHhSmpsco0>jZ%)W5=scP~~i3v!~OiFQRE zVW{|EF`w%enPZ`!QNTYdIk=@(hMI_kKh_sb7y)758%e^8d%0x}E1S59897XCFTznV z&>g?yBVM)1Dxk?r$esP(1?@wWpMG2pjNHKhzhF+dQfjZWaMG+`Yn5n|b`%UuZMy=6 zIV@ZJdRhq6V7s&-3nZ}J!D25#x&q%2uiTP&r!n~Jv#hdOCWM5dN9L+#eroBB5M+5$ z6cd4p>=Ljocz|5sQH9at%1QTJ{Qi+ioAV7a;#=T+s}Zjcs^B2qE@P4oyQ1PK)mtL& zJ!2uO-_r9a)fpaQ=V&pFK;yJjiiibEh7GE1rY29rrZoyyxnw=M<|oq1Ak8v_s6$8#v~pHDwmIQD_Ec#UrB5wR=}gsDGI5DVcHGvDHS>uN zz!MKmfZ08_l}bXy=A#-_E^zkEv|> z3*)r@qUM!woqS z&0(8BhH8DU;MlDwEV*kn%~naoYRL|41J%{l>U)&Jg>3YP9Uv9a=)P2a$u8!#9))!A z&p75-Vh?rtK^N7Jw6V!7FWjlqg!?L@uj;J#4I9ASk>iU-A%m1;l#q;9m72>p0|NWL~%YaL0v3 z8EDUy$5J-3Tjmxfxa`>3^$ked+%?vWm6h0vNxdMUYS*09zf6jw>{9kyJ^Rvx)>Vbo z4ePgxO3#jGYj7f)Pw1(MHRyH;?5@ z+Ez^xE;gW5^H|!cbqaSW^(A0Yqe3knJAMJH$|RATkOEck$HQ~@;QFTb3m(1Gh(OmS z^D#pOp2$YuT@SVox0wNamYS6sbsLj*+$!$QYPWV z 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 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 00e050c52..7fd5d4db5 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 2ef3d6382..36da55367 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 000000000..bfae90561 --- /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 77fbfb522..922f01f7a 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 b51a16632..a488e70dc 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 000000000..37b891073 --- /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 000000000..6f19334bf --- /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 083649e69..20c8adc05 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 000000000..8e4247967 --- /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 000000000..5270eab0e --- /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 000000000..63d3d536b --- /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 000000000..290e5f619 --- /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 000000000..d7a992afd --- /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 000000000..6da6ed268 --- /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 000000000..befbe0d40 --- /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 0a9d5e163..64f4a0721 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 000000000..b7ae444a4 --- /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 601076692..c4347f128 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