mirror of
https://git.yylx.win/https://github.com/verygoodplugins/freescout-gpt-assistant.git
synced 2025-10-03 18:31:13 +08:00
Merge pull request #5 from zackkatz/main
This commit is contained in:
commit
0b96a38c65
14 changed files with 4430 additions and 660 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
.claude
|
||||
.serena
|
117
CHANGELOG.md
Normal file
117
CHANGELOG.md
Normal 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
|
95
README.md
95
README.md
|
@ -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**
|
||||
|
|
568
adapters/freescoutAdapter.js
Normal file
568
adapters/freescoutAdapter.js
Normal 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
1010
adapters/helpscoutAdapter.js
Normal file
File diff suppressed because it is too large
Load diff
520
adapters/platformAdapter.js
Normal file
520
adapters/platformAdapter.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
196
background.js
196
background.js
|
@ -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
1130
content.js
File diff suppressed because it is too large
Load diff
|
@ -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
344
platformDetection.js
Normal 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
534
platformManager.js
Normal 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);
|
|
@ -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
170
popup.js
|
@ -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>🔍 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>📊 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>📝 Individual Feedback Entries</h2>
|
||||
|
||||
|
||||
|
||||
${feedbackEntries.length === 0 ? '<p>No feedback entries found.</p>' :
|
||||
<h2>📝 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
352
utils/htmlSanitizer.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/'
|
||||
};
|
||||
|
||||
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);
|
Loading…
Add table
Add a link
Reference in a new issue