From ac336e7a31b68dfad6eccb27530d0d22dd8661a0 Mon Sep 17 00:00:00 2001 From: Victor Jonah Date: Sun, 25 May 2025 21:11:34 +0100 Subject: [PATCH] Enhance Chrome extension with AI integration for job application assistance. Implement features for selecting AI services (OpenAI and DeepSeek), managing API keys, and generating responses for essay questions. Update UI to support new functionalities, including improved profile management and autofill capabilities. Refactor storage service to handle AI responses and streamline data management. --- .github/workflows/security.yml | 26 ++ README.md | 56 +++- env.example | 13 + package-lock.json | 162 +++++++---- package.json | 3 + popup.html | 324 +++++++++++++++++++++- src/content.ts | 128 ++++++++- src/popup.ts | 375 +++++++++++++++++++++++++- src/services/aiService.ts | 291 ++++++++++++++++++++ src/services/autofillService.ts | 461 ++++++++++++++++++++++++++------ src/services/storageService.ts | 69 ++++- src/types/index.ts | 28 ++ webpack.config.js | 15 ++ 13 files changed, 1782 insertions(+), 169 deletions(-) create mode 100644 .github/workflows/security.yml create mode 100644 env.example create mode 100644 src/services/aiService.ts diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..1dd2492 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,26 @@ +name: Security Scan + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + security-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Check for hardcoded secrets + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + + - name: Install dependencies + run: npm ci + + - name: Run security audit + run: npm audit --production \ No newline at end of file diff --git a/README.md b/README.md index 49b5ad2..f916437 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,35 @@ A Chrome extension built with TypeScript that demonstrates basic extension funct - Content script for interacting with web pages - Popup UI with options and theme switching - TypeScript for type safety and modern JavaScript features +- AI-powered job application form filling with ChatGPT or DeepSeek integration + +## AI Integration + +This extension uses AI to help with job applications in two ways: + +1. **ChatGPT (OpenAI)**: The primary AI service used for generating responses to job application questions. +2. **DeepSeek**: An alternative AI service that can be used instead of ChatGPT. + +### Setting up OpenAI (ChatGPT) + +To use ChatGPT with this extension: + +1. Create an OpenAI account at [platform.openai.com](https://platform.openai.com) +2. Generate an API key from your account dashboard +3. Configure the extension: + - Open the extension popup + - Select "OpenAI (ChatGPT)" as the AI service + - Enter your API key in the field provided + - Click "Save API Key" + +If you don't provide an API key, the extension will use mock responses for testing purposes. ## Development ### Prerequisites - Node.js and npm +- AI API key (from OpenAI or DeepSeek) ### Setup @@ -22,6 +45,30 @@ A Chrome extension built with TypeScript that demonstrates basic extension funct ``` npm install ``` +3. Set up environment variables: + - Copy `env.example` to `.env.local` + - Add your API key to `.env.local`: + ``` + DEEPSEEK_API_KEY=your_api_key_here + ``` + or for OpenAI: + ``` + OPENAI_API_KEY=your_api_key_here + ``` + - This file is gitignored and will not be committed + +### Setting up the AI API Key + +For development: +1. Create an account at [DeepSeek](https://deepseek.com/) or [OpenAI Platform](https://platform.openai.com/) +2. Generate an API key from their developer dashboard +3. Add the key to your `.env.local` file as described above + +For users of the extension: +1. Click on the extension icon to open the popup +2. Enter your AI API key in the designated field +3. Click "Save API Key" +4. The key will be securely stored in Chrome's extension storage ### Building @@ -56,10 +103,17 @@ npm run dev - `background.ts` - Background script - `content.ts` - Content script that runs on web pages - `popup.ts` - Script for the extension popup + - `services/` - Service modules + - `aiService.ts` - ChatGPT integration for AI-powered responses + - `autofillService.ts` - Form detection and filling functionality + - `jobFormService.ts` - Job board detection and form handling + - `storageService.ts` - Chrome storage management + - `types/` - TypeScript type definitions - `popup.html` - HTML for the extension popup - `manifest.json` - Chrome extension configuration - `icons/` - Extension icons +- `env.example` - Example environment variables template ## License -ISC \ No newline at end of file +ISC \ No newline at end of file diff --git a/env.example b/env.example new file mode 100644 index 0000000..a4bd515 --- /dev/null +++ b/env.example @@ -0,0 +1,13 @@ +# AI API Keys for development +OPENAI_API_KEY= +DEEPSEEK_API_KEY= + +# Instructions: +# 1. Copy this file to .env.local +# 2. Add your API key to the .env.local file +# 3. Do not commit .env.local to version control + +# If you don't provide an API key, the extension will use mock responses. + +# This example file should be committed to version control +# to serve as a template for other developers \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index de945c4..8703455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,21 +9,23 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@types/chrome": "^0.0.323", - "ts-loader": "^9.5.2", - "typescript": "^5.8.3", - "webpack": "^5.99.9", - "webpack-cli": "^6.0.1" + "@types/chrome": "^0.0.254", + "cross-env": "^7.0.3", + "dotenv-webpack": "^8.0.1", + "ts-loader": "^9.5.1", + "typescript": "^5.3.3", + "webpack": "^5.89.0", + "webpack-cli": "^5.1.4" } }, "node_modules/@discoveryjs/json-ext": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", - "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.17.0" + "node": ">=10.0.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -91,9 +93,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.0.323", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.323.tgz", - "integrity": "sha512-ipiDwx41lmGeLnbiT6ENOayvWXdkqKqNwqDQWEuz6dujaX7slSkk1nbSt5Q5c6xnQ708+kuCFrC00VLltSbWVA==", + "version": "0.0.254", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.254.tgz", + "integrity": "sha512-svkOGKwA+6ZZuk9xtrYun8MYpNY/9hD17rgZ19v3KunhsK1ZOKaMESw12/1AXLh1u3UPA8jQIRi2370DXv9wgw==", "dev": true, "license": "MIT", "dependencies": { @@ -333,45 +335,45 @@ } }, "node_modules/@webpack-cli/configtest": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", - "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.12.0" + "node": ">=14.15.0" }, "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" + "webpack": "5.x.x", + "webpack-cli": "5.x.x" } }, "node_modules/@webpack-cli/info": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz", - "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.12.0" + "node": ">=14.15.0" }, "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" + "webpack": "5.x.x", + "webpack-cli": "5.x.x" } }, "node_modules/@webpack-cli/serve": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz", - "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.12.0" + "node": ">=14.15.0" }, "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" + "webpack": "5.x.x", + "webpack-cli": "5.x.x" }, "peerDependenciesMeta": { "webpack-dev-server": { @@ -620,6 +622,25 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -635,6 +656,42 @@ "node": ">= 8" } }, + "node_modules/dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-defaults": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dotenv-defaults/-/dotenv-defaults-2.0.2.tgz", + "integrity": "sha512-iOIzovWfsUHU91L5i8bJce3NYK5JXeAwH50Jh6+ARUdLiiGlYWfGw6UkzsYqaXZH/hjE/eCd/PlfM/qqyK0AMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dotenv": "^8.2.0" + } + }, + "node_modules/dotenv-webpack": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-8.1.0.tgz", + "integrity": "sha512-owK1JcsPkIobeqjVrk6h7jPED/W6ZpdFsMPR+5ursB7/SdgDyO+VzAU+szK8C8u3qUhtENyYnj8eyXMR5kkGag==", + "dev": true, + "license": "MIT", + "dependencies": { + "dotenv-defaults": "^2.0.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "webpack": "^4 || ^5" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.157", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", @@ -1629,40 +1686,43 @@ } }, "node_modules/webpack-cli": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", - "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", "dependencies": { - "@discoveryjs/json-ext": "^0.6.1", - "@webpack-cli/configtest": "^3.0.1", - "@webpack-cli/info": "^3.0.1", - "@webpack-cli/serve": "^3.0.1", + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", "colorette": "^2.0.14", - "commander": "^12.1.0", + "commander": "^10.0.1", "cross-spawn": "^7.0.3", - "envinfo": "^7.14.0", + "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", - "webpack-merge": "^6.0.1" + "webpack-merge": "^5.7.3" }, "bin": { "webpack-cli": "bin/cli.js" }, "engines": { - "node": ">=18.12.0" + "node": ">=14.15.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.82.0" + "webpack": "5.x.x" }, "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, "webpack-bundle-analyzer": { "optional": true }, @@ -1672,28 +1732,28 @@ } }, "node_modules/webpack-cli/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=14" } }, "node_modules/webpack-merge": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", - "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", "dev": true, "license": "MIT", "dependencies": { "clone-deep": "^4.0.1", "flat": "^5.0.2", - "wildcard": "^2.0.1" + "wildcard": "^2.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=10.0.0" } }, "node_modules/webpack-sources": { diff --git a/package.json b/package.json index 1f254ea..cd47e6f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "index.js", "scripts": { "build": "webpack --config webpack.config.js", + "build:prod": "cross-env NODE_ENV=production webpack --config webpack.config.js", "watch": "webpack --config webpack.config.js --watch", "dev": "npm run build && npm run copy-files", "copy-files": "cp popup.html dist/ && mkdir -p dist/icons && cp -r icons/* dist/icons/", @@ -19,6 +20,8 @@ "license": "ISC", "devDependencies": { "@types/chrome": "^0.0.254", + "cross-env": "^7.0.3", + "dotenv-webpack": "^8.0.1", "ts-loader": "^9.5.1", "typescript": "^5.3.3", "webpack": "^5.89.0", diff --git a/popup.html b/popup.html index d076d33..4d11843 100644 --- a/popup.html +++ b/popup.html @@ -230,6 +230,126 @@ color: #666; margin-top: 5px; } + + .ai-response-container { + background-color: #f9f9f9; + border-radius: 4px; + padding: 10px; + margin-top: 10px; + } + + .ai-response-item { + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid #ddd; + } + + .ai-response-question { + font-weight: bold; + margin-bottom: 5px; + } + + .ai-response-text { + font-size: 13px; + line-height: 1.4; + white-space: pre-wrap; + } + + .ai-action-buttons { + display: flex; + gap: 10px; + margin-top: 8px; + } + + .ai-action-button { + background-color: #f0f0f0; + border: 1px solid #ddd; + border-radius: 4px; + padding: 3px 8px; + font-size: 12px; + cursor: pointer; + } + + .ai-action-button:hover { + background-color: #e0e0e0; + } + + #generateAIResponse { + background-color: #4285f4; + color: white; + width: 100%; + } + + #copyAIResponse { + background-color: #34a853; + color: white; + } + + .loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255,255,255,.3); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .service-options { + margin-top: 10px; + padding: 10px; + border: 1px solid #eee; + border-radius: 4px; + } + + .info-box { + background-color: #f8f9fa; + border: 1px solid #ddd; + border-radius: 4px; + padding: 10px; + margin-top: 10px; + font-size: 12px; + } + + .info-box p { + margin-top: 0; + margin-bottom: 8px; + } + + .info-box ol { + margin: 0; + padding-left: 20px; + } + + .info-box code { + background-color: #eee; + padding: 2px 4px; + border-radius: 3px; + font-family: monospace; + } + + .info-box a { + color: #4285f4; + text-decoration: none; + } + + .info-box a:hover { + text-decoration: underline; + } + + .note { + color: #666; + font-style: italic; + margin-top: 8px; + font-size: 11px; + background-color: #fffde7; + padding: 5px; + border-left: 3px solid #ffd54f; + } @@ -251,6 +371,54 @@

Job Board Assistant

+
+ AI Service: + +
+ +
+
+ OpenAI API Key: + +
+ +
+ +
+ +
+

ChatGPT Setup: Requires an OpenAI API key to use ChatGPT.

+
    +
  1. Get an API key from OpenAI
  2. +
  3. Enter your key above and click "Save API Key"
  4. +
+

Note: If no API key is provided, mock responses will be used automatically.

+
+
+ +
+
+ DeepSeek API Key: + +
+ +
+ +
+ +
+

DeepSeek Setup: Requires a DeepSeek API key.

+
    +
  1. Get an API key from DeepSeek
  2. +
  3. Enter your key above and click "Save API Key"
  4. +
+

Note: If no API key is provided, mock responses will be used automatically.

+
+
+
@@ -320,14 +488,90 @@

Your Profile

+
Education
- - + +
- - + + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
Personal Details
+
+ +
Resume
@@ -344,12 +588,84 @@

Your Profile

+
Skills
+
+ + +
+ +
Self-Identification (Optional)
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
AI Assistant
+
+ + +
+ +
+ + +
+ +
+ +
+ + + + + \ No newline at end of file diff --git a/src/content.ts b/src/content.ts index 26bb2a9..839952c 100644 --- a/src/content.ts +++ b/src/content.ts @@ -2,13 +2,48 @@ import { Message, ResponseMessage } from './types'; import { detectJobApplicationForm, detectJobBoardSite, getCurrentJobBoardPattern } from './services/jobFormService'; import { autofillForm } from './services/autofillService'; -import { saveJobFormData } from './services/storageService'; +import { saveJobFormData, getOptions } from './services/storageService'; +import { setAPIKey } from './services/aiService'; console.log('Job Board Assistant content script loaded'); +// Enable debug mode for more verbose logging +const DEBUG_MODE = true; + +function debugLog(...args: any[]): void { + if (DEBUG_MODE) { + console.log('[Job Board Assistant]', ...args); + } +} + +// Initialize the content script +async function initialize(): Promise { + try { + // Load options + const options = await getOptions(); + + // Set API key if available + if (options.apiKey) { + setAPIKey(options.apiKey); + debugLog('API key loaded from options'); + } else if (options.openaiApiKey) { + // For backward compatibility + setAPIKey(options.openaiApiKey); + debugLog('API key loaded from legacy options field'); + } else { + debugLog('No API key found in options'); + } + + // Check for job form on page load + checkForJobFormOnLoad(); + } catch (error) { + console.error('Error initializing content script:', error); + } +} + // Listen for messages from the popup chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => { - console.log('Message received in content script:', message); + debugLog('Message received in content script:', message); if (message.action === 'findJobForm') { handleFindJobForm(sendResponse); @@ -24,15 +59,23 @@ chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => // Check if the current page has a job application form async function handleFindJobForm(sendResponse: (response: ResponseMessage) => void): Promise { try { + debugLog('Looking for job application form...'); const formElement = detectJobApplicationForm(); const formFound = !!formElement; + debugLog('Form found:', formFound); + if (formFound) { + debugLog('Form element:', formElement); + } + // Check if the form has resume upload fields const hasResumeField = formFound && checkForResumeFields(formElement); + debugLog('Has resume field:', hasResumeField); // Save result to storage if (formFound) { await saveJobFormData(window.location.href, true); + debugLog('Job form data saved'); } sendResponse({ @@ -52,6 +95,7 @@ async function handleFindJobForm(sendResponse: (response: ResponseMessage) => vo // Check if the form has resume upload fields function checkForResumeFields(formElement: Element): boolean { const fileInputs = formElement.querySelectorAll('input[type="file"]'); + debugLog(`Found ${fileInputs.length} file inputs in form`); for (const input of Array.from(fileInputs)) { const inputElement = input as HTMLInputElement; @@ -59,6 +103,8 @@ function checkForResumeFields(formElement: Element): boolean { const id = inputElement.id?.toLowerCase() || ''; const accept = inputElement.accept?.toLowerCase() || ''; + debugLog(`File input: name=${name}, id=${id}, accept=${accept}`); + // Check if this is likely a resume upload field if ( name.includes('resume') || name.includes('cv') || @@ -66,54 +112,108 @@ function checkForResumeFields(formElement: Element): boolean { accept.includes('pdf') || accept.includes('doc') || inputElement.closest('label')?.textContent?.toLowerCase().includes('resume') ) { + debugLog('Resume field found'); return true; } } + debugLog('No resume fields found'); return false; } // Handle autofill request async function handleAutofill(sendResponse: (response: ResponseMessage) => void): Promise { try { - // Get the current job board pattern - const jobBoardPattern = getCurrentJobBoardPattern(); + debugLog('Handling autofill request'); - if (!jobBoardPattern) { - sendResponse({ - success: false, - error: 'No job board pattern found for this site' - }); - return; + // First try to get the current job board pattern + let jobBoardPattern = getCurrentJobBoardPattern(); + + if (jobBoardPattern) { + debugLog('Using job board pattern for', window.location.hostname); + } else { + debugLog('No specific job board pattern found, creating a generic one'); + + // If no pattern was found, create a generic pattern using the detected form + const formElement = detectJobApplicationForm(); + + if (!formElement) { + debugLog('No job application form found on this page'); + sendResponse({ + success: false, + error: 'No job application form found on this page' + }); + return; + } + + // Create a generic pattern for the detected form + jobBoardPattern = { + formSelector: getUniqueSelector(formElement), + nameFields: ['input[name*="name"]', 'input[id*="name"]', 'input[placeholder*="name"]'], + emailFields: ['input[type="email"]', 'input[name*="email"]', 'input[id*="email"]'] + }; + + debugLog('Created generic pattern for detected form:', jobBoardPattern); } // Autofill the form + debugLog('Attempting to autofill the form...'); const result = await autofillForm(jobBoardPattern); + debugLog('Autofill result:', result); sendResponse({ success: true, - fieldsFilled: result.fieldsFilled + fieldsFilled: result.fieldsFilled, + essaysDetected: result.essaysDetected }); } catch (error) { console.error('Error autofilling form:', error); sendResponse({ success: false, - error: 'Error autofilling form' + error: error instanceof Error ? error.message : 'Error autofilling form' }); } } +// Generate a unique CSS selector for an element +function getUniqueSelector(element: Element): string { + // Try to use ID if available + if (element.id) { + return `#${element.id}`; + } + + // Try to use a unique class + if (element.classList.length > 0) { + return `.${Array.from(element.classList).join('.')}`; + } + + // Use the tag name and position + const tagName = element.tagName.toLowerCase(); + if (tagName === 'form') { + const forms = document.querySelectorAll('form'); + const formIndex = Array.from(forms).indexOf(element as HTMLFormElement); + return `form:nth-of-type(${formIndex + 1})`; + } + + // Fallback to a very generic selector + return tagName; +} + // Check for job form on page load async function checkForJobFormOnLoad(): Promise { try { + debugLog('Checking for job form on page load'); const formElement = detectJobApplicationForm(); if (formElement) { + debugLog('Job form found on page load'); await saveJobFormData(window.location.href, true); + } else { + debugLog('No job form found on page load'); } } catch (error) { console.error('Error checking for job form on load:', error); } } -// Run initial check when page is fully loaded -window.addEventListener('load', checkForJobFormOnLoad); \ No newline at end of file +// Run initialization when script is loaded +initialize(); \ No newline at end of file diff --git a/src/popup.ts b/src/popup.ts index f464c7b..a9176a9 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -1,6 +1,7 @@ // Popup script that runs when the extension icon is clicked import { Options, StorageData, UserProfile } from './types'; -import { saveOptions, getOptions, saveUserProfile, getUserProfile, getJobFormData } from './services/storageService'; +import { saveOptions, getOptions, saveUserProfile, getUserProfile, getJobFormData, getAllAIResponses } from './services/storageService'; +import { generateAIResponse, setAPIKey, getAPIKey, setAIService, getAIService } from './services/aiService'; console.log('Popup script loaded'); @@ -11,7 +12,18 @@ async function initPopup(): Promise { const themeSelect = document.getElementById('themeSelect') as HTMLSelectElement; const statusElement = document.getElementById('status') as HTMLDivElement; - if (analyzeButton && toggleSwitch && themeSelect) { + // AI service elements + const aiServiceSelect = document.getElementById('aiServiceSelect') as HTMLSelectElement; + const openaiOptions = document.getElementById('openaiOptions') as HTMLDivElement; + const openaiApiKeyInput = document.getElementById('openaiApiKey') as HTMLInputElement; + const saveApiKeyButton = document.getElementById('saveApiKey') as HTMLButtonElement; + const deepseekOptions = document.getElementById('deepseekOptions') as HTMLDivElement; + const deepseekApiKeyInput = document.getElementById('deepseekApiKey') as HTMLInputElement; + const saveDeepseekApiKeyButton = document.getElementById('saveDeepseekApiKey') as HTMLButtonElement; + + if (analyzeButton && toggleSwitch && themeSelect && + aiServiceSelect && openaiOptions && openaiApiKeyInput && saveApiKeyButton && + deepseekOptions && deepseekApiKeyInput && saveDeepseekApiKeyButton) { try { // Load saved options const options = await getOptions(); @@ -20,6 +32,27 @@ async function initPopup(): Promise { toggleSwitch.checked = options.enabled; themeSelect.value = options.theme; + // Set AI service + aiServiceSelect.value = options.aiService || 'openai'; + toggleAIServiceOptions(aiServiceSelect.value); + + // Set API key if available + if (options.apiKey) { + if (options.aiService === 'openai') { + openaiApiKeyInput.value = '••••••••••••••••••••••••••'; + } else if (options.aiService === 'deepseek') { + deepseekApiKeyInput.value = '••••••••••••••••••••••••••'; + } + setAPIKey(options.apiKey); + } else if (options.openaiApiKey) { + // For backward compatibility + openaiApiKeyInput.value = '••••••••••••••••••••••••••'; + setAPIKey(options.openaiApiKey); + } + + // Configure AI service + setAIService(options.aiService || 'openai'); + // Apply theme document.body.setAttribute('data-theme', options.theme); } catch (error) { @@ -56,6 +89,100 @@ async function initPopup(): Promise { }); }); + // Toggle between AI service options + aiServiceSelect.addEventListener('change', () => { + toggleAIServiceOptions(aiServiceSelect.value); + saveOptionsHandler(); + }); + + // Save OpenAI API key when clicked + saveApiKeyButton.addEventListener('click', async () => { + const apiKey = openaiApiKeyInput.value.trim(); + + if (apiKey === '••••••••••••••••••••••••••') { + // User didn't change the masked key, so keep the existing one + statusElement.textContent = 'API key unchanged.'; + return; + } + + if (!apiKey) { + statusElement.textContent = 'Please enter an API key.'; + return; + } + + try { + // Get current options + const options = await getOptions(); + + // Update with new API key + options.apiKey = apiKey; + options.aiService = 'openai'; + // Remove old key format for consistency + delete options.openaiApiKey; + + // Save to storage + await saveOptions(options); + + // Update the service + setAPIKey(apiKey); + setAIService('openai'); + + // Mask the input for security + openaiApiKeyInput.value = '••••••••••••••••••••••••••'; + + statusElement.textContent = 'OpenAI API key saved successfully!'; + setTimeout(() => { + statusElement.textContent = ''; + }, 2000); + } catch (error) { + console.error('Error saving API key:', error); + statusElement.textContent = 'Error saving API key.'; + } + }); + + // Save DeepSeek API key when clicked + saveDeepseekApiKeyButton.addEventListener('click', async () => { + const apiKey = deepseekApiKeyInput.value.trim(); + + if (apiKey === '••••••••••••••••••••••••••') { + // User didn't change the masked key, so keep the existing one + statusElement.textContent = 'API key unchanged.'; + return; + } + + if (!apiKey) { + statusElement.textContent = 'Please enter an API key.'; + return; + } + + try { + // Get current options + const options = await getOptions(); + + // Update with new API key + options.apiKey = apiKey; + options.aiService = 'deepseek'; + + // Save to storage + await saveOptions(options); + + // Update the service + setAPIKey(apiKey); + setAIService('deepseek'); + + // Mask the input for security + deepseekApiKeyInput.value = '••••••••••••••••••••••••••'; + + statusElement.textContent = 'DeepSeek API key saved successfully!'; + setTimeout(() => { + statusElement.textContent = ''; + }, 2000); + } catch (error) { + console.error('Error saving API key:', error); + statusElement.textContent = 'Error saving API key.'; + } + }); + // Save options when changed toggleSwitch.addEventListener('change', saveOptionsHandler); themeSelect.addEventListener('change', saveOptionsHandler); @@ -65,6 +192,22 @@ async function initPopup(): Promise { } } +// Toggle between AI service options based on selection +function toggleAIServiceOptions(service: string): void { + const openaiOptions = document.getElementById('openaiOptions'); + const deepseekOptions = document.getElementById('deepseekOptions'); + + if (openaiOptions && deepseekOptions) { + if (service === 'openai') { + openaiOptions.style.display = 'block'; + deepseekOptions.style.display = 'none'; + } else if (service === 'deepseek') { + openaiOptions.style.display = 'none'; + deepseekOptions.style.display = 'block'; + } + } +} + // Show profile management UI async function showProfileManager(hasResumeField: boolean = false): Promise { const profileContainer = document.getElementById('autofillContainer'); @@ -144,9 +287,28 @@ function updateProfileForm(profile: UserProfile): void { setInputValue('profileGithub', profile.github); setInputValue('profilePortfolio', profile.portfolio); setInputValue('profileYearsOfExperience', profile.yearsOfExperience); + + // Education details + setInputValue('profileDegree', profile.degree); + setInputValue('profileDiscipline', profile.discipline); + setInputValue('profileSchool', profile.school); + setInputValue('profileEducationStartMonth', profile.educationStartMonth); + setInputValue('profileEducationStartYear', profile.educationStartYear); + setInputValue('profileEducationEndMonth', profile.educationEndMonth); + setInputValue('profileEducationEndYear', profile.educationEndYear); setInputValue('profileEducation', profile.education); + + // Skills setInputValue('profileSkills', profile.skills); + // Personal details + setInputValue('profileGender', profile.gender); + + // Self-identification + setInputValue('profileHispanicLatino', profile.hispanicLatino); + setInputValue('profileVeteranStatus', profile.veteranStatus); + setInputValue('profileDisabilityStatus', profile.disabilityStatus); + // Resume information if (profile.resumeFileName) { const resumeFileName = document.getElementById('resumeFileName'); @@ -222,8 +384,27 @@ async function collectProfileData(): Promise { github: getInputValue('profileGithub'), portfolio: getInputValue('profilePortfolio'), yearsOfExperience: getInputValue('profileYearsOfExperience'), + + // Education details + degree: getInputValue('profileDegree'), + discipline: getInputValue('profileDiscipline'), + school: getInputValue('profileSchool'), + educationStartMonth: getInputValue('profileEducationStartMonth'), + educationStartYear: getInputValue('profileEducationStartYear'), + educationEndMonth: getInputValue('profileEducationEndMonth'), + educationEndYear: getInputValue('profileEducationEndYear'), education: getInputValue('profileEducation'), - skills: getInputValue('profileSkills') + + // Skills + skills: getInputValue('profileSkills'), + + // Personal details + gender: getInputValue('profileGender'), + + // Self-identification + hispanicLatino: getInputValue('profileHispanicLatino'), + veteranStatus: getInputValue('profileVeteranStatus'), + disabilityStatus: getInputValue('profileDisabilityStatus') }; // Handle resume file @@ -278,18 +459,46 @@ function addAutofillButton(tabId: number, statusElement: HTMLElement): void { const autofillButton = document.getElementById('autofillButton'); if (autofillButton) { autofillButton.addEventListener('click', () => { + // Update status first to show we're working + statusElement.textContent = 'Attempting to autofill form...'; + // Send autofill message to content script chrome.tabs.sendMessage(tabId, { action: 'autofillWithStoredProfile' }, (response) => { + if (chrome.runtime.lastError) { + console.error('Error sending message:', chrome.runtime.lastError); + statusElement.textContent = 'Error: Could not communicate with the page. Try refreshing.'; + return; + } + if (response && response.success) { - if (response.fieldsFilled) { - statusElement.textContent = 'Form autofilled!'; + const fieldsFilled = response.fieldsFilled || 0; + const essaysDetected = response.essaysDetected || 0; + + if (fieldsFilled > 0 || essaysDetected > 0) { + let statusMessage = `Form autofilled! (${fieldsFilled} fields filled`; + + if (essaysDetected > 0) { + statusMessage += `, ${essaysDetected} essay ${essaysDetected === 1 ? 'question' : 'questions'} answered with AI`; + } + + statusMessage += ')'; + statusElement.textContent = statusMessage; } else { - statusElement.textContent = 'No fields could be autofilled.'; + statusElement.textContent = 'Could not autofill any fields. The form structure may not be recognized.'; + + // Suggest a solution + setTimeout(() => { + statusElement.textContent = 'Try clicking "Find Job Application Form" button again or refresh the page.'; + }, 3000); } } else { - statusElement.textContent = 'Error autofilling form.'; + let errorMessage = 'Error autofilling form.'; + if (response && response.error) { + errorMessage += ` ${response.error}`; + } + statusElement.textContent = errorMessage; } }); }); @@ -337,16 +546,25 @@ async function checkForRecentJobForm(): Promise { async function saveOptionsHandler(): Promise { const toggleSwitch = document.getElementById('toggleEnabled') as HTMLInputElement; const themeSelect = document.getElementById('themeSelect') as HTMLSelectElement; + const aiServiceSelect = document.getElementById('aiServiceSelect') as HTMLSelectElement; + + // Get current options to preserve API key + const currentOptions = await getOptions(); const options: Options = { enabled: toggleSwitch.checked, - theme: themeSelect.value + theme: themeSelect.value, + apiKey: currentOptions.apiKey, // Preserve existing API key + aiService: aiServiceSelect.value as 'openai' | 'deepseek' // Cast to the correct type }; try { // Save to Chrome storage await saveOptions(options); + // Update AI service + setAIService(options.aiService || 'openai'); + // Apply theme immediately document.body.setAttribute('data-theme', options.theme); @@ -354,14 +572,149 @@ async function saveOptionsHandler(): Promise { const statusElement = document.getElementById('status') as HTMLDivElement; if (statusElement) { statusElement.textContent = 'Options saved!'; + + // Add note about mock responses if no API key + if (!currentOptions.apiKey) { + setTimeout(() => { + statusElement.textContent = 'No API key set. Mock responses will be used.'; + }, 1500); + } else { + setTimeout(() => { + statusElement.textContent = ''; + }, 1500); + } + } + } catch (error) { + console.error('Error saving options:', error); + } +} + +// Function to initialize the AI assistant +async function initAIAssistant(): Promise { + const generateButton = document.getElementById('generateAIResponse') as HTMLButtonElement; + const questionInput = document.getElementById('aiQuestion') as HTMLTextAreaElement; + const contextInput = document.getElementById('aiContext') as HTMLTextAreaElement; + const responseContainer = document.getElementById('aiResponseContainer') as HTMLDivElement; + const responseTextarea = document.getElementById('aiResponse') as HTMLTextAreaElement; + const copyButton = document.getElementById('copyAIResponse') as HTMLButtonElement; + const historyContainer = document.getElementById('aiResponseHistory') as HTMLDivElement; + const responseList = document.getElementById('aiResponseList') as HTMLDivElement; + + if (generateButton && questionInput && responseContainer && responseTextarea && copyButton) { + // Load previous responses + await loadPreviousResponses(responseList, historyContainer); + + // Set up the generate button + generateButton.addEventListener('click', async () => { + const question = questionInput.value.trim(); + if (!question) { + return; + } + + // Show loading state + generateButton.disabled = true; + generateButton.innerHTML = ' Generating...'; + + try { + // Get user profile for context + const profile = await getUserProfile(); + + // Generate the response + const response = await generateAIResponse( + question, + contextInput.value.trim(), + profile + ); + + // Display the response + responseTextarea.value = response; + responseContainer.style.display = 'block'; + + // Refresh the response history + await loadPreviousResponses(responseList, historyContainer); + } catch (error) { + console.error('Error generating response:', error); + } finally { + // Reset button state + generateButton.disabled = false; + generateButton.textContent = 'Generate Response'; + } + }); + + // Set up copy button + copyButton.addEventListener('click', () => { + responseTextarea.select(); + document.execCommand('copy'); + + // Show copied message + const originalText = copyButton.textContent; + copyButton.textContent = 'Copied!'; setTimeout(() => { - statusElement.textContent = ''; + copyButton.textContent = originalText; }, 1500); + }); + } +} + +// Load previous AI responses +async function loadPreviousResponses( + responseList: HTMLElement, + historyContainer: HTMLElement +): Promise { + try { + const responses = await getAllAIResponses(); + const responseKeys = Object.keys(responses); + + if (responseKeys.length > 0) { + // Clear previous list + responseList.innerHTML = ''; + historyContainer.style.display = 'block'; + + // Create response items (limit to 5 most recent) + const recentKeys = responseKeys.slice(-5); + recentKeys.forEach(key => { + const prompt = responses[key]; + const item = document.createElement('div'); + item.className = 'ai-response-item'; + + const question = document.createElement('div'); + question.className = 'ai-response-question'; + question.textContent = prompt.question; + + const responseText = document.createElement('div'); + responseText.className = 'ai-response-text'; + responseText.textContent = prompt.response || ''; + + const buttons = document.createElement('div'); + buttons.className = 'ai-action-buttons'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'ai-action-button'; + copyBtn.textContent = 'Copy'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(prompt.response || ''); + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 1500); + }); + + buttons.appendChild(copyBtn); + item.appendChild(question); + item.appendChild(responseText); + item.appendChild(buttons); + responseList.appendChild(item); + }); + } else { + historyContainer.style.display = 'none'; } } catch (error) { - console.error('Error saving options:', error); + console.error('Error loading previous responses:', error); } } // Initialize popup when DOM is loaded -document.addEventListener('DOMContentLoaded', initPopup); \ No newline at end of file +document.addEventListener('DOMContentLoaded', async () => { + await initPopup(); + await initAIAssistant(); +}); \ No newline at end of file diff --git a/src/services/aiService.ts b/src/services/aiService.ts new file mode 100644 index 0000000..c3c6d9d --- /dev/null +++ b/src/services/aiService.ts @@ -0,0 +1,291 @@ +import { AIPrompt } from '../types'; +import { saveAIResponse } from './storageService'; + +// API configuration +const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'; +const OPENAI_MODEL = 'gpt-3.5-turbo'; // Default OpenAI model + +const DEEPSEEK_API_URL = 'https://api.deepseek.com/v1/chat/completions'; +const DEEPSEEK_MODEL = 'deepseek-chat'; // Use the appropriate DeepSeek model + +// Service type +type AIService = 'openai' | 'deepseek'; + +// This API key should be stored securely and not in the code +// Initial value will be loaded from environment variables or storage +let API_KEY = ''; +let AI_SERVICE: AIService = 'openai'; // Default to OpenAI + +// Try to load API keys from environment if available (for development) +if (process.env.OPENAI_API_KEY) { + API_KEY = process.env.OPENAI_API_KEY; + AI_SERVICE = 'openai'; + console.log('OpenAI API key loaded from environment'); +} else if (process.env.DEEPSEEK_API_KEY) { + API_KEY = process.env.DEEPSEEK_API_KEY; + AI_SERVICE = 'deepseek'; + console.log('DeepSeek API key loaded from environment'); +} + +/** + * Set the API key + */ +export function setAPIKey(apiKey: string): void { + API_KEY = apiKey; +} + +/** + * Get the API key + */ +export function getAPIKey(): string { + return API_KEY; +} + +/** + * Set the AI service to use + */ +export function setAIService(service: AIService): void { + AI_SERVICE = service; +} + +/** + * Get the current AI service + */ +export function getAIService(): AIService { + return AI_SERVICE; +} + +/** + * Generate an AI-assisted response for a job application question + */ +export async function generateAIResponse( + question: string, + context: string, + userProfile: any +): Promise { + try { + console.log('Generating AI response for question:', question); + + // Create a system prompt that includes the user's profile and context + const systemPrompt = ` + You are an assistant helping a job applicant answer application questions. + Based on the applicant's profile and the job context, generate a professional, + concise, and personalized response that highlights relevant skills and experience. + Keep responses truthful and authentic to the applicant's background. + + Job context: ${context} + + Applicant profile: + - Name: ${userProfile.name} + - Experience: ${userProfile.yearsOfExperience} + - Education: ${userProfile.degree} in ${userProfile.discipline} from ${userProfile.school} + - Skills: ${userProfile.skills} + `; + + let response; + + // Choose which service to use + if (API_KEY) { + if (AI_SERVICE === 'openai') { + response = await callOpenAIAPI(systemPrompt, question); + } else if (AI_SERVICE === 'deepseek') { + response = await callDeepSeekAPI(systemPrompt, question); + } + } + + // If no response was generated (no API key or service error), use mock responses + if (!response) { + console.log('No valid AI service configuration or error occurred, using mock responses'); + response = await mockAICall(systemPrompt, question); + } + + // Create a unique ID for this question + const questionId = createQuestionId(question); + + // Save the response to storage + await saveAIResponse(questionId, { + question, + context, + response + }); + + return response; + } catch (error) { + console.error('Error generating AI response:', error); + return `Sorry, I was unable to generate a response. Please try again. ${error}`; + } +} + +/** + * Call the OpenAI API to generate a response + */ +async function callOpenAIAPI(systemPrompt: string, question: string): Promise { + try { + if (!API_KEY) { + throw new Error('OpenAI API key not set'); + } + + const requestBody = { + model: OPENAI_MODEL, + messages: [ + { + role: 'system', + content: systemPrompt + }, + { + role: 'user', + content: question + } + ], + temperature: 0.7, + max_tokens: 500 + }; + + // Set a timeout for the fetch + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + try { + const response = await fetch(OPENAI_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}` + }, + body: JSON.stringify(requestBody), + signal: controller.signal + }); + + // Clear the timeout since the request completed + clearTimeout(timeoutId); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`OpenAI API error: ${errorData.error?.message || response.statusText}`); + } + + const data = await response.json(); + + // Extract the response text + const responseText = data.choices?.[0]?.message?.content?.trim() || ''; + + if (!responseText) { + throw new Error('Empty response from OpenAI API'); + } + + return responseText; + } catch (error) { + // Clear the timeout in case of error + clearTimeout(timeoutId); + throw error; + } + } catch (err) { + const error = err as Error; + console.error('Error calling OpenAI API:', error); + throw error; + } +} + +/** + * Call the DeepSeek API to generate a response + */ +async function callDeepSeekAPI(systemPrompt: string, question: string): Promise { + try { + if (!API_KEY) { + throw new Error('DeepSeek API key not set'); + } + + const requestBody = { + model: DEEPSEEK_MODEL, + messages: [ + { + role: 'system', + content: systemPrompt + }, + { + role: 'user', + content: question + } + ], + temperature: 0.7, + max_tokens: 500 + }; + + // Set a timeout for the fetch + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + try { + const response = await fetch(DEEPSEEK_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_KEY}` + }, + body: JSON.stringify(requestBody), + signal: controller.signal + }); + + // Clear the timeout since the request completed + clearTimeout(timeoutId); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`DeepSeek API error: ${errorData.error?.message || response.statusText}`); + } + + const data = await response.json(); + + // Extract the response text + const responseText = data.choices?.[0]?.message?.content?.trim() || ''; + + if (!responseText) { + throw new Error('Empty response from DeepSeek API'); + } + + return responseText; + } catch (error) { + // Clear the timeout in case of error + clearTimeout(timeoutId); + throw error; + } + } catch (err) { + const error = err as Error; + console.error('Error calling DeepSeek API:', error); + throw error; + } +} + +/** + * Mock function that simulates an AI API call + * In a production environment, this would be replaced with a real API call + */ +async function mockAICall(systemPrompt: string, question: string): Promise { + // Simulate API latency + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Return a mock response based on the question type + if (question.toLowerCase().includes('why are you a good fit')) { + return `I believe I am a strong fit for this position because my background combines relevant technical skills with practical experience in the field. My education in computer science has provided me with a solid foundation in key technologies mentioned in the job description, and my previous roles have allowed me to apply these skills in real-world scenarios. I'm particularly drawn to this opportunity because it aligns with my career goals of working in a collaborative environment where I can contribute to innovative projects while continuing to grow professionally.`; + } + + if (question.toLowerCase().includes('experience')) { + return `Throughout my career, I've had the opportunity to work on a variety of projects that have strengthened my technical abilities and problem-solving skills. In my most recent role, I was responsible for developing and maintaining web applications that served thousands of users daily. This experience taught me how to write efficient, scalable code and how to collaborate effectively with cross-functional teams. I've consistently received positive feedback for my ability to communicate complex technical concepts clearly and to meet deadlines even under pressure.`; + } + + // Generic response for other questions + return `Based on my background and the requirements for this position, I believe I can make valuable contributions to your team. My combination of technical skills, education, and practical experience has prepared me well for this role. I'm excited about the opportunity to bring my expertise to your organization and to continue developing my professional capabilities in this dynamic field.`; +} + +/** + * Create a unique ID for a question to use as a storage key + */ +function createQuestionId(question: string): string { + // Simplify the question and create a key + return 'q_' + question + .toLowerCase() + .replace(/[^a-z0-9 ]/g, '') + .trim() + .replace(/\s+/g, '_') + .substring(0, 30); +} \ No newline at end of file diff --git a/src/services/autofillService.ts b/src/services/autofillService.ts index 9581416..510869d 100644 --- a/src/services/autofillService.ts +++ b/src/services/autofillService.ts @@ -1,5 +1,6 @@ import { UserProfile, JobBoardPattern } from '../types'; import { getUserProfile } from './storageService'; +import { generateAIResponse } from './aiService'; // Field mapping for common job application form fields const FIELD_MAPPING = { @@ -14,8 +15,22 @@ const FIELD_MAPPING = { github: ['github', 'githubUrl', 'github-url', 'github_url', 'githubProfile', 'github-profile', 'github_profile'], portfolio: ['portfolio', 'portfolioUrl', 'portfolio-url', 'portfolio_url', 'website', 'personalWebsite', 'personal-website', 'personal_website'], yearsOfExperience: ['yearsOfExperience', 'years-of-experience', 'years_of_experience', 'experience', 'experienceYears', 'experience-years', 'experience_years'], - education: ['education', 'educationBackground', 'education-background', 'education_background', 'degree', 'qualification'], - skills: ['skills', 'skillSet', 'skill-set', 'skill_set', 'keySkills', 'key-skills', 'key_skills', 'technicalSkills', 'technical-skills', 'technical_skills'] + education: ['education', 'educationBackground', 'education-background', 'education_background', 'qualification'], + skills: ['skills', 'skillSet', 'skill-set', 'skill_set', 'keySkills', 'key-skills', 'key_skills', 'technicalSkills', 'technical-skills', 'technical_skills'], + // Education details + degree: ['degree', 'degreeType', 'degree-type', 'degree_type', 'educationLevel', 'education-level', 'education_level', 'qualification'], + discipline: ['discipline', 'major', 'field', 'fieldOfStudy', 'field-of-study', 'field_of_study', 'studyField', 'study-field', 'study_field', 'concentration'], + school: ['school', 'university', 'college', 'institution', 'educationInstitution', 'education-institution', 'education_institution', 'schoolName', 'school-name', 'school_name'], + educationStartYear: ['educationStartYear', 'education-start-year', 'education_start_year', 'startYear', 'start-year', 'start_year', 'fromYear', 'from-year', 'from_year'], + educationStartMonth: ['educationStartMonth', 'education-start-month', 'education_start_month', 'startMonth', 'start-month', 'start_month', 'fromMonth', 'from-month', 'from_month'], + educationEndYear: ['educationEndYear', 'education-end-year', 'education_end_year', 'endYear', 'end-year', 'end_year', 'toYear', 'to-year', 'to_year', 'graduationYear', 'graduation-year', 'graduation_year'], + educationEndMonth: ['educationEndMonth', 'education-end-month', 'education_end_month', 'endMonth', 'end-month', 'end_month', 'toMonth', 'to-month', 'to_month', 'graduationMonth', 'graduation-month', 'graduation_month'], + // Personal details + gender: ['gender', 'sex', 'genderIdentity', 'gender-identity', 'gender_identity'], + // Self-identification + hispanicLatino: ['hispanic', 'latino', 'hispanicLatino', 'hispanic-latino', 'hispanic_latino', 'ethnicity', 'hispanic-origin', 'hispanic_origin'], + veteranStatus: ['veteran', 'veteranStatus', 'veteran-status', 'veteran_status', 'military', 'militaryService', 'military-service', 'military_service', 'armedForces', 'armed-forces', 'armed_forces'], + disabilityStatus: ['disability', 'disabilityStatus', 'disability-status', 'disability_status', 'disabled', 'disabilities'] }; // Resume field patterns @@ -24,21 +39,39 @@ const RESUME_FIELD_PATTERNS = [ 'attachment', 'file', 'upload-resume', 'upload-cv', 'upload_resume', 'upload_cv' ]; +// Essay question patterns - these are common keywords found in essay question fields +const ESSAY_QUESTION_PATTERNS = [ + 'why', 'explain', 'describe', 'tell us', 'share', 'provide', 'elaborate', + 'reason', 'experience', 'background', 'skills', 'fit', 'contribute', 'value', + 'strengths', 'weaknesses', 'achievements', 'goals', 'interest', 'passion' +]; + /** * Autofills a job application form with user profile data */ -export async function autofillForm(jobBoardPattern: JobBoardPattern): Promise<{ fieldsFilled: number }> { +export async function autofillForm(jobBoardPattern: JobBoardPattern): Promise<{ fieldsFilled: number; essaysDetected: number }> { try { // Get user profile data const profile = await getUserProfile(); + console.log('Profile data for autofill:', profile); // Find the form element const formElement = document.querySelector(jobBoardPattern.formSelector); if (!formElement) { - console.log('Form element not found'); - return { fieldsFilled: 0 }; + console.error('Form element not found with selector:', jobBoardPattern.formSelector); + + // Try a more generic approach if the specific selector fails + const allForms = document.querySelectorAll('form'); + if (allForms.length > 0) { + console.log('Trying generic form selector instead'); + return autofillAllForms(profile); + } + + return { fieldsFilled: 0, essaysDetected: 0 }; } + console.log('Found form element:', formElement); + // Find and fill form fields let fieldsFilled = 0; @@ -48,19 +81,101 @@ export async function autofillForm(jobBoardPattern: JobBoardPattern): Promise<{ // Process textarea fields (for multi-line inputs like education and skills) fieldsFilled += autofillTextareaFields(formElement, profile); + // Process select fields (dropdown menus) + fieldsFilled += autofillSelectFields(formElement, profile); + // Process file inputs for resume if (profile.resumeData) { fieldsFilled += autofillResumeFields(formElement, profile); } - console.log(`Autofilled ${fieldsFilled} fields`); - return { fieldsFilled }; + // If we didn't fill anything, try a broader search + if (fieldsFilled === 0) { + console.log('No fields filled with specific selectors, trying broader approach'); + fieldsFilled += autofillAllFields(profile); + } + + // Detect and fill essay questions with AI responses + const essaysDetected = await detectAndFillEssayQuestions(formElement); + + console.log(`Autofilled ${fieldsFilled} fields and ${essaysDetected} essay questions`); + return { fieldsFilled, essaysDetected }; } catch (error) { console.error('Error autofilling form:', error); - return { fieldsFilled: 0 }; + return { fieldsFilled: 0, essaysDetected: 0 }; } } +/** + * Try to autofill all forms on the page + */ +function autofillAllForms(profile: UserProfile): { fieldsFilled: number; essaysDetected: number } { + let fieldsFilled = 0; + let essaysDetected = 0; + + // Find all forms + const allForms = document.querySelectorAll('form'); + console.log(`Found ${allForms.length} forms on the page`); + + allForms.forEach(async (form, index) => { + console.log(`Processing form #${index + 1}`); + + // Process all input fields + fieldsFilled += autofillInputFields(form, profile); + + // Process textarea fields + fieldsFilled += autofillTextareaFields(form, profile); + + // Process select fields + fieldsFilled += autofillSelectFields(form, profile); + + // Process file inputs for resume + if (profile.resumeData) { + fieldsFilled += autofillResumeFields(form, profile); + } + + // Detect and fill essay questions + const essays = await detectAndFillEssayQuestions(form); + essaysDetected += essays; + }); + + return { fieldsFilled, essaysDetected }; +} + +/** + * Try to autofill all input fields on the page, not limited to forms + */ +function autofillAllFields(profile: UserProfile): number { + let fieldsFilled = 0; + + // Find all input fields + const allInputs = document.querySelectorAll('input[type="text"], input[type="email"], input[type="tel"], input[type="url"]'); + const allTextareas = document.querySelectorAll('textarea'); + const allSelects = document.querySelectorAll('select'); + + console.log(`Found ${allInputs.length} inputs, ${allTextareas.length} textareas, and ${allSelects.length} selects on the page`); + + // Process all input fields + allInputs.forEach(input => { + const inputField = input as HTMLInputElement; + fieldsFilled += tryAutofillField(inputField, profile) ? 1 : 0; + }); + + // Process all textareas + allTextareas.forEach(textarea => { + const textareaField = textarea as HTMLTextAreaElement; + fieldsFilled += tryAutofillField(textareaField, profile) ? 1 : 0; + }); + + // Process all selects + allSelects.forEach(select => { + const selectField = select as HTMLSelectElement; + fieldsFilled += tryAutofillSelectField(selectField, profile) ? 1 : 0; + }); + + return fieldsFilled; +} + /** * Autofill input fields in a form */ @@ -69,48 +184,62 @@ function autofillInputFields(formElement: Element, profile: UserProfile): number // Find all input fields const inputFields = formElement.querySelectorAll('input[type="text"], input[type="email"], input[type="tel"], input[type="url"]'); + console.log(`Found ${inputFields.length} input fields`); inputFields.forEach((field) => { const inputField = field as HTMLInputElement; - - // Check field attributes (name, id, placeholder, etc.) for matches - const fieldName = inputField.name?.toLowerCase() || ''; - const fieldId = inputField.id?.toLowerCase() || ''; - const fieldPlaceholder = inputField.placeholder?.toLowerCase() || ''; - const fieldLabel = getFieldLabel(inputField)?.toLowerCase() || ''; - - // Try to match field with profile data - for (const [profileKey, possibleMatches] of Object.entries(FIELD_MAPPING)) { - if ( - possibleMatches.some(match => - fieldName.includes(match.toLowerCase()) || - fieldId.includes(match.toLowerCase()) || - fieldPlaceholder.includes(match.toLowerCase()) || - fieldLabel.includes(match.toLowerCase()) - ) - ) { - // Get the corresponding profile value - const profileValue = profile[profileKey as keyof UserProfile]; + fieldsFilled += tryAutofillField(inputField, profile) ? 1 : 0; + }); + + return fieldsFilled; +} + +/** + * Try to autofill a single field + */ +function tryAutofillField(field: HTMLInputElement | HTMLTextAreaElement, profile: UserProfile): boolean { + // Check field attributes (name, id, placeholder, etc.) for matches + const fieldName = field.name?.toLowerCase() || ''; + const fieldId = field.id?.toLowerCase() || ''; + const fieldPlaceholder = field.placeholder?.toLowerCase() || ''; + const fieldLabel = getFieldLabel(field)?.toLowerCase() || ''; + const fieldAriaLabel = field.getAttribute('aria-label')?.toLowerCase() || ''; + + console.log(`Checking field: name=${fieldName}, id=${fieldId}, placeholder=${fieldPlaceholder}`); + + // Try to match field with profile data + for (const [profileKey, possibleMatches] of Object.entries(FIELD_MAPPING)) { + if ( + possibleMatches.some(match => + fieldName.includes(match.toLowerCase()) || + fieldId.includes(match.toLowerCase()) || + fieldPlaceholder.includes(match.toLowerCase()) || + fieldLabel.includes(match.toLowerCase()) || + fieldAriaLabel.includes(match.toLowerCase()) + ) + ) { + // Get the corresponding profile value + const profileValue = profile[profileKey as keyof UserProfile]; + + // Only fill if we have a value and the field is empty or we're overwriting + if (profileValue && !field.value) { + field.value = profileValue; - // Only fill if we have a value and the field is empty - if (profileValue && !inputField.value) { - inputField.value = profileValue; - - // Trigger input event to notify the form of the change - const event = new Event('input', { bubbles: true }); - inputField.dispatchEvent(event); - - fieldsFilled++; - console.log(`Filled field ${fieldName || fieldId} with ${profileKey}`); - } + // Trigger input event to notify the form of the change + const inputEvent = new Event('input', { bubbles: true }); + field.dispatchEvent(inputEvent); + + // Also trigger change event + const changeEvent = new Event('change', { bubbles: true }); + field.dispatchEvent(changeEvent); - // Break the loop once we've found a match - break; + console.log(`Filled field ${fieldName || fieldId} with ${profileKey}: ${profileValue}`); + return true; } } - }); + } - return fieldsFilled; + return false; } /** @@ -121,48 +250,83 @@ function autofillTextareaFields(formElement: Element, profile: UserProfile): num // Find all textarea fields const textareaFields = formElement.querySelectorAll('textarea'); + console.log(`Found ${textareaFields.length} textarea fields`); textareaFields.forEach((field) => { const textareaField = field as HTMLTextAreaElement; - - // Check field attributes (name, id, placeholder, etc.) for matches - const fieldName = textareaField.name?.toLowerCase() || ''; - const fieldId = textareaField.id?.toLowerCase() || ''; - const fieldPlaceholder = textareaField.placeholder?.toLowerCase() || ''; - const fieldLabel = getFieldLabel(textareaField)?.toLowerCase() || ''; - - // Try to match field with profile data - for (const [profileKey, possibleMatches] of Object.entries(FIELD_MAPPING)) { - if ( - possibleMatches.some(match => - fieldName.includes(match.toLowerCase()) || - fieldId.includes(match.toLowerCase()) || - fieldPlaceholder.includes(match.toLowerCase()) || - fieldLabel.includes(match.toLowerCase()) - ) - ) { - // Get the corresponding profile value - const profileValue = profile[profileKey as keyof UserProfile]; + fieldsFilled += tryAutofillField(textareaField, profile) ? 1 : 0; + }); + + return fieldsFilled; +} + +/** + * Autofill select fields in a form + */ +function autofillSelectFields(formElement: Element, profile: UserProfile): number { + let fieldsFilled = 0; + + // Find all select fields + const selectFields = formElement.querySelectorAll('select'); + console.log(`Found ${selectFields.length} select fields`); + + selectFields.forEach((field) => { + const selectField = field as HTMLSelectElement; + fieldsFilled += tryAutofillSelectField(selectField, profile) ? 1 : 0; + }); + + return fieldsFilled; +} + +/** + * Try to autofill a select field + */ +function tryAutofillSelectField(field: HTMLSelectElement, profile: UserProfile): boolean { + // Check field attributes (name, id, etc.) for matches + const fieldName = field.name?.toLowerCase() || ''; + const fieldId = field.id?.toLowerCase() || ''; + const fieldLabel = getFieldLabel(field)?.toLowerCase() || ''; + const fieldAriaLabel = field.getAttribute('aria-label')?.toLowerCase() || ''; + + console.log(`Checking select field: name=${fieldName}, id=${fieldId}`); + + // Try to match field with profile data + for (const [profileKey, possibleMatches] of Object.entries(FIELD_MAPPING)) { + if ( + possibleMatches.some(match => + fieldName.includes(match.toLowerCase()) || + fieldId.includes(match.toLowerCase()) || + fieldLabel.includes(match.toLowerCase()) || + fieldAriaLabel.includes(match.toLowerCase()) + ) + ) { + // Get the corresponding profile value + const profileValue = profile[profileKey as keyof UserProfile]; + + // Only proceed if we have a value + if (!profileValue) continue; + + // Try to find a matching option + const options = Array.from(field.options); + const matchingOption = options.find(option => + option.text.toLowerCase().includes(profileValue.toLowerCase()) || + option.value.toLowerCase().includes(profileValue.toLowerCase()) + ); + + if (matchingOption) { + field.value = matchingOption.value; - // Only fill if we have a value and the field is empty - if (profileValue && !textareaField.value) { - textareaField.value = profileValue; - - // Trigger input event to notify the form of the change - const event = new Event('input', { bubbles: true }); - textareaField.dispatchEvent(event); - - fieldsFilled++; - console.log(`Filled textarea ${fieldName || fieldId} with ${profileKey}`); - } + // Trigger change event + const event = new Event('change', { bubbles: true }); + field.dispatchEvent(event); - // Break the loop once we've found a match - break; + console.log(`Filled select ${fieldName || fieldId} with ${profileKey}: ${matchingOption.value}`); + return true; } } - }); + } - return fieldsFilled; + return false; } /** @@ -177,6 +341,7 @@ function autofillResumeFields(formElement: Element, profile: UserProfile): numbe // Find all file input fields const fileInputs = formElement.querySelectorAll('input[type="file"]'); + console.log(`Found ${fileInputs.length} file input fields`); fileInputs.forEach((field) => { const fileInput = field as HTMLInputElement; @@ -186,13 +351,20 @@ function autofillResumeFields(formElement: Element, profile: UserProfile): numbe const fieldId = fileInput.id?.toLowerCase() || ''; const fieldAccept = fileInput.accept?.toLowerCase() || ''; const fieldLabel = getFieldLabel(fileInput)?.toLowerCase() || ''; + const fieldAriaLabel = fileInput.getAttribute('aria-label')?.toLowerCase() || ''; + + console.log(`Checking file field: name=${fieldName}, id=${fieldId}, accept=${fieldAccept}`); const isResumeField = RESUME_FIELD_PATTERNS.some(pattern => fieldName.includes(pattern) || fieldId.includes(pattern) || - fieldLabel.includes(pattern) + fieldLabel.includes(pattern) || + fieldAriaLabel.includes(pattern) ); + // If no specific resume fields found, try any file input as a fallback + const useAsGenericFileUpload = fileInputs.length === 1; + // At this point we know resumeFileType is defined because of the guard at the start of the function const fileType = profile.resumeFileType as string; const fileExtension = fileType.split('/')[1] || ''; @@ -202,7 +374,7 @@ function autofillResumeFields(formElement: Element, profile: UserProfile): numbe fieldAccept.includes(fileExtension) || fieldAccept.includes(fileType); - if (isResumeField && acceptsFileType) { + if ((isResumeField || useAsGenericFileUpload) && acceptsFileType) { try { // We know these are defined due to the guard at the start of the function const resumeData = profile.resumeData as string; @@ -287,12 +459,12 @@ function tryClickUploadButton(fileInput: HTMLInputElement): void { /** * Get the label text for a form field */ -function getFieldLabel(field: HTMLElement): string | null { +function getFieldLabel(field: HTMLElement): string { // Try to find label by for attribute if (field.id) { const label = document.querySelector(`label[for="${field.id}"]`); - if (label) { - return label.textContent?.trim() || null; + if (label && label instanceof HTMLElement) { + return label.textContent?.trim() || ''; } } @@ -300,17 +472,136 @@ function getFieldLabel(field: HTMLElement): string | null { let parent = field.parentElement; while (parent) { if (parent.tagName === 'LABEL') { - return parent.textContent?.trim() || null; + return parent.textContent?.trim() || ''; } // Look for a label within the parent const labels = parent.querySelectorAll('label'); - if (labels.length === 1) { - return labels[0].textContent?.trim() || null; + if (labels.length === 1 && labels[0] instanceof HTMLElement) { + return labels[0].textContent?.trim() || ''; } parent = parent.parentElement; } - return null; + return ''; +} + +/** + * Detects essay questions in a form and returns them with their associated input fields + */ +export async function detectAndFillEssayQuestions(formElement: Element): Promise { + console.log('Detecting essay questions in form'); + let filledCount = 0; + + // Look for textareas with labels that might be essay questions + const textareas = formElement.querySelectorAll('textarea'); + console.log(`Found ${textareas.length} textarea fields to check for essay questions`); + + for (const textarea of Array.from(textareas)) { + const textareaElement = textarea as HTMLTextAreaElement; + + // Skip if already filled + if (textareaElement.value) { + console.log('Skipping already filled textarea'); + continue; + } + + // Get the label or placeholder text that might contain the question + const labelText = getFieldLabel(textareaElement); + const placeholderText = textareaElement.placeholder || ''; + const ariaLabel = textareaElement.getAttribute('aria-label') || ''; + + // Combine possible sources of question text + const possibleQuestionText = `${labelText} ${placeholderText} ${ariaLabel}`.toLowerCase(); + + // Check if this contains question patterns + const isLikelyQuestion = ESSAY_QUESTION_PATTERNS.some(pattern => + possibleQuestionText.includes(pattern) + ); + + // Additional check: typically essay questions have larger textareas + const isLargeTextarea = textareaElement.rows > 2 || textareaElement.cols > 40; + + if ((isLikelyQuestion || isLargeTextarea) && possibleQuestionText.length > 10) { + console.log('Detected potential essay question:', possibleQuestionText); + + try { + // Extract a cleaner version of the question + const questionText = labelText || placeholderText || ariaLabel || 'General application question'; + + // Get page context (such as job title and description if available) + const pageContext = extractPageContext(); + + // Get user profile + const userProfile = await getUserProfile(); + + // Generate AI response + const response = await generateAIResponse(questionText, pageContext, userProfile); + + // Fill the textarea with the response + textareaElement.value = response; + + // Dispatch events to notify the form + textareaElement.dispatchEvent(new Event('input', { bubbles: true })); + textareaElement.dispatchEvent(new Event('change', { bubbles: true })); + + console.log('Filled essay question with AI response'); + filledCount++; + } catch (error) { + console.error('Error generating AI response for essay question:', error); + } + } + } + + return filledCount; +} + +/** + * Extract context from the current page that might be relevant for generating responses + */ +function extractPageContext(): string { + let context = ''; + + try { + // Try to extract job title + const possibleTitleElements = document.querySelectorAll('h1, h2, .job-title, [class*="title"], [class*="position"]'); + for (const element of Array.from(possibleTitleElements)) { + if (element instanceof HTMLElement) { + const text = element.textContent?.trim(); + if (text && text.length > 5 && text.length < 100) { + context += `Job Title: ${text}\n\n`; + break; + } + } + } + + // Try to extract job description + const possibleDescElements = document.querySelectorAll('.job-description, [class*="description"], [class*="details"], [class*="about"]'); + for (const element of Array.from(possibleDescElements)) { + if (element instanceof HTMLElement) { + const text = element.textContent?.trim(); + if (text && text.length > 100) { + context += `Job Description: ${text.substring(0, 500)}...\n\n`; + break; + } + } + } + + // Try to extract company name + const possibleCompanyElements = document.querySelectorAll('.company-name, [class*="company"], [class*="organization"], [class*="employer"]'); + for (const element of Array.from(possibleCompanyElements)) { + if (element instanceof HTMLElement) { + const text = element.textContent?.trim(); + if (text && text.length > 2 && text.length < 50) { + context += `Company: ${text}\n\n`; + break; + } + } + } + } catch (error) { + console.error('Error extracting page context:', error); + } + + return context || 'No additional context available from the page.'; } \ No newline at end of file diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 2532c9c..b317b35 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -1,15 +1,17 @@ -import { Options, StorageData, UserProfile, JobFormData, RecentJobForms } from '../types'; +import { Options, StorageData, UserProfile, JobFormData, RecentJobForms, AIPrompt, AIResponsesData } from '../types'; const STORAGE_KEYS = { OPTIONS: 'options', USER_PROFILE: 'userProfile', - JOB_FORMS: 'jobForms' + JOB_FORMS: 'jobForms', + AI_RESPONSES: 'aiResponses' }; // Default options const DEFAULT_OPTIONS: Options = { enabled: true, - theme: 'light' + theme: 'light', + openaiApiKey: '' }; // Default empty user profile @@ -27,6 +29,20 @@ const DEFAULT_USER_PROFILE: UserProfile = { yearsOfExperience: '', education: '', skills: '', + // Education details + degree: '', + discipline: '', + school: '', + educationStartYear: '', + educationStartMonth: '', + educationEndYear: '', + educationEndMonth: '', + // Personal details + gender: '', + // Self-identification + hispanicLatino: '', + veteranStatus: '', + disabilityStatus: '', resumeData: undefined, resumeFileName: undefined, resumeFileType: undefined @@ -144,4 +160,51 @@ export async function cleanupOldJobFormData(): Promise { }); }); }); +} + +/** + * Saves an AI-assisted response to Chrome storage + */ +export async function saveAIResponse(questionId: string, prompt: AIPrompt): Promise { + return new Promise((resolve) => { + chrome.storage.sync.get([STORAGE_KEYS.AI_RESPONSES], (result) => { + const aiResponses = result[STORAGE_KEYS.AI_RESPONSES] as AIResponsesData || {}; + + // Update with new data + aiResponses[questionId] = prompt; + + // Save back to storage + chrome.storage.sync.set({ [STORAGE_KEYS.AI_RESPONSES]: aiResponses }, () => { + console.log('AI response saved for:', questionId); + resolve(); + }); + }); + }); +} + +/** + * Gets all saved AI-assisted responses + */ +export async function getAllAIResponses(): Promise { + return new Promise((resolve) => { + chrome.storage.sync.get([STORAGE_KEYS.AI_RESPONSES], (result) => { + const aiResponses = result[STORAGE_KEYS.AI_RESPONSES] as AIResponsesData || {}; + console.log('AI responses loaded:', aiResponses); + resolve(aiResponses); + }); + }); +} + +/** + * Gets a specific AI-assisted response by question ID + */ +export async function getAIResponse(questionId: string): Promise { + return new Promise((resolve) => { + chrome.storage.sync.get([STORAGE_KEYS.AI_RESPONSES], (result) => { + const aiResponses = result[STORAGE_KEYS.AI_RESPONSES] as AIResponsesData || {}; + const response = aiResponses[questionId] || null; + console.log('AI response loaded for:', questionId, response); + resolve(response); + }); + }); } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 15c111f..c751fb2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,9 @@ export interface Options { enabled: boolean; theme: string; + openaiApiKey?: string; + apiKey?: string; // Generic API key that can be used for any AI service + aiService?: 'openai' | 'deepseek'; // Type of AI service to use } export interface StorageData { @@ -34,6 +37,20 @@ export interface UserProfile { yearsOfExperience: string; education: string; skills: string; + // Education details + degree: string; + discipline: string; + school: string; + educationStartYear: string; + educationStartMonth: string; + educationEndYear: string; + educationEndMonth: string; + // Personal details + gender: string; + // Self-identification + hispanicLatino: string; + veteranStatus: string; + disabilityStatus: string; resumeData?: string; // Base64 encoded resume file resumeFileName?: string; // Original file name resumeFileType?: string; // MIME type of the resume @@ -59,4 +76,15 @@ export interface Message { export interface ResponseMessage { success: boolean; [key: string]: any; +} + +// Types for AI-assisted responses +export interface AIPrompt { + question: string; + context: string; + response?: string; +} + +export interface AIResponsesData { + [questionId: string]: AIPrompt; } \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 639bf2e..b430419 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,4 +1,6 @@ const path = require('path'); +const webpack = require('webpack'); +const Dotenv = require('dotenv-webpack'); module.exports = { mode: 'development', @@ -24,4 +26,17 @@ module.exports = { }, ], }, + plugins: [ + // Load environment variables from .env files + new Dotenv({ + path: '.env', // Path to .env file (default) + safe: true, // Load .env.example as a fallback (optional) + systemvars: true, // Load system environment variables as well + defaults: false, // Don't load .env.defaults + }), + // Define process.env if it doesn't exist in the client + new webpack.DefinePlugin({ + 'process.env': JSON.stringify(process.env), + }), + ], }; \ No newline at end of file