diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..af2b427 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,31 @@ +{ + "env": { + "browser": true, + "es2022": true, + "node": true, + "webextensions": true + }, + "extends": [ + "eslint:recommended" + ], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "rules": { + "no-console": ["warn", { "allow": ["warn", "error"] }], + "no-debugger": "error", + "no-eval": "error", + "no-implied-eval": "error", + "no-new-func": "error", + "prefer-const": "error", + "no-var": "error", + "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "node_modules/**", + "coverage/**" + ] +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa29215 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,185 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, feature/* ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run tests + run: npm run test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + build: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 18.x + uses: actions/setup-node@v4 + with: + node-version: 18.x + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build extension + run: npm run build:extension + + - name: Build web demo + run: npm run build:web + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: build-files + path: | + dist/ + web/ + manifest.json + + e2e-test: + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 18.x + uses: actions/setup-node@v4 + with: + node-version: 18.x + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install + + - name: Download build artifacts + uses: actions/download-artifact@v3 + with: + name: build-files + + - name: Run E2E tests + run: npm run test:e2e + + deploy: + runs-on: ubuntu-latest + needs: [test, build, e2e-test] + if: github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Vercel + uses: amondnet/vercel-action@v25 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + vercel-args: '--prod' + working-directory: ./ + + release: + runs-on: ubuntu-latest + needs: [test, build, e2e-test] + if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, 'release:') + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Use Node.js 18.x + uses: actions/setup-node@v4 + with: + node-version: 18.x + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build extension + run: npm run build:extension + + - name: Create extension package + run: | + mkdir -p release + cp -r dist/* release/ + cp manifest.json release/ + cp -r assets release/ || true + cd release + zip -r ../genai-browser-tool-extension.zip * + + - name: Extract version from commit message + id: version + run: echo "version=$(echo '${{ github.event.head_commit.message }}' | grep -oP 'release: v\K[0-9]+\.[0-9]+\.[0-9]+')" >> $GITHUB_OUTPUT + + - name: Create Release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ steps.version.outputs.version }} + release_name: GenAI Browser Tool v${{ steps.version.outputs.version }} + body: | + ## What's New + + - Modern architecture with improved performance + - Enhanced AI provider support + - Better error handling and validation + - Improved user interface + + ## Installation + + 1. Download the extension zip file + 2. Extract to a folder + 3. Open Chrome extensions (chrome://extensions) + 4. Enable Developer Mode + 5. Click "Load unpacked" and select the extracted folder + draft: false + prerelease: false + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./genai-browser-tool-extension.zip + asset_name: genai-browser-tool-extension.zip + asset_content_type: application/zip \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..213b74e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": true, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css" +} \ No newline at end of file diff --git a/manifest.json b/manifest.json index 6bf7e06..7a7f6db 100644 --- a/manifest.json +++ b/manifest.json @@ -1,92 +1,113 @@ { "manifest_version": 3, - "name": "GenAI Browser Assistant - Next-Gen AI Tool", - "version": "4.0.0", - "description": "Advanced AI-powered browser extension with intelligent summarization, Q&A, translation, sentiment analysis, content extraction, and smart productivity features.", + "name": "GenAI Browser Tool - Advanced AI Assistant", + "version": "4.1.0", + "description": "Advanced AI-powered browser extension with multi-provider support, intelligent summarization, contextual Q&A, translation, and content analysis", + "author": "Aaron Sequeira ", + "permissions": [ "storage", "activeTab", + "contextMenus", "scripting", "offscreen", - "contextMenus", - "notifications", - "clipboardWrite", - "clipboardRead", - "unlimitedStorage", - "alarms" + "notifications" ], + "optional_permissions": [ + "tabs", "bookmarks", - "history", - "tabs" + "history" ], + "host_permissions": [ - "" + "https://api.openai.com/*", + "https://api.anthropic.com/*", + "https://generativelanguage.googleapis.com/*", + "https://api.cohere.ai/*", + "https://api.huggingface.co/*" ], - "action": { - "default_popup": "popup.html", - "default_icon": { - "16": "icons/icon16.png", - "32": "icons/icon32.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - } - }, - "icons": { - "16": "icons/icon16.png", - "32": "icons/icon32.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - }, + "background": { - "service_worker": "background.js", + "service_worker": "src/background/background-service.js", "type": "module" }, + "content_scripts": [ { "matches": [""], - "js": ["content-scripts/content-main.js"], - "css": ["styles/content-overlay.css"], + "exclude_matches": [ + "chrome://*/*", + "chrome-extension://*/*", + "moz-extension://*/*", + "edge://*/*" + ], + "js": ["src/content/content-manager.js"], + "css": ["src/styles/content-styles.css"], "run_at": "document_end" } ], - "options_page": "options.html", + + "action": { + "default_popup": "src/popup/popup.html", + "default_title": "GenAI Browser Tool", + "default_icon": { + "16": "assets/icons/icon-16.png", + "32": "assets/icons/icon-32.png", + "48": "assets/icons/icon-48.png", + "128": "assets/icons/icon-128.png" + } + }, + + "icons": { + "16": "assets/icons/icon-16.png", + "32": "assets/icons/icon-32.png", + "48": "assets/icons/icon-48.png", + "128": "assets/icons/icon-128.png" + }, + + "options_page": "src/pages/options.html", + "web_accessible_resources": [ { "resources": [ - "offscreen.html", - "styles/*", - "assets/*" + "src/ui/*", + "assets/*", + "src/styles/*", + "src/popup/*" ], "matches": [""] } ], + "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'; connect-src https://*.openai.com https://*.anthropic.com https://*.googleapis.com https://api.gemini.google.com https://api.cohere.ai https://api.huggingface.co;" + "extension_pages": "script-src 'self'; object-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.openai.com https://api.anthropic.com https://generativelanguage.googleapis.com;" }, + "commands": { - "summarize_page": { + "toggle-popup": { + "suggested_key": { + "default": "Ctrl+Shift+G", + "mac": "Command+Shift+G" + }, + "description": "Toggle GenAI popup" + }, + "quick-summarize": { "suggested_key": { "default": "Ctrl+Shift+S", "mac": "Command+Shift+S" }, - "description": "Summarize current page" + "description": "Quick page summarization" }, - "toggle_popup": { + "analyze-selection": { "suggested_key": { "default": "Ctrl+Shift+A", "mac": "Command+Shift+A" }, - "description": "Toggle AI assistant popup" - }, - "quick_question": { - "suggested_key": { - "default": "Ctrl+Shift+Q", - "mac": "Command+Shift+Q" - }, - "description": "Quick question about page" + "description": "Analyze selected text" } }, + "incognito": "spanning", - "minimum_chrome_version": "121" -} + "minimum_chrome_version": "88" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f5cb12 --- /dev/null +++ b/package.json @@ -0,0 +1,111 @@ +{ + "name": "genai-browser-tool", + "version": "4.0.1", + "description": "Advanced AI-powered browser extension with intelligent summarization, Q&A, translation, sentiment analysis, content extraction, and smart productivity features.", + "type": "module", + "scripts": { + "build": "npm run build:extension && npm run build:web", + "build:extension": "rollup -c rollup.extension.config.js", + "build:web": "vite build", + "dev": "vite dev", + "dev:extension": "rollup -c rollup.extension.config.js --watch", + "test": "vitest", + "test:e2e": "playwright test", + "test:coverage": "vitest --coverage", + "lint": "eslint . --ext .js,.ts,.json --fix", + "format": "prettier --write \"**/*.{js,ts,json,css,html,md}\"", + "typecheck": "tsc --noEmit", + "prepare": "husky install", + "deploy:vercel": "vercel --prod", + "deploy:railway": "railway deploy", + "deploy:render": "render-deploy" + }, + "keywords": [ + "ai", + "browser-extension", + "summarization", + "question-answering", + "translation", + "productivity", + "openai", + "claude", + "gemini" + ], + "author": "Aaron Sequeira ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/aaron-seq/GenAI-Browser-Tool.git" + }, + "bugs": { + "url": "https://github.com/aaron-seq/GenAI-Browser-Tool/issues" + }, + "homepage": "https://github.com/aaron-seq/GenAI-Browser-Tool#readme", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.40.1", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.0.1", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^11.1.5", + "@types/chrome": "^0.0.251", + "@types/node": "^20.10.4", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^1.0.4", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-security": "^1.7.1", + "husky": "^8.0.3", + "jsdom": "^23.0.1", + "lint-staged": "^15.2.0", + "postcss": "^8.4.32", + "prettier": "^3.1.1", + "rollup": "^4.7.0", + "tailwindcss": "^3.3.6", + "typescript": "^5.3.3", + "vite": "^5.0.8", + "vite-plugin-pwa": "^0.17.4", + "vitest": "^1.0.4" + }, + "dependencies": { + "@ai-sdk/anthropic": "^0.0.11", + "@ai-sdk/openai": "^0.0.13", + "@google-ai/generativelanguage": "^2.5.0", + "ai": "^2.2.29", + "dompurify": "^3.0.7", + "idb": "^7.1.1", + "marked": "^11.1.1", + "validator": "^13.11.0", + "zod": "^3.22.4" + }, + "lint-staged": { + "*.{js,ts}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,css,html,md}": [ + "prettier --write" + ] + }, + "browserslist": [ + "Chrome >= 88", + "Firefox >= 78", + "Edge >= 88" + ], + "files": [ + "dist/**/*", + "src/**/*", + "manifest.json", + "README.md", + "LICENSE" + ] +} \ No newline at end of file diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..872e7b0 --- /dev/null +++ b/railway.toml @@ -0,0 +1,18 @@ +[build] +builder = "nixpacks" +buildCommand = "npm run build:web" +watchPatterns = ["web/**"] + +[deploy] +startCommand = "npm start" +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 3 + +[env] +NODE_ENV = "production" +PORT = "3000" + +[healthcheck] +path = "/health" +intervalSeconds = 30 +timeoutSeconds = 10 \ No newline at end of file diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..048ac0c --- /dev/null +++ b/render.yaml @@ -0,0 +1,23 @@ +services: + - type: web + name: genai-browser-tool + env: node + plan: free + buildCommand: npm run build:web + startCommand: npm run serve + envVars: + - key: NODE_ENV + value: production + - key: PORT + value: 3000 + healthCheckPath: /health + autoDeploy: true + buildFilter: + paths: + - web/** + - package.json + - package-lock.json + ignoredPaths: + - src/** + - tests/** + - README.md \ No newline at end of file diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..406c237 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,54 @@ +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import { terser } from 'rollup-plugin-terser'; +import json from '@rollup/plugin-json'; + +const isProduction = process.env.NODE_ENV === 'production'; + +const plugins = [ + resolve({ + browser: true, + preferBuiltins: false + }), + commonjs(), + json() +]; + +if (isProduction) { + plugins.push(terser({ + compress: { + drop_console: true, + drop_debugger: true + } + })); +} + +export default [ + { + input: 'src/popup/popup-manager.js', + output: { + file: 'dist/popup.js', + format: 'iife', + sourcemap: !isProduction + }, + plugins + }, + { + input: 'src/background/background-service.js', + output: { + file: 'dist/background.js', + format: 'es', + sourcemap: !isProduction + }, + plugins + }, + { + input: 'src/content/content-manager.js', + output: { + file: 'dist/content.js', + format: 'iife', + sourcemap: !isProduction + }, + plugins + } +]; \ No newline at end of file diff --git a/src/popup/popup-manager.js b/src/popup/popup-manager.js new file mode 100644 index 0000000..bfec651 --- /dev/null +++ b/src/popup/popup-manager.js @@ -0,0 +1,437 @@ +/** + * @fileoverview Main popup manager for GenAI Browser Tool extension + * @author Aaron Sequeira + * @version 4.0.1 + */ + +import { EventManager } from '../utils/event-manager.js'; +import { ErrorHandler } from '../utils/error-handler.js'; +import { UIRenderer } from '../ui/ui-renderer.js'; +import { ContentExtractor } from '../services/content-extractor.js'; +import { AIService } from '../services/ai-service.js'; +import { StorageManager } from '../services/storage-manager.js'; +import { ValidationService } from '../utils/validation-service.js'; + +/** + * Manages the popup interface and user interactions + * Provides a clean, performant interface for AI-powered browser features + */ +export class PopupManager { + constructor() { + this.eventManager = new EventManager(); + this.errorHandler = new ErrorHandler('PopupManager'); + this.uiRenderer = new UIRenderer(); + this.contentExtractor = new ContentExtractor(); + this.aiService = new AIService(); + this.storageManager = new StorageManager(); + this.validationService = new ValidationService(); + + this.currentPageContent = null; + this.isInitialized = false; + this.conversationHistory = []; + this.activeOperations = new Set(); + + this.initializePopup(); + } + + /** + * Initialize the popup with error handling and graceful degradation + */ + async initializePopup() { + try { + this.showLoadingState('Initializing GenAI Assistant...'); + + await this.setupEventListeners(); + await this.loadUserPreferences(); + await this.extractCurrentPageContent(); + await this.restoreSessionState(); + + this.isInitialized = true; + this.hideLoadingState(); + this.showSuccessMessage('Ready to assist!'); + } catch (error) { + this.errorHandler.handleError(error, 'Failed to initialize popup'); + this.showErrorState('Failed to initialize. Please refresh and try again.'); + } + } + + /** + * Set up all event listeners with proper error boundaries + */ + async setupEventListeners() { + const eventBindings = [ + { selector: '#summarize-page-button', event: 'click', handler: this.handlePageSummarization }, + { selector: '#ask-question-button', event: 'click', handler: this.handleQuestionSubmission }, + { selector: '#translate-content-button', event: 'click', handler: this.handleContentTranslation }, + { selector: '#analyze-sentiment-button', event: 'click', handler: this.handleSentimentAnalysis }, + { selector: '#export-results-button', event: 'click', handler: this.handleResultsExport }, + { selector: '#question-input', event: 'keypress', handler: this.handleQuestionInputKeypress }, + { selector: '.tab-navigation-button', event: 'click', handler: this.handleTabSwitch }, + { selector: '.settings-toggle', event: 'click', handler: this.handleSettingsToggle } + ]; + + eventBindings.forEach(({ selector, event, handler }) => { + this.eventManager.addEventListenerSafely( + selector, + event, + handler.bind(this), + { passive: false, once: false } + ); + }); + } + + /** + * Extract content from the current page with fallback handling + */ + async extractCurrentPageContent() { + try { + const [activeTab] = await chrome.tabs.query({ + active: true, + currentWindow: true + }); + + if (!this.validationService.isValidWebPage(activeTab)) { + this.showWarningMessage('Cannot access this page type'); + return; + } + + const contentResponse = await chrome.tabs.sendMessage(activeTab.id, { + actionType: 'EXTRACT_PAGE_CONTENT', + requestId: this.generateRequestId(), + options: { + includeMetadata: true, + includeImages: false, + maxContentLength: 50000 + } + }); + + if (contentResponse?.success) { + this.currentPageContent = contentResponse.data; + this.updatePageInfoDisplay(activeTab, this.currentPageContent); + } else { + throw new Error(contentResponse?.error || 'Content extraction failed'); + } + } catch (error) { + this.errorHandler.handleError(error, 'Page content extraction failed'); + this.showWarningMessage('Limited functionality - could not access page content'); + } + } + + /** + * Handle page summarization with multiple summary types + */ + async handlePageSummarization(event) { + const operationId = 'page-summarization'; + + if (this.activeOperations.has(operationId)) { + this.showWarningMessage('Summarization already in progress'); + return; + } + + try { + this.activeOperations.add(operationId); + this.showLoadingState('Generating intelligent summary...'); + + if (!this.currentPageContent?.mainTextContent) { + throw new Error('No content available to summarize'); + } + + const summaryOptions = this.getSummaryOptions(); + const summaryResponse = await this.aiService.generateContentSummary({ + content: this.currentPageContent.mainTextContent, + title: this.currentPageContent.pageTitle, + url: this.currentPageContent.pageUrl, + ...summaryOptions + }); + + if (summaryResponse.success) { + this.displaySummaryResults(summaryResponse.data); + await this.storageManager.saveSummaryToHistory(summaryResponse.data); + } else { + throw new Error(summaryResponse.error || 'Summarization failed'); + } + } catch (error) { + this.errorHandler.handleError(error, 'Page summarization failed'); + this.showErrorMessage('Failed to generate summary. Please try again.'); + } finally { + this.activeOperations.delete(operationId); + this.hideLoadingState(); + } + } + + /** + * Handle question submission with conversation context + */ + async handleQuestionSubmission(event) { + const questionInput = document.getElementById('question-input'); + const userQuestion = questionInput.value.trim(); + + if (!this.validationService.isValidQuestion(userQuestion)) { + this.showWarningMessage('Please enter a valid question'); + return; + } + + const operationId = 'question-answering'; + + try { + this.activeOperations.add(operationId); + questionInput.value = ''; + this.addConversationMessage('user', userQuestion); + this.showTypingIndicator(); + + const questionResponse = await this.aiService.answerContextualQuestion({ + question: userQuestion, + pageContent: this.currentPageContent, + conversationHistory: this.conversationHistory, + includeSourceReferences: true + }); + + if (questionResponse.success) { + this.addConversationMessage('assistant', questionResponse.data.answer); + this.conversationHistory.push({ + question: userQuestion, + answer: questionResponse.data.answer, + timestamp: Date.now() + }); + } else { + throw new Error(questionResponse.error || 'Failed to get answer'); + } + } catch (error) { + this.errorHandler.handleError(error, 'Question answering failed'); + this.addConversationMessage('assistant', 'I apologize, but I encountered an error while processing your question. Please try again.'); + } finally { + this.activeOperations.delete(operationId); + this.hideTypingIndicator(); + } + } + + /** + * Handle keyboard shortcuts for question input + */ + handleQuestionInputKeypress(event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.handleQuestionSubmission(event); + } + } + + /** + * Handle content translation with language detection + */ + async handleContentTranslation(event) { + const operationId = 'content-translation'; + + try { + this.activeOperations.add(operationId); + this.showLoadingState('Translating content...'); + + const selectedText = await this.getSelectedText(); + const contentToTranslate = selectedText || this.currentPageContent?.mainTextContent; + + if (!contentToTranslate) { + throw new Error('No content selected for translation'); + } + + const translationOptions = this.getTranslationOptions(); + const translationResponse = await this.aiService.translateContent({ + content: contentToTranslate, + ...translationOptions + }); + + if (translationResponse.success) { + this.displayTranslationResults(translationResponse.data); + } else { + throw new Error(translationResponse.error || 'Translation failed'); + } + } catch (error) { + this.errorHandler.handleError(error, 'Content translation failed'); + this.showErrorMessage('Translation failed. Please try again.'); + } finally { + this.activeOperations.delete(operationId); + this.hideLoadingState(); + } + } + + /** + * Get summary options from UI controls + */ + getSummaryOptions() { + const summaryTypeElement = document.getElementById('summary-type-select'); + const summaryLengthElement = document.querySelector('input[name="summary-length"]:checked'); + + return { + summaryType: summaryTypeElement?.value || 'key-points', + targetLength: summaryLengthElement?.value || 'medium', + includeKeyInsights: true, + includeSentiment: false + }; + } + + /** + * Get translation options from UI controls + */ + getTranslationOptions() { + const targetLanguageElement = document.getElementById('target-language-select'); + const preserveFormattingElement = document.getElementById('preserve-formatting-checkbox'); + + return { + targetLanguage: targetLanguageElement?.value || 'auto-detect', + preserveFormatting: preserveFormattingElement?.checked || true, + includeOriginal: true + }; + } + + /** + * Display summary results in the UI + */ + displaySummaryResults(summaryData) { + const summaryContainer = document.getElementById('summary-results-container'); + + summaryContainer.innerHTML = this.uiRenderer.renderSummaryCard({ + summary: summaryData.summary, + keyPoints: summaryData.keyPoints, + processingStats: summaryData.processingStats, + provider: summaryData.provider + }); + + this.animateElementAppearance(summaryContainer); + } + + /** + * Add a message to the conversation display + */ + addConversationMessage(role, content) { + const conversationContainer = document.getElementById('conversation-container'); + const messageElement = this.uiRenderer.createConversationMessage(role, content); + + conversationContainer.appendChild(messageElement); + this.scrollToBottom(conversationContainer); + this.animateElementAppearance(messageElement); + } + + /** + * Show loading state with custom message + */ + showLoadingState(message = 'Processing...') { + const loadingOverlay = document.getElementById('loading-overlay'); + const loadingText = document.getElementById('loading-text'); + + loadingText.textContent = message; + loadingOverlay.classList.add('active'); + } + + /** + * Hide loading state + */ + hideLoadingState() { + const loadingOverlay = document.getElementById('loading-overlay'); + loadingOverlay.classList.remove('active'); + } + + /** + * Show success message to user + */ + showSuccessMessage(message) { + this.showStatusMessage(message, 'success'); + } + + /** + * Show warning message to user + */ + showWarningMessage(message) { + this.showStatusMessage(message, 'warning'); + } + + /** + * Show error message to user + */ + showErrorMessage(message) { + this.showStatusMessage(message, 'error'); + } + + /** + * Show status message with type + */ + showStatusMessage(message, type = 'info') { + const statusContainer = document.getElementById('status-container'); + const statusMessage = this.uiRenderer.createStatusMessage(message, type); + + statusContainer.appendChild(statusMessage); + + setTimeout(() => { + statusMessage.classList.add('fade-out'); + setTimeout(() => statusMessage.remove(), 300); + }, 3000); + } + + /** + * Generate unique request ID for tracking + */ + generateRequestId() { + return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * Animate element appearance + */ + animateElementAppearance(element) { + element.style.opacity = '0'; + element.style.transform = 'translateY(10px)'; + + requestAnimationFrame(() => { + element.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + element.style.opacity = '1'; + element.style.transform = 'translateY(0)'; + }); + } + + /** + * Scroll element to bottom smoothly + */ + scrollToBottom(element) { + element.scrollTo({ + top: element.scrollHeight, + behavior: 'smooth' + }); + } + + /** + * Load user preferences from storage + */ + async loadUserPreferences() { + try { + const preferences = await this.storageManager.getUserPreferences(); + this.applyUserPreferences(preferences); + } catch (error) { + this.errorHandler.handleError(error, 'Failed to load user preferences'); + } + } + + /** + * Apply user preferences to the UI + */ + applyUserPreferences(preferences) { + if (preferences.theme) { + document.body.setAttribute('data-theme', preferences.theme); + } + + if (preferences.language) { + document.documentElement.setAttribute('lang', preferences.language); + } + } + + /** + * Cleanup resources when popup closes + */ + cleanup() { + this.eventManager.removeAllListeners(); + this.activeOperations.clear(); + } +} + +// Initialize popup when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => new PopupManager()); +} else { + new PopupManager(); +} \ No newline at end of file diff --git a/src/services/ai-service.js b/src/services/ai-service.js new file mode 100644 index 0000000..28e7a27 --- /dev/null +++ b/src/services/ai-service.js @@ -0,0 +1,503 @@ +/** + * @fileoverview AI service with multi-provider support for GenAI Browser Tool + * @author Aaron Sequeira + * @version 4.0.1 + */ + +import { ErrorHandler } from '../utils/error-handler.js'; +import { ValidationService } from '../utils/validation-service.js'; + +/** + * Modern AI service with support for multiple providers and intelligent fallbacks + */ +export class AIService { + constructor() { + this.errorHandler = new ErrorHandler('AIService'); + this.validationService = new ValidationService(); + this.providers = new Map(); + this.currentProvider = null; + this.requestCache = new Map(); + this.rateLimiter = new Map(); + + this.initializeProviders(); + } + + /** + * Initialize available AI providers + */ + async initializeProviders() { + try { + // Initialize providers based on available APIs and keys + await this.loadProviderConfigurations(); + this.currentProvider = await this.selectOptimalProvider(); + } catch (error) { + this.errorHandler.handleError(error, 'Provider initialization failed'); + } + } + + /** + * Generate content summary with intelligent processing + * @param {Object} summaryRequest - Summary request parameters + * @returns {Promise} Summary response + */ + async generateContentSummary(summaryRequest) { + try { + const validation = this.validationService.validateContentForSummarization( + summaryRequest.content + ); + + if (!validation.isValid) { + throw new Error(`Invalid content: ${validation.errors.join(', ')}`); + } + + const cacheKey = this.generateCacheKey('summary', summaryRequest); + const cachedResult = this.requestCache.get(cacheKey); + + if (cachedResult && !this.isCacheExpired(cachedResult.timestamp)) { + return { success: true, data: cachedResult.data, fromCache: true }; + } + + if (await this.isRateLimited('summary')) { + throw new Error('Rate limit exceeded. Please try again later.'); + } + + const summaryResponse = await this.executeWithRetry( + () => this.callSummaryProvider(summaryRequest), + 3 + ); + + // Cache successful results + this.requestCache.set(cacheKey, { + data: summaryResponse, + timestamp: Date.now() + }); + + this.updateRateLimit('summary'); + + return { success: true, data: summaryResponse }; + } catch (error) { + this.errorHandler.handleError(error, 'Content summarization failed'); + return { + success: false, + error: error.message || 'Failed to generate summary' + }; + } + } + + /** + * Answer contextual questions with conversation memory + * @param {Object} questionRequest - Question request parameters + * @returns {Promise} Answer response + */ + async answerContextualQuestion(questionRequest) { + try { + if (!this.validationService.isValidQuestion(questionRequest.question)) { + throw new Error('Invalid question format'); + } + + const cacheKey = this.generateCacheKey('question', questionRequest); + const cachedResult = this.requestCache.get(cacheKey); + + if (cachedResult && !this.isCacheExpired(cachedResult.timestamp)) { + return { success: true, data: cachedResult.data, fromCache: true }; + } + + if (await this.isRateLimited('question')) { + throw new Error('Rate limit exceeded. Please try again later.'); + } + + const answerResponse = await this.executeWithRetry( + () => this.callQuestionProvider(questionRequest), + 3 + ); + + this.requestCache.set(cacheKey, { + data: answerResponse, + timestamp: Date.now() + }); + + this.updateRateLimit('question'); + + return { success: true, data: answerResponse }; + } catch (error) { + this.errorHandler.handleError(error, 'Question answering failed'); + return { + success: false, + error: error.message || 'Failed to answer question' + }; + } + } + + /** + * Translate content with language detection + * @param {Object} translationRequest - Translation request parameters + * @returns {Promise} Translation response + */ + async translateContent(translationRequest) { + try { + const { content, targetLanguage = 'en', preserveFormatting = true } = translationRequest; + + if (!content || content.trim().length === 0) { + throw new Error('No content provided for translation'); + } + + if (await this.isRateLimited('translation')) { + throw new Error('Rate limit exceeded. Please try again later.'); + } + + const translationResponse = await this.executeWithRetry( + () => this.callTranslationProvider({ + content: content.trim(), + targetLanguage, + preserveFormatting + }), + 2 + ); + + this.updateRateLimit('translation'); + + return { success: true, data: translationResponse }; + } catch (error) { + this.errorHandler.handleError(error, 'Content translation failed'); + return { + success: false, + error: error.message || 'Failed to translate content' + }; + } + } + + /** + * Call summary provider with current configuration + * @param {Object} request - Summary request + * @returns {Promise} Summary response + */ + async callSummaryProvider(request) { + if (!this.currentProvider) { + throw new Error('No AI provider available'); + } + + const { content, summaryType = 'key-points', targetLength = 'medium' } = request; + + const prompt = this.buildSummaryPrompt(content, summaryType, targetLength); + + const response = await this.makeProviderRequest({ + prompt, + maxTokens: this.getMaxTokensForLength(targetLength), + temperature: 0.3 + }); + + return { + summary: response.text, + summaryType, + targetLength, + provider: this.currentProvider.name, + processingStats: { + inputLength: content.length, + outputLength: response.text.length, + compressionRatio: (response.text.length / content.length).toFixed(2) + } + }; + } + + /** + * Call question provider with context + * @param {Object} request - Question request + * @returns {Promise} Answer response + */ + async callQuestionProvider(request) { + const { question, pageContent, conversationHistory = [] } = request; + + const prompt = this.buildQuestionPrompt(question, pageContent, conversationHistory); + + const response = await this.makeProviderRequest({ + prompt, + maxTokens: 500, + temperature: 0.4 + }); + + return { + answer: response.text, + confidence: response.confidence || 0.9, + provider: this.currentProvider.name, + hasContext: !!pageContent + }; + } + + /** + * Call translation provider + * @param {Object} request - Translation request + * @returns {Promise} Translation response + */ + async callTranslationProvider(request) { + const { content, targetLanguage, preserveFormatting } = request; + + const prompt = this.buildTranslationPrompt(content, targetLanguage, preserveFormatting); + + const response = await this.makeProviderRequest({ + prompt, + maxTokens: Math.min(content.length * 2, 1000), + temperature: 0.2 + }); + + return { + originalText: content, + translatedText: response.text, + sourceLanguage: 'auto-detected', + targetLanguage, + confidence: response.confidence || 0.8, + provider: this.currentProvider.name + }; + } + + /** + * Make request to current AI provider + * @param {Object} requestParams - Request parameters + * @returns {Promise} Provider response + */ + async makeProviderRequest(requestParams) { + // This would interface with the actual AI provider APIs + // For now, return a mock response + return { + text: 'This is a sample AI response that would come from the selected provider.', + confidence: 0.9, + tokensUsed: 150 + }; + } + + /** + * Build summary prompt based on type and length + * @param {string} content - Content to summarize + * @param {string} type - Summary type + * @param {string} length - Target length + * @returns {string} Formatted prompt + */ + buildSummaryPrompt(content, type, length) { + const prompts = { + 'key-points': `Please provide a ${length} summary highlighting the key points from the following content:\n\n${content}\n\nKey points:`, + 'abstract': `Please create a ${length} abstract of the following content:\n\n${content}\n\nAbstract:`, + 'tldr': `Please provide a TL;DR (${length} version) of the following content:\n\n${content}\n\nTL;DR:` + }; + + return prompts[type] || prompts['key-points']; + } + + /** + * Build question prompt with context + * @param {string} question - User question + * @param {Object} pageContent - Page content for context + * @param {Array} history - Conversation history + * @returns {string} Formatted prompt + */ + buildQuestionPrompt(question, pageContent, history) { + let prompt = ''; + + if (pageContent?.mainTextContent) { + prompt += `Context from current page:\n${pageContent.mainTextContent.slice(0, 2000)}\n\n`; + } + + if (history.length > 0) { + prompt += 'Previous conversation:\n'; + history.slice(-3).forEach(item => { + prompt += `Q: ${item.question}\nA: ${item.answer}\n\n`; + }); + } + + prompt += `Question: ${question}\n\nAnswer:`; + + return prompt; + } + + /** + * Build translation prompt + * @param {string} content - Content to translate + * @param {string} targetLanguage - Target language + * @param {boolean} preserveFormatting - Whether to preserve formatting + * @returns {string} Formatted prompt + */ + buildTranslationPrompt(content, targetLanguage, preserveFormatting) { + const formatNote = preserveFormatting ? ' (preserve original formatting)' : ''; + return `Please translate the following text to ${targetLanguage}${formatNote}:\n\n${content}\n\nTranslation:`; + } + + /** + * Execute function with retry logic + * @param {Function} fn - Function to execute + * @param {number} maxRetries - Maximum retry attempts + * @returns {Promise} Function result + */ + async executeWithRetry(fn, maxRetries = 3) { + let lastError; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + if (attempt < maxRetries - 1) { + const delay = Math.pow(2, attempt) * 1000; // Exponential backoff + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + throw lastError; + } + + /** + * Load provider configurations from storage + */ + async loadProviderConfigurations() { + try { + // This would load from chrome.storage or other configuration source + const config = await this.getStoredConfiguration(); + + // Initialize available providers based on configuration + if (config.openaiKey) { + this.providers.set('openai', { + name: 'OpenAI', + type: 'openai', + apiKey: config.openaiKey, + available: true + }); + } + + if (config.anthropicKey) { + this.providers.set('anthropic', { + name: 'Claude', + type: 'anthropic', + apiKey: config.anthropicKey, + available: true + }); + } + } catch (error) { + console.warn('Failed to load provider configurations:', error); + } + } + + /** + * Select optimal provider based on availability and performance + * @returns {Object|null} Selected provider + */ + async selectOptimalProvider() { + const availableProviders = Array.from(this.providers.values()) + .filter(provider => provider.available); + + if (availableProviders.length === 0) { + return null; + } + + // For now, return the first available provider + // In the future, this could include performance metrics + return availableProviders[0]; + } + + /** + * Generate cache key for request + * @param {string} type - Request type + * @param {Object} request - Request object + * @returns {string} Cache key + */ + generateCacheKey(type, request) { + const keyData = { + type, + content: request.content?.slice(0, 100) || '', + question: request.question || '', + params: JSON.stringify({ + summaryType: request.summaryType, + targetLength: request.targetLength, + targetLanguage: request.targetLanguage + }) + }; + + return btoa(JSON.stringify(keyData)).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32); + } + + /** + * Check if cache entry is expired + * @param {number} timestamp - Cache timestamp + * @returns {boolean} True if expired + */ + isCacheExpired(timestamp) { + const cacheLifetime = 10 * 60 * 1000; // 10 minutes + return Date.now() - timestamp > cacheLifetime; + } + + /** + * Check if rate limited for operation type + * @param {string} operationType - Type of operation + * @returns {boolean} True if rate limited + */ + async isRateLimited(operationType) { + const limits = { + summary: { requests: 10, window: 60000 }, // 10 per minute + question: { requests: 20, window: 60000 }, // 20 per minute + translation: { requests: 15, window: 60000 } // 15 per minute + }; + + const limit = limits[operationType]; + if (!limit) return false; + + const now = Date.now(); + const requests = this.rateLimiter.get(operationType) || []; + + // Remove expired entries + const validRequests = requests.filter(time => now - time < limit.window); + + if (validRequests.length >= limit.requests) { + return true; + } + + return false; + } + + /** + * Update rate limit tracking + * @param {string} operationType - Type of operation + */ + updateRateLimit(operationType) { + const now = Date.now(); + const requests = this.rateLimiter.get(operationType) || []; + + requests.push(now); + this.rateLimiter.set(operationType, requests); + } + + /** + * Get stored configuration + * @returns {Promise} Configuration object + */ + async getStoredConfiguration() { + // Mock configuration - would interface with chrome.storage + return { + openaiKey: '', + anthropicKey: '', + preferredProvider: 'openai' + }; + } + + /** + * Get max tokens for summary length + * @param {string} length - Target length + * @returns {number} Max tokens + */ + getMaxTokensForLength(length) { + const tokenLimits = { + short: 150, + medium: 300, + long: 500, + detailed: 800 + }; + + return tokenLimits[length] || 300; + } + + /** + * Clean up resources + */ + destroy() { + this.requestCache.clear(); + this.rateLimiter.clear(); + this.providers.clear(); + } +} \ No newline at end of file diff --git a/src/ui/ui-renderer.js b/src/ui/ui-renderer.js new file mode 100644 index 0000000..446f9e4 --- /dev/null +++ b/src/ui/ui-renderer.js @@ -0,0 +1,82 @@ +/** + * @fileoverview UI rendering utilities for GenAI Browser Tool + * @author Aaron Sequeira + * @version 4.0.1 + */ + +export class UIRenderer { + constructor() { + this.theme = this.detectTheme(); + this.animationDuration = 300; + } + + renderSummaryCard(summaryData) { + const { summary, keyPoints = [], provider = 'AI' } = summaryData; + const keyPointsHtml = keyPoints.length > 0 + ? keyPoints.map(point => `
  • ${this.sanitizeHtml(point)}
  • `).join('') + : ''; + + return ` +
    +
    +

    Page Summary

    +
    +
    +
    ${this.formatText(summary)}
    + ${keyPointsHtml ? `
      ${keyPointsHtml}
    ` : ''} +
    +
    + +
    +
    + `; + } + + createConversationMessage(role, content) { + const messageElement = document.createElement('div'); + messageElement.className = `conversation-message ${role}-message`; + messageElement.innerHTML = ` +
    +
    ${this.formatText(content)}
    +
    ${this.formatTime(new Date())}
    + `; + return messageElement; + } + + createStatusMessage(message, type = 'info') { + const statusElement = document.createElement('div'); + statusElement.className = `status-message ${type}-message`; + statusElement.innerHTML = ` +
    +
    ${message}
    + + `; + return statusElement; + } + + formatText(text) { + if (!text) return ''; + return text + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/\n/g, '
    '); + } + + sanitizeHtml(html) { + const temp = document.createElement('div'); + temp.textContent = html; + return temp.innerHTML; + } + + escapeForAttribute(str) { + return str.replace(/"/g, '"').replace(/'/g, '''); + } + + formatTime(date) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + detectTheme() { + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } +} \ No newline at end of file diff --git a/src/utils/error-handler.js b/src/utils/error-handler.js new file mode 100644 index 0000000..f9b3623 --- /dev/null +++ b/src/utils/error-handler.js @@ -0,0 +1,392 @@ +/** + * @fileoverview Advanced error handling utility for GenAI Browser Tool + * @author Aaron Sequeira + * @version 4.0.1 + */ + +/** + * Enhanced error handler with logging, reporting, and recovery strategies + */ +export class ErrorHandler { + constructor(contextName = 'Unknown') { + this.contextName = contextName; + this.errorCount = 0; + this.errorHistory = []; + this.maxHistorySize = 50; + this.rateLimitMap = new Map(); + this.setupGlobalErrorHandling(); + } + + /** + * Handle errors with context and recovery options + * @param {Error} error - The error object + * @param {string} operationName - Name of the failed operation + * @param {Object} context - Additional context information + * @param {Object} options - Error handling options + */ + handleError(error, operationName = 'Unknown Operation', context = {}, options = {}) { + try { + const errorInfo = this.createErrorInfo(error, operationName, context); + + // Apply rate limiting to prevent spam + if (this.isRateLimited(errorInfo.signature)) { + return; + } + + this.logError(errorInfo); + this.updateErrorHistory(errorInfo); + + if (options.showToUser !== false) { + this.displayUserFriendlyError(errorInfo); + } + + if (options.reportError) { + this.reportError(errorInfo); + } + + // Attempt recovery if strategy provided + if (options.recoveryStrategy) { + this.attemptRecovery(options.recoveryStrategy, errorInfo); + } + + this.errorCount++; + } catch (handlerError) { + console.error('Error handler failed:', handlerError); + } + } + + /** + * Handle critical errors that may require immediate attention + * @param {Error} error - The critical error + * @param {string} operationName - Name of the failed operation + * @param {Object} context - Additional context + */ + handleCriticalError(error, operationName = 'Critical Operation', context = {}) { + const errorInfo = this.createErrorInfo(error, operationName, { + ...context, + severity: 'critical', + timestamp: Date.now() + }); + + console.error(`CRITICAL ERROR in ${this.contextName}:`, errorInfo); + + // Always show critical errors to user + this.displayCriticalErrorMessage(errorInfo); + + // Report critical errors immediately + this.reportError(errorInfo); + + // Store in persistent storage for debugging + this.storeCriticalError(errorInfo); + } + + /** + * Create comprehensive error information object + * @param {Error} error - The error object + * @param {string} operationName - Operation name + * @param {Object} context - Additional context + * @returns {Object} Error information object + */ + createErrorInfo(error, operationName, context) { + const errorInfo = { + message: error.message || 'Unknown error occurred', + name: error.name || 'UnknownError', + stack: error.stack, + operationName, + contextName: this.contextName, + timestamp: Date.now(), + errorId: this.generateErrorId(), + signature: this.generateErrorSignature(error, operationName), + severity: context.severity || 'error', + userAgent: navigator.userAgent, + url: window.location?.href || 'extension-popup', + additionalContext: context + }; + + return errorInfo; + } + + /** + * Generate unique error ID for tracking + * @returns {string} Unique error ID + */ + generateErrorId() { + return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + /** + * Generate error signature for deduplication + * @param {Error} error - The error object + * @param {string} operationName - Operation name + * @returns {string} Error signature + */ + generateErrorSignature(error, operationName) { + const message = error.message || ''; + const name = error.name || ''; + return `${this.contextName}:${operationName}:${name}:${message.slice(0, 100)}`; + } + + /** + * Check if error is rate limited + * @param {string} signature - Error signature + * @returns {boolean} True if rate limited + */ + isRateLimited(signature) { + const now = Date.now(); + const rateLimitWindow = 60000; // 1 minute + const maxErrorsPerWindow = 5; + + if (!this.rateLimitMap.has(signature)) { + this.rateLimitMap.set(signature, []); + } + + const errorTimes = this.rateLimitMap.get(signature); + + // Remove old entries outside the window + while (errorTimes.length > 0 && errorTimes[0] < now - rateLimitWindow) { + errorTimes.shift(); + } + + if (errorTimes.length >= maxErrorsPerWindow) { + return true; + } + + errorTimes.push(now); + return false; + } + + /** + * Log error to console with proper formatting + * @param {Object} errorInfo - Error information object + */ + logError(errorInfo) { + const logLevel = errorInfo.severity === 'critical' ? 'error' : 'warn'; + + console[logLevel](`[${this.contextName}] ${errorInfo.operationName} failed:`, { + message: errorInfo.message, + errorId: errorInfo.errorId, + timestamp: new Date(errorInfo.timestamp).toISOString(), + context: errorInfo.additionalContext + }); + + if (errorInfo.stack) { + console.groupCollapsed('Stack trace'); + console.error(errorInfo.stack); + console.groupEnd(); + } + } + + /** + * Update error history with size limit + * @param {Object} errorInfo - Error information object + */ + updateErrorHistory(errorInfo) { + this.errorHistory.push(errorInfo); + + if (this.errorHistory.length > this.maxHistorySize) { + this.errorHistory.shift(); + } + } + + /** + * Display user-friendly error message + * @param {Object} errorInfo - Error information object + */ + displayUserFriendlyError(errorInfo) { + const userMessage = this.createUserFriendlyMessage(errorInfo); + this.showNotification(userMessage, 'error'); + } + + /** + * Display critical error message to user + * @param {Object} errorInfo - Error information object + */ + displayCriticalErrorMessage(errorInfo) { + const message = `A critical error occurred in ${errorInfo.operationName}. Please refresh the extension and try again.`; + this.showNotification(message, 'critical'); + } + + /** + * Create user-friendly error message + * @param {Object} errorInfo - Error information object + * @returns {string} User-friendly message + */ + createUserFriendlyMessage(errorInfo) { + const messageMap = { + 'NetworkError': 'Network connection issue. Please check your internet connection.', + 'TypeError': 'An unexpected error occurred. Please try again.', + 'SecurityError': 'Security restriction encountered. Please check permissions.', + 'QuotaExceededError': 'Storage limit exceeded. Please clear some data.', + 'TimeoutError': 'Operation timed out. Please try again.', + }; + + return messageMap[errorInfo.name] || + `An error occurred in ${errorInfo.operationName}. Please try again.`; + } + + /** + * Show notification to user + * @param {string} message - Message to show + * @param {string} type - Notification type + */ + showNotification(message, type = 'error') { + // Try to use the browser's notification system + if (typeof chrome !== 'undefined' && chrome.notifications) { + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon48.png', + title: 'GenAI Browser Tool', + message: message + }); + } else { + // Fallback to console for testing environments + console.warn(`Notification (${type}): ${message}`); + } + } + + /** + * Report error to monitoring service (if configured) + * @param {Object} errorInfo - Error information object + */ + reportError(errorInfo) { + try { + // Store error for potential reporting + const reportData = { + errorId: errorInfo.errorId, + message: errorInfo.message, + context: errorInfo.contextName, + operation: errorInfo.operationName, + severity: errorInfo.severity, + timestamp: errorInfo.timestamp, + userAgent: errorInfo.userAgent.substring(0, 200), // Limit size + url: errorInfo.url + }; + + // Store in local storage for potential batch reporting + this.storeErrorReport(reportData); + } catch (reportError) { + console.error('Failed to report error:', reportError); + } + } + + /** + * Store error report in local storage + * @param {Object} reportData - Error report data + */ + storeErrorReport(reportData) { + try { + const storageKey = 'genai_error_reports'; + const existingReports = JSON.parse(localStorage.getItem(storageKey) || '[]'); + + existingReports.push(reportData); + + // Keep only last 20 reports + if (existingReports.length > 20) { + existingReports.splice(0, existingReports.length - 20); + } + + localStorage.setItem(storageKey, JSON.stringify(existingReports)); + } catch (storageError) { + console.error('Failed to store error report:', storageError); + } + } + + /** + * Store critical error in persistent storage + * @param {Object} errorInfo - Error information object + */ + storeCriticalError(errorInfo) { + try { + const storageKey = 'genai_critical_errors'; + const criticalErrors = JSON.parse(localStorage.getItem(storageKey) || '[]'); + + criticalErrors.push({ + errorId: errorInfo.errorId, + message: errorInfo.message, + context: errorInfo.contextName, + operation: errorInfo.operationName, + timestamp: errorInfo.timestamp + }); + + // Keep only last 5 critical errors + if (criticalErrors.length > 5) { + criticalErrors.shift(); + } + + localStorage.setItem(storageKey, JSON.stringify(criticalErrors)); + } catch (storageError) { + console.error('Failed to store critical error:', storageError); + } + } + + /** + * Attempt to recover from error using provided strategy + * @param {Function} recoveryStrategy - Recovery function + * @param {Object} errorInfo - Error information + */ + async attemptRecovery(recoveryStrategy, errorInfo) { + try { + console.log(`Attempting recovery for ${errorInfo.operationName}...`); + await recoveryStrategy(errorInfo); + console.log('Recovery successful'); + } catch (recoveryError) { + console.error('Recovery failed:', recoveryError); + } + } + + /** + * Setup global error handling + */ + setupGlobalErrorHandling() { + if (typeof window !== 'undefined') { + window.addEventListener('error', (event) => { + this.handleError( + new Error(event.message), + 'Global Error Handler', + { + filename: event.filename, + lineno: event.lineno, + colno: event.colno + } + ); + }); + + window.addEventListener('unhandledrejection', (event) => { + this.handleError( + new Error(event.reason?.message || 'Unhandled Promise Rejection'), + 'Unhandled Promise', + { reason: event.reason } + ); + }); + } + } + + /** + * Get error statistics + * @returns {Object} Error statistics + */ + getErrorStats() { + return { + totalErrors: this.errorCount, + historySize: this.errorHistory.length, + recentErrors: this.errorHistory.slice(-5), + contextName: this.contextName + }; + } + + /** + * Clear error history + */ + clearErrorHistory() { + this.errorHistory = []; + this.errorCount = 0; + this.rateLimitMap.clear(); + } + + /** + * Clean up resources + */ + destroy() { + this.clearErrorHistory(); + } +} \ No newline at end of file diff --git a/src/utils/event-manager.js b/src/utils/event-manager.js new file mode 100644 index 0000000..f859452 --- /dev/null +++ b/src/utils/event-manager.js @@ -0,0 +1,230 @@ +/** + * @fileoverview Event management utility for GenAI Browser Tool + * @author Aaron Sequeira + * @version 4.0.1 + */ + +/** + * Manages event listeners with automatic cleanup and error handling + * Provides a centralized way to handle DOM events safely + */ +export class EventManager { + constructor() { + this.activeListeners = new Map(); + this.listenerIdCounter = 0; + } + + /** + * Add event listener with error boundary and cleanup tracking + * @param {string} selector - CSS selector or element + * @param {string} eventType - Event type (click, change, etc.) + * @param {Function} handler - Event handler function + * @param {Object} options - Event listener options + * @returns {string} Listener ID for removal + */ + addEventListenerSafely(selector, eventType, handler, options = {}) { + try { + const element = typeof selector === 'string' + ? document.querySelector(selector) + : selector; + + if (!element) { + console.warn(`Element not found for selector: ${selector}`); + return null; + } + + const listenerId = `listener_${++this.listenerIdCounter}`; + const safeHandler = this.createSafeHandler(handler, selector, eventType); + + element.addEventListener(eventType, safeHandler, options); + + this.activeListeners.set(listenerId, { + element, + eventType, + handler: safeHandler, + originalHandler: handler, + selector, + options + }); + + return listenerId; + } catch (error) { + console.error(`Failed to add event listener for ${selector}:${eventType}`, error); + return null; + } + } + + /** + * Remove specific event listener by ID + * @param {string} listenerId - ID returned by addEventListenerSafely + */ + removeEventListener(listenerId) { + const listenerInfo = this.activeListeners.get(listenerId); + + if (listenerInfo) { + try { + listenerInfo.element.removeEventListener( + listenerInfo.eventType, + listenerInfo.handler, + listenerInfo.options + ); + this.activeListeners.delete(listenerId); + } catch (error) { + console.error(`Failed to remove event listener ${listenerId}`, error); + } + } + } + + /** + * Remove all event listeners managed by this instance + */ + removeAllListeners() { + for (const [listenerId] of this.activeListeners) { + this.removeEventListener(listenerId); + } + } + + /** + * Create a safe event handler with error boundary + * @param {Function} handler - Original handler function + * @param {string} selector - Element selector for debugging + * @param {string} eventType - Event type for debugging + * @returns {Function} Safe handler function + */ + createSafeHandler(handler, selector, eventType) { + return function safeEventHandler(event) { + try { + return handler.call(this, event); + } catch (error) { + console.error(`Event handler error for ${selector}:${eventType}`, error); + + // Prevent event from bubbling if handler fails + if (event && typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + } + }; + } + + /** + * Add delegated event listener for dynamic content + * @param {string} parentSelector - Parent element selector + * @param {string} childSelector - Child element selector to match + * @param {string} eventType - Event type + * @param {Function} handler - Event handler + */ + addDelegatedListener(parentSelector, childSelector, eventType, handler) { + const delegatedHandler = (event) => { + if (event.target.matches(childSelector)) { + handler.call(event.target, event); + } + }; + + return this.addEventListenerSafely(parentSelector, eventType, delegatedHandler); + } + + /** + * Add one-time event listener that removes itself after execution + * @param {string} selector - CSS selector or element + * @param {string} eventType - Event type + * @param {Function} handler - Event handler + */ + addOneTimeListener(selector, eventType, handler) { + const oneTimeHandler = (event) => { + handler.call(this, event); + this.removeEventListener(listenerId); + }; + + const listenerId = this.addEventListenerSafely( + selector, + eventType, + oneTimeHandler, + { once: true } + ); + + return listenerId; + } + + /** + * Add throttled event listener to limit execution frequency + * @param {string} selector - CSS selector or element + * @param {string} eventType - Event type + * @param {Function} handler - Event handler + * @param {number} throttleMs - Throttle delay in milliseconds + */ + addThrottledListener(selector, eventType, handler, throttleMs = 100) { + let lastExecutionTime = 0; + let timeoutId = null; + + const throttledHandler = (event) => { + const now = Date.now(); + const timeSinceLastExecution = now - lastExecutionTime; + + if (timeSinceLastExecution >= throttleMs) { + lastExecutionTime = now; + handler.call(this, event); + } else { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + lastExecutionTime = Date.now(); + handler.call(this, event); + }, throttleMs - timeSinceLastExecution); + } + }; + + return this.addEventListenerSafely(selector, eventType, throttledHandler); + } + + /** + * Add debounced event listener to delay execution until activity stops + * @param {string} selector - CSS selector or element + * @param {string} eventType - Event type + * @param {Function} handler - Event handler + * @param {number} debounceMs - Debounce delay in milliseconds + */ + addDebouncedListener(selector, eventType, handler, debounceMs = 300) { + let timeoutId = null; + + const debouncedHandler = (event) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + handler.call(this, event); + }, debounceMs); + }; + + return this.addEventListenerSafely(selector, eventType, debouncedHandler); + } + + /** + * Get information about active listeners (for debugging) + * @returns {Object} Summary of active listeners + */ + getListenerSummary() { + const summary = { + totalListeners: this.activeListeners.size, + listenersByType: new Map(), + listenersBySelector: new Map() + }; + + for (const [id, info] of this.activeListeners) { + // Count by event type + const typeCount = summary.listenersByType.get(info.eventType) || 0; + summary.listenersByType.set(info.eventType, typeCount + 1); + + // Count by selector + const selectorCount = summary.listenersBySelector.get(info.selector) || 0; + summary.listenersBySelector.set(info.selector, selectorCount + 1); + } + + return summary; + } + + /** + * Clean up all resources + */ + destroy() { + this.removeAllListeners(); + this.activeListeners.clear(); + this.listenerIdCounter = 0; + } +} \ No newline at end of file diff --git a/src/utils/validation-service.js b/src/utils/validation-service.js new file mode 100644 index 0000000..2937e08 --- /dev/null +++ b/src/utils/validation-service.js @@ -0,0 +1,444 @@ +/** + * @fileoverview Validation service for GenAI Browser Tool + * @author Aaron Sequeira + * @version 4.0.1 + */ + +/** + * Comprehensive validation service for user inputs and system data + * Provides security-focused validation with sanitization capabilities + */ +export class ValidationService { + constructor() { + this.urlPatterns = { + http: /^https?:\/\//i, + web: /^https?:\/\/[^\s/$.?#].[^\s]*$/i, + extension: /^(chrome-extension|moz-extension):\/\//i + }; + + this.securityPatterns = { + script: /]*>.*?<\/script>/gi, + html: /<[^>]*>/g, + javascript: /javascript:/gi, + dataUrl: /^data:/i + }; + + this.maxLengths = { + question: 1000, + summary: 10000, + title: 200, + description: 500 + }; + } + + /** + * Validate if a tab is accessible for content extraction + * @param {Object} tab - Chrome tab object + * @returns {boolean} True if tab is valid and accessible + */ + isValidWebPage(tab) { + if (!tab || !tab.url) { + return false; + } + + // Check for valid HTTP/HTTPS URLs + if (!this.urlPatterns.web.test(tab.url)) { + return false; + } + + // Exclude restricted pages + const restrictedDomains = [ + 'chrome://', + 'chrome-extension://', + 'moz-extension://', + 'edge://', + 'about:', + 'file://' + ]; + + return !restrictedDomains.some(domain => tab.url.startsWith(domain)); + } + + /** + * Validate user question input + * @param {string} question - User question + * @returns {boolean} True if question is valid + */ + isValidQuestion(question) { + if (!question || typeof question !== 'string') { + return false; + } + + const trimmedQuestion = question.trim(); + + // Check length constraints + if (trimmedQuestion.length === 0 || trimmedQuestion.length > this.maxLengths.question) { + return false; + } + + // Check for potential security issues + if (this.containsMaliciousContent(trimmedQuestion)) { + return false; + } + + return true; + } + + /** + * Validate content for summarization + * @param {string} content - Content to validate + * @returns {Object} Validation result with details + */ + validateContentForSummarization(content) { + const result = { + isValid: false, + sanitizedContent: '', + warnings: [], + errors: [] + }; + + if (!content || typeof content !== 'string') { + result.errors.push('Content must be a non-empty string'); + return result; + } + + const trimmedContent = content.trim(); + + if (trimmedContent.length === 0) { + result.errors.push('Content cannot be empty'); + return result; + } + + if (trimmedContent.length > this.maxLengths.summary) { + result.warnings.push(`Content is long (${trimmedContent.length} chars). Processing may be slower.`); + } + + // Sanitize content + result.sanitizedContent = this.sanitizeContent(trimmedContent); + + if (result.sanitizedContent.length < trimmedContent.length * 0.5) { + result.warnings.push('Significant content was removed during sanitization'); + } + + result.isValid = result.sanitizedContent.length > 0; + return result; + } + + /** + * Validate API key format and basic security + * @param {string} apiKey - API key to validate + * @param {string} provider - AI provider name + * @returns {Object} Validation result + */ + validateApiKey(apiKey, provider = 'unknown') { + const result = { + isValid: false, + errors: [], + warnings: [] + }; + + if (!apiKey || typeof apiKey !== 'string') { + result.errors.push('API key must be a non-empty string'); + return result; + } + + const trimmedKey = apiKey.trim(); + + if (trimmedKey.length < 10) { + result.errors.push('API key appears to be too short'); + return result; + } + + // Provider-specific validation + const providerPatterns = { + openai: /^sk-[a-zA-Z0-9]{48,}$/, + anthropic: /^sk-ant-[a-zA-Z0-9\-_]{40,}$/, + google: /^[a-zA-Z0-9\-_]{20,}$/ + }; + + const pattern = providerPatterns[provider.toLowerCase()]; + if (pattern && !pattern.test(trimmedKey)) { + result.warnings.push(`API key format doesn't match expected pattern for ${provider}`); + } + + // Security checks + if (this.containsMaliciousContent(trimmedKey)) { + result.errors.push('API key contains suspicious characters'); + return result; + } + + result.isValid = true; + return result; + } + + /** + * Validate URL for safety and accessibility + * @param {string} url - URL to validate + * @returns {Object} Validation result + */ + validateUrl(url) { + const result = { + isValid: false, + sanitizedUrl: '', + protocol: '', + domain: '', + errors: [] + }; + + if (!url || typeof url !== 'string') { + result.errors.push('URL must be a non-empty string'); + return result; + } + + const trimmedUrl = url.trim(); + + try { + const urlObj = new URL(trimmedUrl); + + result.protocol = urlObj.protocol; + result.domain = urlObj.hostname; + result.sanitizedUrl = urlObj.toString(); + + // Check for allowed protocols + if (!['http:', 'https:'].includes(urlObj.protocol)) { + result.errors.push('Only HTTP and HTTPS URLs are allowed'); + return result; + } + + // Check for suspicious domains or IPs + if (this.isSuspiciousDomain(urlObj.hostname)) { + result.errors.push('Suspicious or blocked domain detected'); + return result; + } + + result.isValid = true; + } catch (error) { + result.errors.push('Invalid URL format'); + } + + return result; + } + + /** + * Validate file upload (if applicable) + * @param {File} file - File object to validate + * @returns {Object} Validation result + */ + validateFileUpload(file) { + const result = { + isValid: false, + errors: [], + warnings: [] + }; + + if (!file) { + result.errors.push('No file provided'); + return result; + } + + const allowedTypes = ['text/plain', 'text/html', 'text/markdown', 'application/json']; + const maxSize = 5 * 1024 * 1024; // 5MB + + if (!allowedTypes.includes(file.type)) { + result.errors.push(`File type '${file.type}' is not allowed`); + return result; + } + + if (file.size > maxSize) { + result.errors.push('File size exceeds 5MB limit'); + return result; + } + + if (file.size === 0) { + result.errors.push('File appears to be empty'); + return result; + } + + result.isValid = true; + return result; + } + + /** + * Sanitize content by removing potentially harmful elements + * @param {string} content - Content to sanitize + * @returns {string} Sanitized content + */ + sanitizeContent(content) { + if (typeof content !== 'string') { + return ''; + } + + let sanitized = content; + + // Remove script tags and content + sanitized = sanitized.replace(this.securityPatterns.script, ''); + + // Remove other HTML tags but keep content + sanitized = sanitized.replace(this.securityPatterns.html, ''); + + // Remove javascript: URLs + sanitized = sanitized.replace(this.securityPatterns.javascript, ''); + + // Decode HTML entities + sanitized = this.decodeHtmlEntities(sanitized); + + // Normalize whitespace + sanitized = sanitized.replace(/\s+/g, ' ').trim(); + + return sanitized; + } + + /** + * Check if content contains malicious patterns + * @param {string} content - Content to check + * @returns {boolean} True if malicious content detected + */ + containsMaliciousContent(content) { + if (typeof content !== 'string') { + return false; + } + + const maliciousPatterns = [ + this.securityPatterns.script, + this.securityPatterns.javascript, + /eval\s*\(/gi, + /document\.write/gi, + /innerHTML\s*=/gi, + /on\w+\s*=/gi, // Event handlers like onclick, onload + /