Merge pull request #5 from zackkatz/main

This commit is contained in:
Jack Arturo 2025-09-23 10:42:00 +01:00 committed by GitHub
commit 0b96a38c65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 4430 additions and 660 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.claude
.serena

117
CHANGELOG.md Normal file
View file

@ -0,0 +1,117 @@
# Changelog

## [2.0.0] - 2024-09-08

### 🎉 Major Release: Dual-Platform Support

#### Added
- **Help Scout Support**: Full integration with Help Scout platform
- Automatic platform detection with 5-minute caching
- React/SPA support with MutationObserver
- Dynamic content handling for Help Scout's interface
- window.appData integration for customer information
- Customer property extraction from Help Scout sidebar

- **Platform Abstraction Layer**
- Base adapter class for consistent interface
- Platform-specific adapters for FreeScout and Help Scout
- Centralized platform manager for coordination
- Event-driven architecture for platform-specific features

- **Security Enhancements**
- Comprehensive HTML sanitization utility
- XSS prevention through whitelist-based filtering
- Safe DOM manipulation methods
- Content validation before injection

- **Performance Optimizations**
- Platform detection caching (5-minute TTL)
- Debounced DOM operations
- Lazy adapter loading
- Cached querySelector operations
- Retry mechanism with exponential backoff

- **Developer Tools**
- Health check system (`window.gptAssistant.getHealth()`)
- Performance metrics tracking (`window.gptAssistant.getMetrics()`)
- Debug utilities for platform detection
- Build script for easy deployment

- **GPT-5 Support**:
- Pre-configured option for future GPT-5 (set as recommended) and GPT-5 Mini models

#### Changed
- Extension name to "GPT Assistant for FreeScout & Help Scout"
- Manifest version to 2.0.0
- Content script architecture to use platform abstraction
- Host permissions to include Help Scout domains
- README to reflect dual-platform capabilities

#### Maintained
- All existing FreeScout features
- WordPress customer data extraction
- Tone analysis and matching
- Feedback system
- Documentation integration
- Keyboard shortcuts (Ctrl+Shift+G)
- OpenAI GPT integration
- Custom system prompts

### Technical Details

#### New Files Created
- `platformDetection.js` - Intelligent platform detection
- `platformManager.js` - Central coordination layer
- `adapters/platformAdapter.js` - Base adapter class
- `adapters/freescoutAdapter.js` - FreeScout implementation
- `adapters/helpscoutAdapter.js` - Help Scout implementation
- `utils/htmlSanitizer.js` - Security utility

#### Architecture
- **Adapter Pattern**: Clean separation between platforms
- **Event-Driven**: Platform-specific event handling
- **Singleton Manager**: Centralized platform management
- **Defensive Programming**: Comprehensive error handling

#### Browser Compatibility
- Chrome/Chromium 88+
- Edge 88+
- Requires Manifest V3 support

#### Migration Notes
- Existing users: No action required, full backward compatibility
- New users: Automatic platform detection on first use
- Settings: All existing settings preserved
- API Keys: No changes required

---

## [1.1.0] - 2024-01-15

### Added
- Feedback system for response quality tracking
- Customer data extraction from WordPress
- Tone analysis from previous messages
- Documentation caching with 24-hour TTL

### Changed
- Improved error handling and user feedback
- Enhanced Summernote editor integration
- Updated to support GPT-4o model

### Fixed
- Context extraction from reply editor
- Keyboard shortcut handling on Mac
- API error message clarity

---

## [1.0.0] - 2023-12-01

### Initial Release
- Core GPT integration with FreeScout
- Customizable system prompts
- Keyboard shortcuts
- Temperature and token controls
- Basic conversation extraction
- OpenAI API integration

View file

@ -1,11 +1,11 @@
# FreeScout GPT Assistant
# GPT Assistant for FreeScout & Help Scout

A powerful Chrome extension that integrates OpenAI's GPT models with FreeScout to generate intelligent, context-aware customer support responses. Features advanced documentation integration, customer data extraction, and tone matching for personalized support.
A powerful Chrome extension that integrates OpenAI's GPT models with both FreeScout and Help Scout platforms to generate intelligent, context-aware customer support responses. Features advanced documentation integration, customer data extraction, tone matching, and automatic platform detection for seamless multi-platform support.

## 🚀 Features

### Core AI Integration
- **GPT-4 Support**: Use GPT-4o, GPT-4 Turbo, or GPT-3.5 Turbo models
- **Multiple Model Support**: GPT-5, GPT-5 Mini, GPT-4o Mini, GPT-4o, GPT-4 Turbo, and GPT-3.5 Turbo
- **Smart Context Building**: Automatically extracts conversation history and customer information
- **Tone Matching**: Analyzes your previous responses to maintain consistent communication style
- **Customizable System Prompts**: Define your support agent's personality and guidelines
@ -34,12 +34,19 @@ When used with the [WordPressFreeScout module](https://github.com/verygoodplugin
- **Feedback System**: Optional response quality tracking (can be disabled)
- **Error Handling**: Detailed error messages for troubleshooting

### Multi-Platform Support
- **Automatic Platform Detection**: Works seamlessly with both FreeScout and Help Scout
- **FreeScout Features**: Full WordPress integration, Summernote editor support
- **Help Scout Features**: React/SPA support, dynamic content handling, appData integration
- **Security**: Comprehensive HTML sanitization and XSS prevention
- **Performance**: 5-minute detection caching, debounced operations

### User Experience
- **Visual Feedback**: "🤖 Generating AI response..." status indicator
- **Optional Context Input**: Type context/notes in the reply field before generation
- **Response Feedback System**: Rate responses and track improvement over time
- **Markdown Support**: Automatic conversion of links and bold text
- **Summernote Integration**: Native support for FreeScout's WYSIWYG editor
- **Editor Integration**: Native support for both platforms' WYSIWYG editors
- **Personalized Signatures**: Automatic sign-offs using agent names

<img width="1504" alt="image" src="https://github.com/user-attachments/assets/2c64dc3d-bf49-4394-a684-e72252791e88" />
@ -109,7 +116,7 @@ Based on OpenAI's current pricing (as of 2025) and assuming an average conversat

**Light Usage** (50 responses/month):
- GPT-4o: ~$0.40/month
- GPT-4 Turbo: ~$1.45/month
- GPT-4 Turbo: ~$1.45/month
- GPT-3.5 Turbo: ~$0.05/month

**Medium Usage** (200 responses/month):
@ -139,6 +146,12 @@ Based on OpenAI's current pricing (as of 2025) and assuming an average conversat
- Set up billing alerts in your OpenAI account
- Track response quality vs. cost for your use case

4. **Leverage Prompt Caching** (Automatic):
- The extension automatically implements OpenAI's Prompt Caching
- Reduces latency by up to 80% and costs by up to 75%
- No configuration needed - works automatically with GPT-4o and newer models
- See "Prompt Caching" section below for details

*Note: Prices are subject to change. Check [OpenAI's pricing page](https://openai.com/pricing) for current rates.*

### Documentation Integration
@ -400,6 +413,39 @@ Smart caching reduces API calls and improves performance:
- Manual cache clearing capability
- Automatic cache busting for fresh content

### Prompt Caching (OpenAI Feature)

The extension automatically implements OpenAI's Prompt Caching to dramatically improve performance and reduce costs:

**Automatic Benefits:**
- **Up to 80% faster response times** - Cached prompts are processed much faster
- **Up to 75% cost reduction** - Cached tokens cost significantly less
- **No configuration needed** - Works automatically with compatible models
- **No additional fees** - Prompt caching is free from OpenAI

**How It Works:**
- Static content (system prompt, documentation) is placed first for optimal caching
- Dynamic content (conversation, customer info) is placed last
- A unique cache key routes similar requests to the same servers
- Cache remains active for 5-10 minutes (up to 1 hour during off-peak)

**Monitoring Performance:**
Check the browser console for cache metrics:
```
GPT Assistant: Prompt caching active! 1920 tokens cached (78.3% hit rate, ~58.7% cost savings)
```

**Requirements:**
- Prompts must exceed 1024 tokens for caching to activate
- Works with GPT-4o and newer models
- Best results with consistent system prompts and documentation

**Optimization Tips:**
- Keep your system prompt and documentation URL consistent
- Make frequent requests to maintain cache warmth
- Longer, detailed documentation improves cache hit rates
- Monitor console logs to verify caching is working

## 🔧 Troubleshooting

### Common Issues
@ -424,10 +470,27 @@ Smart caching reduces API calls and improves performance:
### Debug Information

Enable Chrome DevTools Console to see detailed logs:
1. Press F12 in FreeScout
1. Press F12 in FreeScout or Help Scout
2. Go to Console tab
3. Trigger the extension and review any error messages

**Useful debugging commands in console:**
```javascript
// Check if extension loaded
console.log(window.gptAssistant);

// Check detected platform
window.gptAssistant.platformManager.getPlatform()
// Should return: 'freescout' or 'helpscout'

// Check health status
await window.gptAssistant.getHealth()
// Should show: {status: 'healthy', ...}

// View performance metrics
window.gptAssistant.getMetrics()
```

## 📋 Requirements

- **Chrome Browser**: Version 88+ (Manifest V3 support)
@ -458,7 +521,21 @@ This project is open source. Please check the license file for details.

## 📝 Changelog

### Version 1.1 (Latest)
### Version 2.0.1 (Latest)
- ✅ **NEW: Help Scout Support** - Full compatibility with Help Scout platform
- ✅ **NEW: Prompt Caching** - Automatic OpenAI prompt caching for 80% faster responses and 75% cost reduction
- ✅ **FIXED: Duplicate Response Bug** - Fixed issue where responses were inserted twice in Help Scout
- ✅ **IMPROVED: Documentation Parsing** - Better handling of large documentation files
- Enhanced error handling and debugging capabilities
- Added comprehensive debug console commands

### Version 2.0.0
- Major rewrite for multi-platform support
- Added Help Scout platform detection and integration
- Improved React/SPA compatibility
- Enhanced Slate.js editor support

### Version 1.1
- ✅ **NEW: Optional Context Input** - Type context/notes in the reply field before generating AI responses
- ✅ **NEW: Response Feedback System** - Rate AI responses with thumbs up/down and provide improvement notes
- The AI will incorporate your context into the generated response
@ -467,7 +544,7 @@ This project is open source. Please check the license file for details.
- Feedback analytics help identify patterns and suggest improvements

### Version 1.0
- Initial release with GPT-4 integration
- Initial release with GPT-4 integration for FreeScout
- WordPress customer data extraction
- Documentation integration with llms.txt support
- Tone matching and personalized signatures
@ -486,4 +563,4 @@ For issues with the WordPressFreeScout module:

---

**Made with ❤️ for the FreeScout community**
**Made with ❤️ for the FreeScout community**

View file

@ -0,0 +1,568 @@
/**
* FreeScout Platform Adapter
* Handles all FreeScout-specific operations
* Extends PlatformAdapter with FreeScout implementation
*/

(function(global) {
'use strict';
// Wait for dependencies
const PlatformAdapter = global.PlatformAdapter || window.PlatformAdapter;
const HTMLSanitizer = global.HTMLSanitizer || window.HTMLSanitizer;
class FreeScoutAdapter extends PlatformAdapter {
constructor() {
super();
this.platformName = 'freescout';
}

/**
* Get platform name
*/
getPlatformName() {
return this.platformName;
}

/**
* Check if this adapter can handle the current page
*/
static canHandle(url, document) {
// Check for FreeScout-specific elements
const hasThreadItems = document.querySelector('.thread-item') !== null;
const hasNoteEditable = document.querySelector('.note-editable') !== null;
const hasConversationId = document.querySelector('[data-conversation-id]') !== null;
// Check URL patterns
const isFreeScoutUrl = url.includes('conversation') || url.includes('mailbox');
const isNotHelpScout = !url.includes('helpscout');
return (hasThreadItems || hasNoteEditable || hasConversationId) &&
isFreeScoutUrl && isNotHelpScout;
}

/**
* Extract conversation thread messages
*/
extractThread() {
const messages = [];
try {
// Find all thread items
const threadItems = this.querySelectorAll('.thread-item', false); // Don't cache as content changes
threadItems.forEach(item => {
const content = item.querySelector('.thread-content');
const person = item.querySelector('.thread-person');
if (!content) return;
// Sanitize extracted text
const messageText = this.sanitizeText(content.innerText.trim());
const personName = person ? this.sanitizeText(person.innerText.trim()) : 'Unknown';
// Determine message type and role
if (item.classList.contains('thread-type-customer')) {
messages.push({
role: 'user',
content: `Customer (${personName}): ${messageText}`
});
} else if (item.classList.contains('thread-type-message')) {
messages.push({
role: 'assistant',
content: `Agent (${personName}): ${messageText}`
});
} else if (item.classList.contains('thread-type-note')) {
// Include internal notes for context
messages.push({
role: 'system',
content: `Internal Note from ${personName}: ${messageText}`
});
}
});
// Fallback if no structured messages found
if (messages.length === 0) {
const fallbackContent = this.extractFallbackContent();
if (fallbackContent) {
messages.push({
role: 'user',
content: fallbackContent
});
}
}
console.log(`GPT Assistant: Extracted ${messages.length} messages from FreeScout`);
} catch (error) {
this.logError('Failed to extract thread', error);
}
return messages;
}

/**
* Extract fallback content if structured extraction fails
*/
extractFallbackContent() {
try {
const contentDivs = document.querySelectorAll('div.thread-content');
const content = Array.from(contentDivs)
.map(el => this.sanitizeText(el.innerText.trim()))
.filter(text => text.length > 0)
.join("\n\n");
return content || null;
} catch (error) {
this.logError('Failed to extract fallback content', error);
return null;
}
}

/**
* Get the reply editor element
*/
getReplyEditor() {
// Try Summernote WYSIWYG editor first
let editor = this.querySelector('.note-editable');
// Fallback to textarea
if (!editor) {
editor = this.querySelector('textarea#body');
}
// Try alternative selectors
if (!editor) {
editor = this.querySelector('.reply-editor') ||
this.querySelector('[name="body"]');
}
return editor;
}

/**
* Get current user name from FreeScout UI
*/
getCurrentUser() {
try {
// Try to get from nav bar
const navUser = this.querySelector('span.nav-user');
if (navUser) {
return this.sanitizeText(navUser.textContent.trim());
}
// Try alternative selectors
const userDropdown = this.querySelector('.user-dropdown .user-name');
if (userDropdown) {
return this.sanitizeText(userDropdown.textContent.trim());
}
// Try to get from current user's messages
const currentUserMessage = this.querySelector('.thread-type-message.current-user .thread-person');
if (currentUserMessage) {
return this.sanitizeText(currentUserMessage.textContent.trim());
}
} catch (error) {
this.logError('Failed to get current user', error);
}
return null;
}

/**
* Extract WordPress/FreeScout customer information
*/
extractPlatformCustomerInfo() {
const customerInfo = {};
try {
// Check for WordPress FreeScout widget
const wpWidget = this.querySelector('#wordpress-freescout');
if (wpWidget) {
// Extract WordPress customer data
Object.assign(customerInfo, this.extractWordPressData(wpWidget));
}
// Extract native FreeScout customer data
Object.assign(customerInfo, this.extractNativeFreeScoutData());
} catch (error) {
this.logError('Failed to extract customer info', error);
}
return Object.keys(customerInfo).length > 0 ? customerInfo : null;
}

/**
* Extract WordPress integration data
*/
extractWordPressData(wpWidget) {
const data = {};
try {
// Extract customer name and basic info
const userLink = wpWidget.querySelector('a[href*="user-edit.php"]');
if (userLink) {
data.name = userLink.textContent.trim();
}

// Extract basic customer details from the first list
const basicInfoList = wpWidget.querySelector('.wordpress-orders-list');
if (basicInfoList) {
const listItems = basicInfoList.querySelectorAll('li');
listItems.forEach(item => {
const label = item.querySelector('label');
if (label) {
const labelText = label.textContent.trim();
const value = item.textContent.replace(labelText, '').trim();
switch (labelText) {
case 'Registered':
data.registered = value;
break;
case 'Active CRM':
case 'Actve CRM': // Handle typo in source
data.activeCRM = value;
break;
case 'Last License Check':
data.lastLicenseCheck = value;
break;
case 'Version':
const versionSpan = item.querySelector('.label');
if (versionSpan) {
data.version = versionSpan.textContent.trim();
data.versionStatus = versionSpan.classList.contains('label-danger') ? 'outdated' : 'current';
}
break;
}
}
});
}

// Extract active integrations
data.activeIntegrations = this.extractLabelCloud(wpWidget, 'Active Integrations');
// Extract CRM tags
data.crmTags = this.extractLabelCloud(wpWidget, 'Tags');
// Extract recent orders
data.recentOrders = this.extractOrders(wpWidget);
// Extract license information
data.license = this.extractLicense(wpWidget);
} catch (error) {
this.logError('Failed to extract WordPress data', error);
}
return data;
}

/**
* Extract label cloud data (integrations, tags)
*/
extractLabelCloud(container, sectionTitle) {
try {
const section = Array.from(container.querySelectorAll('h5')).find(h5 =>
h5.textContent.includes(sectionTitle)
);
if (section) {
const labelContainer = section.nextElementSibling;
if (labelContainer && labelContainer.classList.contains('label-cloud')) {
return Array.from(labelContainer.querySelectorAll('.label'))
.map(label => label.textContent.trim());
}
}
} catch (error) {
this.logError(`Failed to extract ${sectionTitle}`, error);
}
return [];
}

/**
* Extract order information
*/
extractOrders(container) {
const orders = [];
try {
const ordersSection = Array.from(container.querySelectorAll('h5')).find(h5 =>
h5.textContent.includes('EDD Orders')
);
if (ordersSection) {
const ordersList = ordersSection.nextElementSibling;
if (ordersList) {
const orderItems = ordersList.querySelectorAll('.list-group-item');
// Limit to 3 most recent orders
Array.from(orderItems).slice(0, 3).forEach(orderItem => {
const order = {};
// Order status
const statusLabel = orderItem.querySelector('.label');
if (statusLabel) {
order.status = statusLabel.textContent.trim();
}
// Order number and amount
const orderLink = orderItem.querySelector('a[href*="edd-payment-history"]');
if (orderLink) {
order.number = orderLink.textContent.trim();
}
// Extract amount
const amountMatch = orderItem.textContent.match(/\$[\d,]+\.?\d*/);
if (amountMatch) {
order.amount = amountMatch[0];
}
// Product name
const productItem = orderItem.querySelector('.edd-order-items-list li');
if (productItem) {
order.product = productItem.textContent.trim().replace(/\s*-\s*\$[\d,]+\.?\d*/, '');
}
// Order date
const orderMeta = orderItem.querySelector('.edd-order-meta');
if (orderMeta) {
const dateMatch = orderMeta.textContent.match(/\d{4}-\d{2}-\d{2}/);
if (dateMatch) {
order.date = dateMatch[0];
}
}
if (Object.keys(order).length > 0) {
orders.push(order);
}
});
}
}
} catch (error) {
this.logError('Failed to extract orders', error);
}
return orders;
}

/**
* Extract license information
*/
extractLicense(container) {
const license = {};
try {
const licensesSection = Array.from(container.querySelectorAll('h5')).find(h5 =>
h5.textContent.includes('EDD Licenses')
);
if (licensesSection) {
const licensesList = licensesSection.nextElementSibling;
if (licensesList) {
const firstLicense = licensesList.querySelector('.list-group-item');
if (firstLicense) {
// License status
const statusLabel = firstLicense.querySelector('.label');
if (statusLabel) {
license.status = statusLabel.textContent.trim();
}
// License number
const licenseLink = firstLicense.querySelector('a[href*="edd-licenses"]');
if (licenseLink) {
license.number = licenseLink.textContent.trim();
}
// License key (be careful with sensitive data)
const licenseKey = firstLicense.querySelector('code');
if (licenseKey) {
// Only store partial key for security
const fullKey = licenseKey.textContent.trim();
license.keyPartial = fullKey.substring(0, 8) + '...';
}
// Active sites count
const sitesList = firstLicense.querySelector('.edd-order-items-list');
if (sitesList) {
const sites = sitesList.querySelectorAll('li');
license.activeSites = sites.length;
// Get first few site URLs for context
license.sampleSites = Array.from(sites).slice(0, 3).map(site => {
const link = site.querySelector('a');
return link ? link.textContent.trim() : site.textContent.trim();
});
}
// Expiration date
const orderMeta = firstLicense.querySelector('.edd-order-meta');
if (orderMeta && orderMeta.textContent.includes('Expires')) {
const expirationMatch = orderMeta.textContent.match(/Expires (\d{2}\/\d{2}\/\d{4})/);
if (expirationMatch) {
license.expires = expirationMatch[1];
}
}
}
}
}
} catch (error) {
this.logError('Failed to extract license', error);
}
return Object.keys(license).length > 0 ? license : null;
}

/**
* Extract native FreeScout customer data
*/
extractNativeFreeScoutData() {
const data = {};
try {
// Try to get customer name from conversation header
const customerName = this.querySelector('.conv-customer-name, .customer-name');
if (customerName) {
data.name = customerName.textContent.trim();
}
// Try to get customer email
const customerEmail = this.querySelector('.conv-customer-email, .customer-email');
if (customerEmail) {
data.email = customerEmail.textContent.trim();
}
// Try to get conversation subject
const subject = this.querySelector('.conv-subject, .conversation-subject');
if (subject) {
data.conversationSubject = subject.textContent.trim();
}
// Try to get conversation status
const status = this.querySelector('.conv-status, .conversation-status');
if (status) {
data.conversationStatus = status.textContent.trim();
}
// Try to get assigned agent
const assignee = this.querySelector('.conv-assignee, .assignee-name');
if (assignee) {
data.assignedTo = assignee.textContent.trim();
}
} catch (error) {
this.logError('Failed to extract native FreeScout data', error);
}
return data;
}

/**
* Analyze user tone from previous messages
*/
analyzeUserTone(threadMessages, currentUser) {
if (!currentUser || !threadMessages || threadMessages.length === 0) {
return '';
}
try {
// Find messages from the current user
const userMessages = threadMessages.filter(msg =>
msg.role === 'assistant' &&
msg.content.includes(`Agent (${currentUser}):`)
);
if (userMessages.length === 0) {
return '';
}
// Extract just the message content
const userReplies = userMessages.map(msg =>
msg.content.replace(`Agent (${currentUser}): `, '')
);
// Create tone analysis prompt
const tonePrompt = `\n\nBased on ${currentUser}'s previous responses in this conversation, ` +
`please match their communication style and tone. Here are their previous replies:\n` +
userReplies.map((reply, i) => `${i + 1}. ${reply}`).join('\n');
return tonePrompt;
} catch (error) {
this.logError('Failed to analyze user tone', error);
return '';
}
}

/**
* Get existing context from the reply field
*/
getExistingContext() {
try {
const editor = this.getReplyEditor();
if (!editor) {
return '';
}
let existingContext = '';
if (editor.contentEditable === 'true' || editor.classList.contains('note-editable')) {
existingContext = editor.innerText.trim();
} else if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') {
existingContext = editor.value.trim();
}
// Only return if it's not the generating status message
if (existingContext && !existingContext.includes('🤖 Generating AI response')) {
return existingContext;
}
} catch (error) {
this.logError('Failed to get existing context', error);
}
return '';
}

/**
* Platform-specific initialization
*/
async platformInitialize() {
console.log('GPT Assistant: FreeScout adapter initialized');
// Set up any FreeScout-specific event listeners or observers
this.setupFreeScoutObservers();
}

/**
* Set up FreeScout-specific observers
*/
setupFreeScoutObservers() {
// Watch for conversation changes (for SPAs or AJAX updates)
const conversationContainer = this.querySelector('.conversation-body, .thread-list');
if (conversationContainer) {
// Use MutationObserver to detect when conversation changes
const observer = new MutationObserver((mutations) => {
// Clear cache when conversation content changes
this.clearCache();
});
observer.observe(conversationContainer, {
childList: true,
subtree: true
});
console.log('GPT Assistant: FreeScout conversation observer set up');
}
}
}

// Export to global scope
global.FreeScoutAdapter = FreeScoutAdapter;
})(window);

1010
adapters/helpscoutAdapter.js Normal file

File diff suppressed because it is too large Load diff

520
adapters/platformAdapter.js Normal file
View file

@ -0,0 +1,520 @@
/**
* Base Platform Adapter Class
* Abstract base class that defines the interface for platform-specific adapters
* Includes common functionality and security measures
*/

(function(global) {
'use strict';
class PlatformAdapter {
constructor() {
// Cache for DOM queries to improve performance
this._elementCache = new Map();
this._cacheTimeout = 5000; // 5 seconds cache
// Debounce timers
this._debounceTimers = new Map();
// Error tracking
this._errors = [];
this._maxErrors = 10;
}

// ============= Abstract Methods (must be implemented by subclasses) =============

/**
* Get the platform name
* @returns {string} Platform identifier ('freescout' or 'helpscout')
*/
getPlatformName() {
throw new Error('getPlatformName() must be implemented by subclass');
}

/**
* Extract conversation thread messages
* @returns {Array} Array of message objects with role and content
*/
extractThread() {
throw new Error('extractThread() must be implemented by subclass');
}

/**
* Get the reply editor element
* @returns {HTMLElement|null} Editor element or null if not found
*/
getReplyEditor() {
throw new Error('getReplyEditor() must be implemented by subclass');
}

/**
* Platform-specific customer info extraction
* @returns {Object|null} Customer information object
*/
extractPlatformCustomerInfo() {
throw new Error('extractPlatformCustomerInfo() must be implemented by subclass');
}

// ============= Common Methods (shared functionality) =============

/**
* Initialize the adapter
* @returns {Promise<boolean>} Success status
*/
async initialize() {
try {
console.log(`GPT Assistant: Initializing ${this.getPlatformName()} adapter`);
// Clear caches
this.clearCache();
// Platform-specific initialization
if (this.platformInitialize) {
await this.platformInitialize();
}
return true;
} catch (error) {
this.logError('Initialization failed', error);
return false;
}
}

/**
* Static method to check if adapter can handle current page
* @param {string} url - Current page URL
* @param {Document} document - Document object
* @returns {boolean}
*/
static canHandle(url, document) {
throw new Error('canHandle() must be implemented by subclass');
}

/**
* Inject reply into editor with sanitization
* @param {string} reply - Reply text to inject
*/
injectReply(reply) {
try {
const editor = this.getReplyEditor();
if (!editor) {
throw new Error('Reply editor not found');
}

// Sanitize the reply
const sanitizedReply = this.formatReplyHTML(reply);

// Check if it's a contentEditable element or textarea
if (editor.contentEditable === 'true' || editor.classList.contains('note-editable')) {
editor.innerHTML = sanitizedReply;
this.triggerInputEvents(editor);
} else if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') {
// For text inputs, convert HTML to plain text
editor.value = this.htmlToPlainText(sanitizedReply);
this.triggerInputEvents(editor);
} else {
throw new Error('Unknown editor type');
}

// Focus the editor
editor.focus();
// Scroll to editor if needed
this.scrollToElement(editor);
console.log('GPT Assistant: Reply injected successfully');
} catch (error) {
this.logError('Failed to inject reply', error);
this.showUserError('Failed to insert reply. Please try again.');
}
}

/**
* Format and sanitize reply HTML
* @param {string} reply - Raw reply text
* @returns {string} Sanitized HTML
*/
formatReplyHTML(reply) {
// Use HTMLSanitizer if available
if (typeof HTMLSanitizer !== 'undefined') {
return HTMLSanitizer.sanitize(reply, {
convertMarkdown: true,
convertLineBreaks: true
});
}
// Fallback sanitization (basic)
return this.basicSanitize(reply);
}

/**
* Basic HTML sanitization (fallback)
*/
basicSanitize(html) {
// Escape HTML entities
let safe = html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
// Convert markdown links
safe = safe.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
);
// Convert markdown bold
safe = safe.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Convert line breaks
safe = safe.replace(/\n/g, '<br>');
return safe;
}

/**
* Convert HTML to plain text
*/
htmlToPlainText(html) {
const temp = document.createElement('div');
temp.innerHTML = html;
return temp.textContent || temp.innerText || '';
}

/**
* Show generating status in editor
*/
showGeneratingStatus() {
try {
const editor = this.getReplyEditor();
if (!editor) {
console.warn('GPT Assistant: Editor not found for status display');
return;
}

const statusMessage = '🤖 Generating AI response...';
if (editor.contentEditable === 'true' || editor.classList.contains('note-editable')) {
editor.innerHTML = `<div style="color: #6c757d; font-style: italic;">${statusMessage}</div>`;
editor.style.opacity = '0.7';
} else if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') {
editor.value = statusMessage;
editor.style.opacity = '0.7';
}
} catch (error) {
this.logError('Failed to show generating status', error);
}
}

/**
* Clear generating status from editor
*/
clearGeneratingStatus() {
try {
const editor = this.getReplyEditor();
if (!editor) {
return;
}

editor.style.opacity = '1';
if (editor.contentEditable === 'true' || editor.classList.contains('note-editable')) {
if (editor.innerHTML.includes('Generating AI response')) {
editor.innerHTML = '';
}
} else if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') {
if (editor.value.includes('Generating AI response')) {
editor.value = '';
}
}
} catch (error) {
this.logError('Failed to clear generating status', error);
}
}

/**
* Get current user name
* @returns {string|null} User name or null
*/
getCurrentUser() {
// Default implementation - override in subclass for platform-specific logic
return null;
}

/**
* Extract customer information with sanitization
* @returns {Object|null} Sanitized customer info
*/
extractCustomerInfo() {
try {
const rawInfo = this.extractPlatformCustomerInfo();
if (!rawInfo) {
return null;
}

// Sanitize all string values in the customer info
return this.sanitizeObject(rawInfo);
} catch (error) {
this.logError('Failed to extract customer info', error);
return null;
}
}

/**
* Get keyboard shortcuts configuration
* @returns {Object} Keyboard shortcuts config
*/
getKeyboardShortcuts() {
// Default shortcuts - can be overridden by platform
return {
generateReply: 'Ctrl+Shift+G',
toggleFeedback: 'Ctrl+Shift+F',
clearReply: 'Ctrl+Shift+C'
};
}

/**
* Attach feedback UI to the page
* @param {string} generatedResponse - The generated response for feedback
*/
attachFeedbackUI(generatedResponse) {
// This will be implemented based on the existing feedback UI logic
// For now, just log
console.log('GPT Assistant: Feedback UI would be attached here');
}

// ============= Utility Methods =============

/**
* Cached querySelector with timeout
*/
querySelector(selector, useCache = true) {
const cacheKey = `qs_${selector}`;
if (useCache && this._elementCache.has(cacheKey)) {
const cached = this._elementCache.get(cacheKey);
if (Date.now() - cached.timestamp < this._cacheTimeout) {
return cached.element;
}
}
const element = document.querySelector(selector);
if (element && useCache) {
this._elementCache.set(cacheKey, {
element: element,
timestamp: Date.now()
});
}
return element;
}

/**
* Cached querySelectorAll
*/
querySelectorAll(selector, useCache = true) {
const cacheKey = `qsa_${selector}`;
if (useCache && this._elementCache.has(cacheKey)) {
const cached = this._elementCache.get(cacheKey);
if (Date.now() - cached.timestamp < this._cacheTimeout) {
return cached.elements;
}
}
const elements = document.querySelectorAll(selector);
if (elements.length > 0 && useCache) {
this._elementCache.set(cacheKey, {
elements: elements,
timestamp: Date.now()
});
}
return elements;
}

/**
* Clear element cache
*/
clearCache() {
this._elementCache.clear();
}

/**
* Trigger input events on an element
*/
triggerInputEvents(element) {
const events = ['input', 'change', 'keyup'];
events.forEach(eventType => {
const event = new Event(eventType, {
bubbles: true,
cancelable: true
});
element.dispatchEvent(event);
});
}

/**
* Scroll element into view
*/
scrollToElement(element) {
if (element && element.scrollIntoView) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}

/**
* Debounce function execution
*/
debounce(func, delay, key) {
if (this._debounceTimers.has(key)) {
clearTimeout(this._debounceTimers.get(key));
}
const timer = setTimeout(() => {
func();
this._debounceTimers.delete(key);
}, delay);
this._debounceTimers.set(key, timer);
}

/**
* Sanitize object (recursively sanitize all string values)
*/
sanitizeObject(obj) {
if (!obj || typeof obj !== 'object') {
return obj;
}

const sanitized = Array.isArray(obj) ? [] : {};

for (let key in obj) {
if (obj.hasOwnProperty(key)) {
const value = obj[key];
if (typeof value === 'string') {
// Sanitize string values (escape HTML)
sanitized[key] = this.sanitizeText(value);
} else if (typeof value === 'object' && value !== null) {
// Recursively sanitize nested objects
sanitized[key] = this.sanitizeObject(value);
} else {
sanitized[key] = value;
}
}
}

return sanitized;
}

/**
* Sanitize plain text (escape HTML entities)
*/
sanitizeText(text) {
if (!text || typeof text !== 'string') {
return text;
}

return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}

/**
* Wait for element to appear in DOM
*/
async waitForElement(selector, timeout = 5000) {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const checkElement = () => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
} else if (Date.now() - startTime > timeout) {
reject(new Error(`Element ${selector} not found after ${timeout}ms`));
} else {
setTimeout(checkElement, 100);
}
};
checkElement();
});
}

/**
* Log error with tracking
*/
logError(message, error) {
console.error(`GPT Assistant [${this.getPlatformName()}]: ${message}`, error);
// Track errors
this._errors.push({
message: message,
error: error,
timestamp: Date.now()
});
// Keep only recent errors
if (this._errors.length > this._maxErrors) {
this._errors.shift();
}
}

/**
* Show user-friendly error message
*/
showUserError(message) {
// This could be implemented to show a toast/notification
// For now, use console.warn to be less intrusive
console.warn(`GPT Assistant: ${message}`);
// Could also inject a temporary error message in the UI
// This would be platform-specific
}

/**
* Get recent errors for debugging
*/
getRecentErrors() {
return this._errors;
}

/**
* Clean up resources
*/
cleanup() {
// Clear caches
this.clearCache();
// Clear debounce timers
this._debounceTimers.forEach(timer => clearTimeout(timer));
this._debounceTimers.clear();
// Clear errors
this._errors = [];
console.log(`GPT Assistant: ${this.getPlatformName()} adapter cleaned up`);
}
}

// Export to global scope
global.PlatformAdapter = PlatformAdapter;
})(window);

View file

@ -3,12 +3,56 @@ chrome.runtime.onInstalled.addListener(() => {
console.log("FreeScout GPT Assistant installed.");
});

// Test function for debugging - can be called from console
globalThis.testFetchDocs = async function(url = 'https://docs.gravitykit.com/llms-full.txt') {
console.log('=== TESTING DOCUMENTATION FETCH ===');
console.log('URL:', url);
try {
// Try direct fetch first
console.log('Step 1: Attempting direct fetch...');
const response = await fetch(url);
console.log('Step 2: Response status:', response.status, 'OK:', response.ok);
console.log('Step 3: Response headers:', Object.fromEntries(response.headers.entries()));
const text = await response.text();
console.log('Step 4: Received text length:', text.length);
console.log('Step 5: First 500 characters:', text.substring(0, 500));
console.log('Step 6: Text starts with "# "?', text.startsWith('# '));
console.log('Step 7: Contains any "# " headers?', text.includes('\n# '));
// Now try the actual fetchDocs function
console.log('Step 8: Testing fetchDocs function...');
const docs = await fetchDocs(url);
console.log('Step 9: Parsed documents:', docs.length);
if (docs.length > 0) {
console.log('Step 10: First document:', docs[0]);
}
return { success: true, textLength: text.length, docsCount: docs.length };
} catch (error) {
console.error('Test failed:', error);
return { success: false, error: error.message };
}
};

console.log('GPT Assistant: Background script loaded. Run testFetchDocs() in console to test.');

// Handle messages from content script
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.log('GPT Assistant: Received message:', request.action, request.url || '');
if (request.action === 'fetchDocs') {
console.log('GPT Assistant: Processing fetchDocs request for:', request.url);
fetchDocsWithCache(request.url)
.then(docs => sendResponse({ success: true, docs }))
.catch(error => sendResponse({ success: false, error: error.message }));
.then(docs => {
console.log('GPT Assistant: fetchDocs completed, returning', docs?.length || 0, 'docs');
sendResponse({ success: true, docs });
})
.catch(error => {
console.error('GPT Assistant: fetchDocs error:', error);
sendResponse({ success: false, error: error.message });
});
return true; // Keep the message channel open for async response
} else if (request.action === 'clearDocsCache') {
clearDocsCache()
@ -34,7 +78,10 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
});

async function fetchDocsWithCache(url) {
if (!url) return [];
if (!url) {
console.log('GPT Assistant: No documentation URL provided');
return [];
}
// Check cache first
const cacheKey = `docs_cache_${url}`;
@ -50,23 +97,29 @@ async function fetchDocsWithCache(url) {
const now = Date.now();
if (cachedDocs && cachedTimestamp && (now - cachedTimestamp) < oneDayInMs) {
console.log('Using cached docs for:', url);
console.log('GPT Assistant: Using cached docs for:', url, 'Count:', cachedDocs.length);
return cachedDocs;
}
// Cache is invalid or doesn't exist, fetch fresh data
console.log('Fetching fresh docs for:', url);
console.log('GPT Assistant: Fetching fresh docs for:', url);
const docs = await fetchDocs(url);
// Store in cache with timestamp
await chrome.storage.local.set({
[cacheKey]: docs,
[timestampKey]: now
});
// Only cache if we got valid docs
if (docs && docs.length > 0) {
// Store in cache with timestamp
await chrome.storage.local.set({
[cacheKey]: docs,
[timestampKey]: now
});
console.log('GPT Assistant: Cached', docs.length, 'documents');
} else {
console.warn('GPT Assistant: No documents to cache for:', url);
}
return docs;
} catch (error) {
console.error('Error with docs cache:', error);
console.error('GPT Assistant: Error with docs cache:', error);
// Fallback to direct fetch if cache fails
return await fetchDocs(url);
}
@ -90,12 +143,19 @@ async function clearDocsCache() {
}

async function fetchDocs(url) {
if (!url) return [];
if (!url) {
console.log('GPT Assistant: fetchDocs called with no URL');
return [];
}
console.log('GPT Assistant: Starting fetch for:', url);
try {
// Add cache-busting query parameter
const urlWithCacheBuster = url + (url.includes('?') ? '&' : '?') + `cb=${Date.now()}`;
console.log('GPT Assistant: Fetching URL:', urlWithCacheBuster);
const response = await fetch(urlWithCacheBuster, {
cache: 'no-cache',
headers: {
@ -105,28 +165,118 @@ async function fetchDocs(url) {
}
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log('GPT Assistant: Fetch response status:', response.status, 'OK:', response.ok);
if (!response.ok) {
// Handle 404 and other errors more gracefully
if (response.status === 404) {
console.warn(`GPT Assistant: Documentation URL not found (404): ${url}`);
// Return empty array instead of throwing
return [];
}
// For other errors, log but don't throw
console.warn(`GPT Assistant: Failed to fetch docs (HTTP ${response.status}): ${url}`);
return [];
}
const text = await response.text();
console.log('GPT Assistant: Fetched text length:', text.length);
// Debug: Log raw text size
console.log('GPT Assistant: Fetched documentation:', {
url: url,
textLength: text.length,
firstChars: text.substring(0, 200) // Log first 200 chars to see format
});
const docs = [];
const lines = text.split('\n');
let currentDoc = null;
for (const line of lines) {
if (line.startsWith('# ')) {
if (currentDoc) docs.push(currentDoc);
currentDoc = { title: line.slice(2).trim(), content: '', url: '' };
} else if (line.startsWith('URL: ') && currentDoc) {
currentDoc.url = line.slice(5).trim();
} else if (currentDoc && line.trim()) {
currentDoc.content += line + '\n';
// Check if the text follows the expected format (has # headers)
const hasHeaders = lines.some(line => line.startsWith('# '));
if (hasHeaders) {
// Parse structured format with headers
for (const line of lines) {
if (line.startsWith('# ')) {
if (currentDoc) docs.push(currentDoc);
currentDoc = { title: line.slice(2).trim(), content: '', url: '' };
} else if (line.startsWith('URL: ') && currentDoc) {
currentDoc.url = line.slice(5).trim();
} else if (currentDoc && line.trim()) {
currentDoc.content += line + '\n';
}
}
if (currentDoc) docs.push(currentDoc);
} else if (text.trim()) {
// Fallback: Treat entire text as one document if no headers found
console.log('GPT Assistant: No headers found in documentation, using entire text as one document');
// Split into chunks if text is very large (>10000 chars per chunk)
const chunkSize = 10000;
if (text.length > chunkSize) {
// Split by paragraphs or sections
const sections = text.split(/\n\n+/);
let currentChunk = '';
let chunkIndex = 1;
for (const section of sections) {
if ((currentChunk + section).length > chunkSize && currentChunk) {
docs.push({
title: `Documentation Part ${chunkIndex}`,
content: currentChunk.trim(),
url: url
});
currentChunk = section;
chunkIndex++;
} else {
currentChunk += (currentChunk ? '\n\n' : '') + section;
}
}
// Add remaining chunk
if (currentChunk.trim()) {
docs.push({
title: `Documentation Part ${chunkIndex}`,
content: currentChunk.trim(),
url: url
});
}
} else {
// Small enough to use as single document
docs.push({
title: 'Documentation',
content: text.trim(),
url: url
});
}
}
if (currentDoc) docs.push(currentDoc);
// Debug: Log parsed results
console.log('GPT Assistant: Parsed documentation:', {
docsCount: docs.length,
totalContentLength: docs.reduce((sum, doc) => sum + (doc.content?.length || 0), 0),
titles: docs.map(d => d.title)
});
return docs;
} catch (error) {
console.error('Error fetching docs:', error);
// Log detailed error information
console.error('GPT Assistant: Error fetching docs:', {
url: url,
errorMessage: error.message,
errorStack: error.stack,
errorType: error.name
});
// Don't log network errors as errors, just warnings
if (error.message?.includes('Failed to fetch')) {
console.warn('GPT Assistant: Documentation fetch failed (network issue). This might be a CORS issue or network problem:', url);
} else {
console.warn('GPT Assistant: Error fetching docs:', error.message || error);
}
return [];
}
}

1130
content.js

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,16 @@
{
"manifest_version": 3,
"name": "FreeScout GPT Assistant",
"version": "1.1.0",
"description": "Generate AI replies in FreeScout using GPT-4 with customizable documentation support.",
"name": "GPT Assistant for FreeScout & Help Scout",
"version": "2.0.0",
"description": "Generate AI replies in FreeScout and Help Scout using GPT-4 with customizable documentation support.",
"permissions": ["scripting", "activeTab", "storage"],
"host_permissions": ["https://api.openai.com/", "https://*/"],
"host_permissions": [
"https://api.openai.com/",
"https://*/",
"*://*.helpscout.net/*",
"*://*.helpscout.com/*",
"*://secure.helpscout.net/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
@ -16,8 +22,35 @@
},
"content_scripts": [
{
"matches": ["*://*/conversation/*"],
"js": ["content.js"]
"matches": [
"*://*/conversation/*",
"*://*/conversations/*",
"*://*.helpscout.net/*",
"*://secure.helpscout.net/*"
],
"js": [
"utils/htmlSanitizer.js",
"platformDetection.js",
"adapters/platformAdapter.js",
"adapters/freescoutAdapter.js",
"adapters/helpscoutAdapter.js",
"platformManager.js",
"content.js"
],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": [
"utils/htmlSanitizer.js",
"platformDetection.js",
"adapters/platformAdapter.js",
"adapters/freescoutAdapter.js",
"adapters/helpscoutAdapter.js",
"platformManager.js"
],
"matches": ["<all_urls>"]
}
]
}

344
platformDetection.js Normal file
View file

@ -0,0 +1,344 @@
/**
* Platform Detection Module
* Detects whether the current page is FreeScout or Help Scout
* Includes caching and multiple fallback strategies
*/

(function(global) {
'use strict';
class PlatformDetector {
// Cache platform detection result for performance
static _cachedPlatform = null;
static _cacheTimestamp = null;
static CACHE_DURATION = 5 * 60 * 1000; // 5 minutes cache

// Platform configuration
static PLATFORM_PATTERNS = {
freescout: {
patterns: ['/conversation/*', '/mailbox/*'],
urlIncludes: ['freescout', 'conversation'],
// Add more specific patterns for self-hosted instances
urlExcludes: ['helpscout.net', 'secure.helpscout.net']
},
helpscout: {
patterns: ['/conversations/*', '/inboxes/*/views', '/conversation/*/'],
urlIncludes: ['helpscout.net', 'secure.helpscout.net', 'helpscout.com']
}
};

static DOM_MARKERS = {
freescout: {
selectors: [
'.thread-item',
'.note-editable',
'#wordpress-freescout',
'.thread-type-customer',
'.thread-type-message'
],
dataAttributes: ['data-conversation-id'],
// Unique FreeScout identifiers
uniqueMarkers: ['#conv-layout-main', '.conv-actions']
},
helpscout: {
selectors: [
'#mailbox',
'.c-conversation',
'section#wrap',
'.c-thread-item',
'.c-conversation-thread'
],
dataAttributes: ['data-cy', 'data-bypass'],
// Unique Help Scout identifiers
uniqueMarkers: ['#AccountDropdown', '.c-nav-secondary']
}
};

/**
* Main detection method with caching
* @returns {string|null} 'freescout', 'helpscout', or null
*/
static detectPlatform() {
// Check cache first
if (this.isCacheValid()) {
console.log('GPT Assistant: Using cached platform detection:', this._cachedPlatform);
return this._cachedPlatform;
}

const url = window.location.href;
const doc = document;
console.log('GPT Assistant: Detecting platform for URL:', url);

// Strategy 1: URL pattern detection (fastest)
let urlDetection = this.detectByURL(url);
// Strategy 2: DOM marker verification
let domDetection = this.detectByDOM(doc);
// Strategy 3: API/Global variable detection
let apiDetection = this.detectByAPI();
// Strategy 4: User configuration override (from storage)
let userOverride = this.getUserOverride();

// Combine detection strategies with confidence scoring
let detectedPlatform = this.combineDetectionStrategies(
urlDetection,
domDetection,
apiDetection,
userOverride
);

// Cache the result
this.cacheResult(detectedPlatform);
return detectedPlatform;
}

/**
* Check if cache is still valid
*/
static isCacheValid() {
if (!this._cachedPlatform || !this._cacheTimestamp) {
return false;
}
const now = Date.now();
return (now - this._cacheTimestamp) < this.CACHE_DURATION;
}

/**
* Cache detection result
*/
static cacheResult(platform) {
this._cachedPlatform = platform;
this._cacheTimestamp = Date.now();
}

/**
* Clear cache (useful for testing or forced re-detection)
*/
static clearCache() {
this._cachedPlatform = null;
this._cacheTimestamp = null;
}

/**
* Detect platform by URL patterns
*/
static detectByURL(url) {
// Check for Help Scout first (more specific patterns)
if (this.matchesPattern(url, this.PLATFORM_PATTERNS.helpscout)) {
// Ensure it's not excluded
if (!this.PLATFORM_PATTERNS.freescout.urlExcludes.some(exclude => url.includes(exclude))) {
return null;
}
return 'helpscout';
}
// Check for FreeScout
if (this.matchesPattern(url, this.PLATFORM_PATTERNS.freescout)) {
// Ensure it's not a Help Scout URL
if (!this.PLATFORM_PATTERNS.helpscout.urlIncludes.some(include => url.includes(include))) {
return 'freescout';
}
}
return null;
}

/**
* Detect platform by DOM markers
*/
static detectByDOM(doc) {
// Check unique markers first for more accurate detection
// Check Help Scout unique markers
if (this.hasUniqueMarkers(doc, this.DOM_MARKERS.helpscout.uniqueMarkers)) {
return 'helpscout';
}
// Check FreeScout unique markers
if (this.hasUniqueMarkers(doc, this.DOM_MARKERS.freescout.uniqueMarkers)) {
return 'freescout';
}
// Fall back to general markers with scoring
const helpScoutScore = this.calculateDOMScore(doc, this.DOM_MARKERS.helpscout);
const freeScoutScore = this.calculateDOMScore(doc, this.DOM_MARKERS.freescout);
if (helpScoutScore > freeScoutScore && helpScoutScore > 0.5) {
return 'helpscout';
}
if (freeScoutScore > helpScoutScore && freeScoutScore > 0.5) {
return 'freescout';
}
return null;
}

/**
* Detect platform by API/Global variables
*/
static detectByAPI() {
// Check for Help Scout specific globals
if (window.hsGlobal || window.appData || window.HelpScout) {
return 'helpscout';
}
// Check for FreeScout specific globals
if (window.fsGlobal || window.FreeScout || window.Conversation) {
return 'freescout';
}
// Check for platform-specific meta tags
const metaTags = document.getElementsByTagName('meta');
for (let meta of metaTags) {
const content = meta.getAttribute('content') || '';
const name = meta.getAttribute('name') || '';
if (content.includes('Help Scout') || name.includes('helpscout')) {
return 'helpscout';
}
if (content.includes('FreeScout') || name.includes('freescout')) {
return 'freescout';
}
}
return null;
}

/**
* Get user override from storage (if configured)
*/
static getUserOverride() {
// This would be implemented to check chrome.storage for user preference
// For now, return null to use auto-detection
return null;
}

/**
* Combine detection strategies with confidence scoring
*/
static combineDetectionStrategies(urlDetection, domDetection, apiDetection, userOverride) {
// User override takes precedence
if (userOverride) {
console.log('GPT Assistant: Using user override:', userOverride);
return userOverride;
}
// Count votes for each platform
const votes = {
freescout: 0,
helpscout: 0
};
if (urlDetection) votes[urlDetection]++;
if (domDetection) votes[domDetection]++;
if (apiDetection) votes[apiDetection]++;
// API detection gets extra weight as it's most reliable
if (apiDetection) votes[apiDetection]++;
console.log('GPT Assistant: Detection votes:', votes);
// Return platform with most votes
if (votes.helpscout > votes.freescout) {
return 'helpscout';
}
if (votes.freescout > votes.helpscout) {
return 'freescout';
}
// If tied or no votes, return null
return null;
}

/**
* Match URL against patterns
*/
static matchesPattern(url, patterns) {
// Check URL includes
const hasInclude = patterns.urlIncludes.some(pattern => url.includes(pattern));
// Check URL patterns (with wildcards)
const matchesPattern = patterns.patterns.some(pattern => {
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
return regex.test(url);
});
return hasInclude || matchesPattern;
}

/**
* Check for unique DOM markers
*/
static hasUniqueMarkers(doc, markers) {
return markers.some(selector => doc.querySelector(selector) !== null);
}

/**
* Calculate DOM score based on markers
*/
static calculateDOMScore(doc, markers) {
let found = 0;
let total = markers.selectors.length + markers.dataAttributes.length;
// Check selectors
markers.selectors.forEach(selector => {
if (doc.querySelector(selector)) found++;
});
// Check data attributes
markers.dataAttributes.forEach(attr => {
if (doc.querySelector(`[${attr}]`)) found++;
});
return found / total;
}

/**
* Wait for DOM to be ready for detection
*/
static async waitForDOM() {
return new Promise((resolve) => {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
resolve();
} else {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
}
});
}

/**
* Detect platform with retry logic for SPAs
*/
static async detectWithRetry(maxRetries = 3, delay = 1000) {
await this.waitForDOM();
for (let i = 0; i < maxRetries; i++) {
const platform = this.detectPlatform();
if (platform) {
return platform;
}
// Wait before retry (DOM might still be loading in SPA)
if (i < maxRetries - 1) {
console.log(`GPT Assistant: Platform not detected, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
console.warn('GPT Assistant: Could not detect platform after retries');
return null;
}
}

// Export to global scope
global.PlatformDetector = PlatformDetector;
})(window);

534
platformManager.js Normal file
View file

@ -0,0 +1,534 @@
/**
* Platform Manager
* Coordinates platform detection and adapter management
* Provides unified interface for Chrome extension
*/

(function(global) {
'use strict';
class PlatformManager {
constructor() {
this.adapter = null;
this.platform = null;
this.initialized = false;
this.initPromise = null;
this.errorCount = 0;
this.maxErrors = 3;
this.retryDelay = 1000;
// Event listeners
this.eventListeners = new Map();
// Performance metrics
this.metrics = {
initTime: 0,
detectionTime: 0,
adapterLoadTime: 0,
operationCounts: {},
errorCounts: {}
};
}
/**
* Initialize platform manager
*/
async initialize() {
// Return existing promise if initialization is in progress
if (this.initPromise) {
return this.initPromise;
}
// Return immediately if already initialized
if (this.initialized && this.adapter) {
return true;
}
this.initPromise = this._performInitialization();
return this.initPromise;
}
/**
* Perform actual initialization
*/
async _performInitialization() {
const startTime = performance.now();
try {
// Detect platform
console.log('GPT Assistant: Detecting platform...');
const detectionStart = performance.now();
const PlatformDetector = global.PlatformDetector || window.PlatformDetector;
if (!PlatformDetector) {
throw new Error('PlatformDetector not loaded');
}
this.platform = await PlatformDetector.detectPlatform();
this.metrics.detectionTime = performance.now() - detectionStart;
if (!this.platform) {
console.warn('GPT Assistant: Platform not supported on this page');
return false;
}
console.log(`GPT Assistant: Detected platform - ${this.platform}`);
// Load appropriate adapter
const adapterStart = performance.now();
const success = await this.loadAdapter();
this.metrics.adapterLoadTime = performance.now() - adapterStart;
if (!success) {
throw new Error(`Failed to load adapter for ${this.platform}`);
}
// Mark as initialized
this.initialized = true;
this.metrics.initTime = performance.now() - startTime;
console.log(`GPT Assistant: Initialized for ${this.platform} (${Math.round(this.metrics.initTime)}ms)`);
// Emit initialization event
this.emit('initialized', {
platform: this.platform,
metrics: this.metrics
});
return true;
} catch (error) {
console.error('GPT Assistant: Initialization error:', error);
this.handleError('initialization', error);
// Retry if under error threshold
if (this.errorCount < this.maxErrors) {
console.log(`GPT Assistant: Retrying initialization (attempt ${this.errorCount + 1}/${this.maxErrors})...`);
await this.delay(this.retryDelay * this.errorCount);
this.initPromise = null;
return this.initialize();
}
return false;
}
}
/**
* Load platform-specific adapter
*/
async loadAdapter() {
try {
// Get adapter classes from global scope
const FreeScoutAdapter = global.FreeScoutAdapter || window.FreeScoutAdapter;
const HelpScoutAdapter = global.HelpScoutAdapter || window.HelpScoutAdapter;
switch (this.platform) {
case 'freescout':
if (!FreeScoutAdapter) {
throw new Error('FreeScoutAdapter not loaded');
}
this.adapter = new FreeScoutAdapter();
break;
case 'helpscout':
if (!HelpScoutAdapter) {
throw new Error('HelpScoutAdapter not loaded');
}
this.adapter = new HelpScoutAdapter();
break;
default:
throw new Error(`Unknown platform: ${this.platform}`);
}
// Set up adapter event forwarding
this.setupAdapterEvents();
// Wait for adapter to be ready
if (this.adapter.initialize) {
await this.adapter.initialize();
}
// Verify adapter is ready (optional check - don't fail if method doesn't exist)
if (this.adapter.isReady && typeof this.adapter.isReady === 'function') {
const isReady = this.adapter.isReady();
if (!isReady) {
console.warn(`GPT Assistant: ${this.platform} adapter not fully ready, but continuing initialization`);
// Don't throw error - adapter may become ready later
}
}
return true;
} catch (error) {
console.error(`GPT Assistant: Failed to load ${this.platform} adapter:`, error);
this.adapter = null;
return false;
}
}
/**
* Set up event forwarding from adapter
*/
setupAdapterEvents() {
if (!this.adapter) return;
// Common events to forward
const eventsToForward = [
'editorChanged',
'editorReady',
'conversationLoaded',
'replyGenerated',
'error'
];
eventsToForward.forEach(eventName => {
if (this.adapter.addEventListener) {
this.adapter.addEventListener(eventName, (data) => {
this.emit(eventName, data);
});
}
});
}
/**
* Get current adapter
*/
getAdapter() {
if (!this.initialized || !this.adapter) {
console.warn('GPT Assistant: Platform manager not initialized');
return null;
}
return this.adapter;
}
/**
* Get current platform
*/
getPlatform() {
return this.platform;
}
/**
* Check if manager is initialized
*/
isInitialized() {
return this.initialized && this.adapter !== null;
}
/**
* Extract conversation thread
*/
async extractThread() {
return this.executeAdapterMethod('extractThread', []);
}
/**
* Inject reply into editor
*/
async injectReply(reply) {
return this.executeAdapterMethod('injectReply', [reply]);
}
/**
* Extract customer information
*/
async extractCustomerInfo() {
return this.executeAdapterMethod('extractCustomerInfo', []);
}
/**
* Get current user
*/
async getCurrentUser() {
return this.executeAdapterMethod('getCurrentUser', []);
}
/**
* Get reply editor element
*/
async getReplyEditor() {
return this.executeAdapterMethod('getReplyEditor', []);
}
/**
* Show generating status
*/
async showGeneratingStatus() {
return this.executeAdapterMethod('showGeneratingStatus', []);
}
/**
* Clear generating status
*/
async clearGeneratingStatus() {
return this.executeAdapterMethod('clearGeneratingStatus', []);
}
/**
* Get keyboard shortcuts
*/
getKeyboardShortcuts() {
if (!this.adapter) {
return {
generateReply: 'ctrl+shift+g',
regenerate: 'ctrl+shift+r',
copyReply: 'ctrl+shift+c',
clearReply: 'ctrl+shift+x'
};
}
return this.adapter.getKeyboardShortcuts ?
this.adapter.getKeyboardShortcuts() :
this.getKeyboardShortcuts();
}
/**
* Execute adapter method with error handling
*/
async executeAdapterMethod(methodName, args = []) {
try {
// Ensure initialization
if (!this.initialized) {
const success = await this.initialize();
if (!success) {
throw new Error('Failed to initialize platform manager');
}
}
if (!this.adapter) {
throw new Error('No adapter available');
}
if (typeof this.adapter[methodName] !== 'function') {
throw new Error(`Method ${methodName} not available on ${this.platform} adapter`);
}
// Track operation metrics
this.trackOperation(methodName);
// Execute method
const result = await this.adapter[methodName](...args);
// Reset error count on success
if (this.errorCount > 0) {
this.errorCount = 0;
}
return result;
} catch (error) {
console.error(`GPT Assistant: Error executing ${methodName}:`, error);
this.handleError(methodName, error);
// Return sensible defaults based on method
switch (methodName) {
case 'extractThread':
return [];
case 'extractCustomerInfo':
case 'getCurrentUser':
case 'getReplyEditor':
return null;
case 'injectReply':
case 'showGeneratingStatus':
case 'clearGeneratingStatus':
return false;
default:
return null;
}
}
}
/**
* Track operation metrics
*/
trackOperation(operation) {
if (!this.metrics.operationCounts[operation]) {
this.metrics.operationCounts[operation] = 0;
}
this.metrics.operationCounts[operation]++;
}
/**
* Handle errors with tracking
*/
handleError(operation, error) {
this.errorCount++;
if (!this.metrics.errorCounts[operation]) {
this.metrics.errorCounts[operation] = 0;
}
this.metrics.errorCounts[operation]++;
// Emit error event
this.emit('error', {
operation,
error: error.message,
errorCount: this.errorCount,
platform: this.platform
});
// Log to Chrome extension error reporting if available
if (chrome?.runtime?.sendMessage) {
chrome.runtime.sendMessage({
type: 'error',
data: {
operation,
error: error.message,
platform: this.platform,
url: window.location.href
}
}).catch(() => {
// Ignore messaging errors
});
}
}
/**
* Add event listener
*/
addEventListener(event, callback) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set());
}
this.eventListeners.get(event).add(callback);
}
/**
* Remove event listener
*/
removeEventListener(event, callback) {
if (this.eventListeners.has(event)) {
this.eventListeners.get(event).delete(callback);
}
}
/**
* Emit event
*/
emit(event, data) {
if (this.eventListeners.has(event)) {
this.eventListeners.get(event).forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in event listener for ${event}:`, error);
}
});
}
}
/**
* Delay helper
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Reset platform manager
*/
async reset() {
console.log('GPT Assistant: Resetting platform manager...');
// Clean up adapter
if (this.adapter && this.adapter.cleanup) {
this.adapter.cleanup();
}
// Reset state
this.adapter = null;
this.platform = null;
this.initialized = false;
this.initPromise = null;
this.errorCount = 0;
// Clear metrics
this.metrics = {
initTime: 0,
detectionTime: 0,
adapterLoadTime: 0,
operationCounts: {},
errorCounts: {}
};
// Clear cache in detector
const PlatformDetector = global.PlatformDetector || window.PlatformDetector;
if (PlatformDetector) {
PlatformDetector.clearCache();
}
// Re-initialize
return this.initialize();
}
/**
* Get metrics
*/
getMetrics() {
return {
...this.metrics,
platform: this.platform,
initialized: this.initialized,
errorCount: this.errorCount
};
}
/**
* Health check
*/
async healthCheck() {
const health = {
status: 'unknown',
platform: this.platform,
initialized: this.initialized,
adapterReady: false,
canExtractThread: false,
canInjectReply: false,
errors: this.errorCount
};
try {
if (!this.initialized) {
health.status = 'not_initialized';
return health;
}
if (!this.adapter) {
health.status = 'no_adapter';
return health;
}
// Check adapter readiness
health.adapterReady = this.adapter.isReady ? this.adapter.isReady() : true;
// Check core capabilities
const editor = await this.getReplyEditor();
health.canInjectReply = editor !== null;
const thread = await this.extractThread();
health.canExtractThread = Array.isArray(thread) && thread.length > 0;
// Determine overall status
if (health.adapterReady && (health.canExtractThread || health.canInjectReply)) {
health.status = 'healthy';
} else if (health.adapterReady) {
health.status = 'partial';
} else {
health.status = 'unhealthy';
}
} catch (error) {
health.status = 'error';
health.error = error.message;
}
return health;
}
}

// Export singleton instance
const platformManager = new PlatformManager();
// Export to global scope
global.platformManager = platformManager;
global.PlatformManager = PlatformManager;
})(window);

View file

@ -102,7 +102,9 @@
<input type="password" id="openaiKey" /><br />
<label>OpenAI Model</label><br />
<select id="openaiModel">
<option value="gpt-4o">GPT-4o (Recommended)</option>
<option value="gpt-5">GPT-5 (Recommended)</option>
<option value="gpt-5-mini">GPT-5 Mini</option>
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4-turbo">GPT-4 Turbo</option>
<option value="gpt-3.5-turbo">GPT-3.5 Turbo</option></select
><br />
@ -143,6 +145,9 @@
<button type="button" id="clearCache" class="btn-small">
Clear Documentation Cache
</button>
<button type="button" id="testFetch" class="btn-small">
Test Fetch Docs
</button>
</div>

<div class="feedback-section">

170
popup.js
View file

@ -18,15 +18,15 @@ chrome.storage.local.get(['systemPrompt', 'docsUrl', 'openaiKey', 'openaiModel',
document.getElementById('systemPrompt').value = result.systemPrompt || DEFAULT_SYSTEM_PROMPT;
document.getElementById('docsUrl').value = result.docsUrl || '';
document.getElementById('openaiKey').value = result.openaiKey || '';
document.getElementById('openaiModel').value = result.openaiModel || 'gpt-4o';
document.getElementById('temperature').value = result.temperature || 0.7;
document.getElementById('openaiModel').value = result.openaiModel || 'gpt-5';
document.getElementById('temperature').value = result.temperature || 1;
document.getElementById('maxTokens').value = result.maxTokens || 1000;
document.getElementById('keyboardShortcut').value = result.keyboardShortcut || 'Ctrl+Shift+G';
document.getElementById('enableFeedback').checked = result.enableFeedback !== false; // Default to true

// Check cache status after loading settings
checkCacheStatus(result.docsUrl);

// Load feedback analytics (only if feedback is enabled)
if (result.enableFeedback !== false) {
loadFeedbackAnalytics();
@ -59,25 +59,25 @@ document.getElementById('enableFeedback').addEventListener('change', function()
// Check cache status for the docs URL
function checkCacheStatus(docsUrl) {
const statusElement = document.getElementById('cacheStatus');

if (!docsUrl) {
statusElement.textContent = 'No docs URL configured';
statusElement.className = 'cache-status cache-not-cached';
return;
}

const cacheKey = `docs_cache_${docsUrl}`;
const timestampKey = `docs_timestamp_${docsUrl}`;

chrome.storage.local.get([cacheKey, timestampKey], (result) => {
const cachedDocs = result[cacheKey];
const cachedTimestamp = result[timestampKey];

if (cachedDocs && cachedTimestamp) {
const now = Date.now();
const ageHours = Math.floor((now - cachedTimestamp) / (1000 * 60 * 60));
const ageText = ageHours < 1 ? 'less than 1 hour' : `${ageHours} hour${ageHours > 1 ? 's' : ''}`;

statusElement.textContent = `Documentation cached (${ageText} ago)`;
statusElement.className = 'cache-status cache-cached';
} else {
@ -93,37 +93,37 @@ async function loadFeedbackAnalytics() {
const allData = await new Promise(resolve => {
chrome.storage.local.get(null, resolve);
});

// Get feedback entries
const feedbackEntries = Object.entries(allData)
.filter(([key]) => key.startsWith('feedback_'))
.map(([key, value]) => value)
.sort((a, b) => b.timestamp - a.timestamp);

const statsElement = document.getElementById('feedbackStats');
const suggestionsElement = document.getElementById('feedbackSuggestions');
const suggestionsList = document.getElementById('suggestionsList');

if (feedbackEntries.length === 0) {
statsElement.innerHTML = '<div class="feedback-stat"><span>No feedback data yet</span></div>';
return;
}

// Calculate recent stats (last 30 days)
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
const recentFeedback = feedbackEntries.filter(f => f.timestamp > thirtyDaysAgo);

if (recentFeedback.length === 0) {
statsElement.innerHTML = '<div class="feedback-stat"><span>No recent feedback (30 days)</span></div>';
return;
}

// Calculate metrics
const positiveCount = recentFeedback.filter(f => f.rating === 'positive').length;
const negativeCount = recentFeedback.filter(f => f.rating === 'negative').length;
const total = positiveCount + negativeCount;
const successRate = total > 0 ? Math.round((positiveCount / total) * 100) : 0;

// Display stats
statsElement.innerHTML = `
<div class="feedback-stat">
@ -139,7 +139,7 @@ async function loadFeedbackAnalytics() {
<span>${feedbackEntries.length} entries</span>
</div>
`;

// Show suggestions if available
const analysisData = allData.feedbackAnalysis;
if (analysisData && analysisData.suggestions && analysisData.suggestions.length > 0) {
@ -150,10 +150,10 @@ async function loadFeedbackAnalytics() {
} else {
suggestionsElement.style.display = 'none';
}

} catch (error) {
console.error('Error loading feedback analytics:', error);
document.getElementById('feedbackStats').innerHTML =
document.getElementById('feedbackStats').innerHTML =
'<div class="feedback-stat"><span>Error loading feedback data</span></div>';
}
}
@ -164,22 +164,22 @@ document.getElementById('viewFeedback').addEventListener('click', async () => {
const allData = await new Promise(resolve => {
chrome.storage.local.get(null, resolve);
});

const feedbackEntries = Object.entries(allData)
.filter(([key]) => key.startsWith('feedback_'))
.map(([key, value]) => value)
.sort((a, b) => b.timestamp - a.timestamp);

const analysisData = allData.feedbackAnalysis;

// Create HTML page with feedback data
const html = generateFeedbackReportHTML(feedbackEntries, analysisData);

// Create blob and open in new tab
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
chrome.tabs.create({ url: url });

} catch (error) {
console.error('Error viewing feedback:', error);
alert('Error loading feedback data');
@ -189,7 +189,7 @@ document.getElementById('viewFeedback').addEventListener('click', async () => {
// Generate HTML report for feedback data
function generateFeedbackReportHTML(feedbackEntries, analysisData) {
const formatDate = (timestamp) => new Date(timestamp).toLocaleString();

return `
<!DOCTYPE html>
<html>
@ -214,14 +214,14 @@ function generateFeedbackReportHTML(feedbackEntries, analysisData) {
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
th { background-color: #f8f9fa; }
.delete-btn {
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 11px;
.delete-btn {
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 11px;
transition: background 0.2s;
}
.delete-btn:hover { background: #c82333; }
@ -236,7 +236,7 @@ function generateFeedbackReportHTML(feedbackEntries, analysisData) {
<h1>FreeScout GPT Assistant - Feedback Report</h1>
<p>Generated on ${formatDate(Date.now())}</p>
</div>

${analysisData ? `
<div class="stats">
<div class="stat-card">
@ -252,7 +252,7 @@ function generateFeedbackReportHTML(feedbackEntries, analysisData) {
<div style="font-size: 14px;">${formatDate(analysisData.timestamp)}</div>
</div>
</div>

${analysisData.suggestions && analysisData.suggestions.length > 0 ? `
<div class="suggestions">
<h3>&#x1F50D; Improvement Suggestions</h3>
@ -261,25 +261,25 @@ function generateFeedbackReportHTML(feedbackEntries, analysisData) {
</ul>
</div>
` : ''}

${analysisData.commonIssues && analysisData.commonIssues.length > 0 ? `
<div style="margin: 20px 0;">
<h3>&#x1F4CA; Common Issues</h3>
<table>
<tr><th>Issue</th><th>Frequency</th></tr>
${analysisData.commonIssues.map(({issue, count}) =>
${analysisData.commonIssues.map(({issue, count}) =>
`<tr><td>${issue.replace('_', ' ')}</td><td>${count}</td></tr>`
).join('')}
</table>
</div>
` : ''}
` : ''}
<h2>&#x1F4DD; Individual Feedback Entries</h2>

${feedbackEntries.length === 0 ? '<p>No feedback entries found.</p>' :
<h2>&#x1F4DD; Individual Feedback Entries</h2>



${feedbackEntries.length === 0 ? '<p>No feedback entries found.</p>' :
feedbackEntries.map(entry => `
<div class="feedback-entry" data-entry-id="${entry.id}">
<div class="feedback-header">
@ -288,35 +288,35 @@ function generateFeedbackReportHTML(feedbackEntries, analysisData) {
<span style="margin-left: 15px; color: #6c757d;">${formatDate(entry.timestamp)}</span>
</div>
</div>

<div class="response-text">
<strong>Generated Response:</strong><br>
${entry.generatedResponse.substring(0, 300)}${entry.generatedResponse.length > 300 ? '...' : ''}
</div>

${entry.notes ? `
<div class="notes">
<strong>Feedback Notes:</strong><br>
${entry.notes}
</div>
` : ''}

${entry.customerInfo ? `
<div class="customer-info">
<strong>Customer Context:</strong>
${entry.customerInfo.name || 'Unknown'} |
Version: ${entry.customerInfo.version || 'Unknown'} |
<strong>Customer Context:</strong>
${entry.customerInfo.name || 'Unknown'} |
Version: ${entry.customerInfo.version || 'Unknown'} |
Status: ${entry.customerInfo.versionStatus || 'Unknown'}
</div>
` : ''}
</div>
`).join('')
}

<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #dee2e6; color: #6c757d; font-size: 12px;">
<p>This report contains ${feedbackEntries.length} feedback entries. Data is stored locally in your browser.</p>
</div>

<div style="margin-top: 20px; padding: 15px; background: #e9ecef; border-radius: 8px;">
<p><strong>Note:</strong> Delete functionality is not available in this static report view due to browser security restrictions. To delete feedback entries, please use the "Clear Feedback" button in the extension settings.</p>
</div>
@ -335,7 +335,7 @@ document.getElementById('clearOld30').addEventListener('click', async () => {
cutoffDate: Date.now() - (30 * 24 * 60 * 60 * 1000)
}, resolve);
});

if (response && response.success) {
loadFeedbackAnalytics();
alert(`Cleared ${response.deletedCount} entries older than 30 days.`);
@ -359,7 +359,7 @@ document.getElementById('clearOld90').addEventListener('click', async () => {
cutoffDate: Date.now() - (90 * 24 * 60 * 60 * 1000)
}, resolve);
});

if (response && response.success) {
loadFeedbackAnalytics();
alert(`Cleared ${response.deletedCount} entries older than 90 days.`);
@ -382,7 +382,7 @@ document.getElementById('clearNegative').addEventListener('click', async () => {
action: 'deleteNegativeFeedbackEntries'
}, resolve);
});

if (response && response.success) {
loadFeedbackAnalytics();
alert(`Cleared ${response.deletedCount} negative feedback entries.`);
@ -403,24 +403,24 @@ document.getElementById('clearFeedback').addEventListener('click', async () => {
const allData = await new Promise(resolve => {
chrome.storage.local.get(null, resolve);
});

// Find all feedback keys
const feedbackKeys = Object.keys(allData).filter(key =>
const feedbackKeys = Object.keys(allData).filter(key =>
key.startsWith('feedback_') || key === 'feedbackAnalysis'
);

if (feedbackKeys.length > 0) {
await new Promise(resolve => {
chrome.storage.local.remove(feedbackKeys, resolve);
});

// Refresh the display
loadFeedbackAnalytics();
alert(`Cleared ${feedbackKeys.length} feedback entries.`);
} else {
alert('No feedback data to clear.');
}

} catch (error) {
console.error('Error clearing feedback:', error);
alert('Error clearing feedback data.');
@ -435,7 +435,7 @@ document.getElementById('clearCache').onclick = () => {
const statusElement = document.getElementById('cacheStatus');
statusElement.textContent = 'Cache cleared successfully';
statusElement.className = 'cache-status cache-not-cached';

// Refresh cache status after a brief delay
setTimeout(() => {
const docsUrl = document.getElementById('docsUrl').value;
@ -449,6 +449,54 @@ document.getElementById('clearCache').onclick = () => {
});
};

// Test fetch button handler
document.getElementById('testFetch').onclick = () => {
const docsUrl = document.getElementById('docsUrl').value;
const statusElement = document.getElementById('cacheStatus');

if (!docsUrl) {
statusElement.textContent = 'Please enter a documentation URL first';
statusElement.className = 'cache-status cache-not-cached';
return;
}

statusElement.textContent = 'Testing documentation fetch...';
statusElement.className = 'cache-status';

// First clear the cache to force a fresh fetch
chrome.runtime.sendMessage({ action: 'clearDocsCache' }, (clearResponse) => {
// Then fetch the docs
chrome.runtime.sendMessage({ action: 'fetchDocs', url: docsUrl }, (response) => {
console.log('Test fetch response:', response);

if (response && response.success) {
const docsCount = response.docs?.length || 0;
const totalChars = response.docs?.reduce((sum, doc) => sum + (doc.content?.length || 0), 0) || 0;

if (docsCount > 0) {
statusElement.textContent = `Success! Loaded ${docsCount} documents (${totalChars.toLocaleString()} characters)`;
statusElement.className = 'cache-status cache-cached';

// Log first document as sample
if (response.docs[0]) {
console.log('Sample document:', {
title: response.docs[0].title,
contentLength: response.docs[0].content?.length || 0,
firstChars: response.docs[0].content?.substring(0, 200)
});
}
} else {
statusElement.textContent = 'Fetch succeeded but no documents were parsed. Check the Service Worker console for details.';
statusElement.className = 'cache-status cache-not-cached';
}
} else {
statusElement.textContent = `Fetch failed: ${response?.error || 'Unknown error'}. Check Service Worker console.`;
statusElement.className = 'cache-status cache-not-cached';
}
});
});
};

// Update cache status when docs URL changes
document.getElementById('docsUrl').addEventListener('blur', function() {
checkCacheStatus(this.value);
@ -463,7 +511,7 @@ document.getElementById('save').onclick = () => {
const maxTokens = parseInt(document.getElementById('maxTokens').value) || 1000;
const keyboardShortcut = document.getElementById('keyboardShortcut').value || 'Ctrl+Shift+G';
const enableFeedback = document.getElementById('enableFeedback').checked;

chrome.storage.local.set({ systemPrompt, docsUrl, openaiKey, openaiModel, temperature, maxTokens, keyboardShortcut, enableFeedback }, () => {
// Clear docs cache when settings are saved
chrome.runtime.sendMessage({ action: 'clearDocsCache' }, (response) => {
@ -471,7 +519,7 @@ document.getElementById('save').onclick = () => {
console.log('Docs cache cleared');
}
alert('Settings saved!');

// Update cache status after saving
checkCacheStatus(docsUrl);
});

352
utils/htmlSanitizer.js Normal file
View file

@ -0,0 +1,352 @@
/**
* HTML Sanitization Utility
* Provides secure HTML sanitization to prevent XSS attacks
* Uses a whitelist approach for allowed tags and attributes
*/

(function(global) {
'use strict';
class HTMLSanitizer {
// Configuration for allowed HTML elements and attributes
static ALLOWED_TAGS = [
'p', 'br', 'div', 'span',
'strong', 'b', 'em', 'i', 'u',
'a', 'ul', 'ol', 'li',
'blockquote', 'code', 'pre',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
];

static ALLOWED_ATTRIBUTES = {
'a': ['href', 'target', 'rel', 'title'],
'div': ['class', 'id'],
'span': ['class'],
'code': ['class'],
'pre': ['class']
};

// URL schemes that are safe for href attributes
static ALLOWED_SCHEMES = ['http', 'https', 'mailto'];

// Regex patterns for dangerous content
static DANGEROUS_PATTERNS = [
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
/on\w+\s*=/gi, // Event handlers like onclick, onmouseover
/javascript:/gi,
/data:text\/html/gi,
/<iframe/gi,
/<embed/gi,
/<object/gi
];

/**
* Main sanitization method
* @param {string} html - HTML string to sanitize
* @param {Object} options - Optional configuration
* @returns {string} Sanitized HTML
*/
static sanitize(html, options = {}) {
if (!html || typeof html !== 'string') {
return '';
}

// Merge options with defaults
const config = {
allowedTags: options.allowedTags || this.ALLOWED_TAGS,
allowedAttributes: options.allowedAttributes || this.ALLOWED_ATTRIBUTES,
allowedSchemes: options.allowedSchemes || this.ALLOWED_SCHEMES,
stripDangerous: options.stripDangerous !== false,
convertLineBreaks: options.convertLineBreaks !== false
};

let sanitized = html;

// Step 1: Remove obviously dangerous patterns
if (config.stripDangerous) {
sanitized = this.removeDangerousPatterns(sanitized);
}

// Step 2: Parse and rebuild HTML with whitelist
sanitized = this.parseAndSanitize(sanitized, config);

// Step 3: Convert markdown-style formatting if needed
if (options.convertMarkdown !== false) {
sanitized = this.convertMarkdownToHTML(sanitized);
}

// Step 4: Convert line breaks to <br> tags if needed
if (config.convertLineBreaks) {
sanitized = this.convertLineBreaks(sanitized);
}

// Step 5: Final validation
sanitized = this.finalValidation(sanitized);

return sanitized;
}

/**
* Remove dangerous patterns using regex
*/
static removeDangerousPatterns(html) {
let cleaned = html;
static removeDangerousPatterns(html) {
let cleaned = html;
this.DANGEROUS_PATTERNS.forEach(pattern => {
// Reset lastIndex to ensure consistent behavior
pattern.lastIndex = 0;
cleaned = cleaned.replace(pattern, '');
});
return cleaned;
}

return cleaned;
}

/**
* Parse HTML and rebuild with only allowed elements
*/
static parseAndSanitize(html, config) {
// Create a temporary container
const temp = document.createElement('div');
temp.innerHTML = html;

// Recursively sanitize all elements
this.sanitizeNode(temp, config);

return temp.innerHTML;
}

/**
* Recursively sanitize a DOM node
*/
static sanitizeNode(node, config) {
// Get all child elements (create array copy to avoid live collection issues)
const children = Array.from(node.children);

children.forEach(child => {
const tagName = child.tagName.toLowerCase();

// Remove element if not in whitelist
if (!config.allowedTags.includes(tagName)) {
// Keep the text content but remove the element
const textContent = child.textContent;
const textNode = document.createTextNode(textContent);
child.parentNode.replaceChild(textNode, child);
return;
}

// Sanitize attributes
this.sanitizeAttributes(child, tagName, config);

// Recursively sanitize children
this.sanitizeNode(child, config);
});
}

/**
* Sanitize attributes of an element
*/
static sanitizeAttributes(element, tagName, config) {
const allowedAttrs = config.allowedAttributes[tagName] || [];
const attributes = Array.from(element.attributes);

attributes.forEach(attr => {
const attrName = attr.name.toLowerCase();
const attrValue = attr.value;

// Remove if not in whitelist
if (!allowedAttrs.includes(attrName)) {
element.removeAttribute(attrName);
return;
}

// Special handling for href attributes
if (attrName === 'href') {
const sanitizedHref = this.sanitizeURL(attrValue, config.allowedSchemes);
if (sanitizedHref) {
element.setAttribute('href', sanitizedHref);
// Add security attributes for external links
if (sanitizedHref.startsWith('http')) {
element.setAttribute('rel', 'noopener noreferrer');
if (!element.hasAttribute('target')) {
element.setAttribute('target', '_blank');
}
}
} else {
element.removeAttribute('href');
}
}

// Remove javascript: and data: protocols from any attribute
if (attrValue.includes('javascript:') || attrValue.includes('data:')) {
element.removeAttribute(attrName);
}
});
}

/**
* Sanitize URLs
*/
static sanitizeURL(url, allowedSchemes) {
if (!url) return null;

// Trim and lowercase for checking
const trimmed = url.trim();
const lower = trimmed.toLowerCase();

// Check for dangerous protocols
if (lower.startsWith('javascript:') ||
lower.startsWith('data:') ||
lower.startsWith('vbscript:')) {
return null;
}

// Check if URL starts with allowed scheme
const hasAllowedScheme = allowedSchemes.some(scheme =>
lower.startsWith(scheme + ':')
);

// If no scheme, assume it's a relative URL (safe)
if (!lower.match(/^[a-z]+:/)) {
return trimmed;
}

// Return URL only if it has an allowed scheme
return hasAllowedScheme ? trimmed : null;
}

/**
* Convert markdown-style formatting to HTML
*/
static convertMarkdownToHTML(text) {
// Convert markdown links [text](url) to HTML
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
const sanitizedUrl = this.sanitizeURL(url, this.ALLOWED_SCHEMES);
if (sanitizedUrl) {
return `<a href="${this.escapeHtml(sanitizedUrl)}" target="_blank" rel="noopener noreferrer">${this.escapeHtml(linkText)}</a>`;
}
return linkText;
});

// Convert **bold** to <strong>
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');

// Convert *italic* to <em>
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');

// Convert `code` to <code>
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');

return text;
}

/**
* Convert line breaks to <br> tags
*/
static convertLineBreaks(text) {
// Convert \n to <br> but not within <pre> tags
const parts = text.split(/(<pre>[\s\S]*?<\/pre>)/);
return parts.map((part, index) => {
// Don't convert line breaks in <pre> tags (odd indices)
if (index % 2 === 1) {
return part;
}
return part.replace(/\n/g, '<br>');
}).join('');
}

/**
* Final validation pass
*/
static finalValidation(html) {
// Remove any remaining script tags that might have been injected
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
// Remove any remaining event handlers
html = html.replace(/on\w+\s*=/gi, '');
return html;
}

/**
* Escape HTML special characters
*/
static escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;'
};
return text.replace(/[&<>"'/]/g, char => map[char]);
}

/**
* Sanitize for plain text display (no HTML)
*/
static sanitizeText(text) {
if (!text || typeof text !== 'string') {
return '';
}
return this.escapeHtml(text);
}

/**
* Create a safe HTML element from text
*/
static createSafeElement(tagName, text, attributes = {}) {
const element = document.createElement(tagName);
// Set text content (automatically escaped)
element.textContent = text;
// Add safe attributes
Object.keys(attributes).forEach(key => {
const value = attributes[key];
if (typeof value === 'string') {
element.setAttribute(key, this.escapeHtml(value));
}
});
return element;
}

/**
* Validate that HTML string is safe (for testing)
*/
static isSafe(html) {
// Check for dangerous patterns
for (let pattern of this.DANGEROUS_PATTERNS) {
if (pattern.test(html)) {
return false;
}
}
// Parse and check for disallowed tags
const temp = document.createElement('div');
temp.innerHTML = html;
const allElements = temp.getElementsByTagName('*');
for (let element of allElements) {
const tagName = element.tagName.toLowerCase();
if (!this.ALLOWED_TAGS.includes(tagName)) {
return false;
}
}
return true;
}
}

// Export to global scope
global.HTMLSanitizer = HTMLSanitizer;
})(window);