288 lines
7.9 KiB
TypeScript
288 lines
7.9 KiB
TypeScript
/**
|
|
* Frontend Research Cache Service
|
|
*
|
|
* Provides persistent caching for research results to survive page refreshes,
|
|
* browser restarts, and internet disconnections.
|
|
*/
|
|
|
|
import { BlogResearchResponse } from './blogWriterApi';
|
|
|
|
interface CachedResearchEntry {
|
|
result: BlogResearchResponse;
|
|
keywords: string[];
|
|
industry: string;
|
|
target_audience: string;
|
|
created_at: string;
|
|
expires_at: string;
|
|
cache_key: string;
|
|
}
|
|
|
|
interface CacheStats {
|
|
total_entries: number;
|
|
expired_entries: number;
|
|
valid_entries: number;
|
|
cache_size_mb: number;
|
|
}
|
|
|
|
class ResearchCacheService {
|
|
private readonly CACHE_KEY_PREFIX = 'alwrity_research_';
|
|
private readonly CACHE_VERSION = '1.0';
|
|
private readonly DEFAULT_TTL_HOURS = 24;
|
|
private readonly MAX_CACHE_SIZE_MB = 50; // 50MB max cache size
|
|
|
|
/**
|
|
* Generate a cache key for research parameters
|
|
*/
|
|
private generateCacheKey(keywords: string[], industry: string, target_audience: string): string {
|
|
const normalized_keywords = keywords.map(k => k.toLowerCase().trim()).sort();
|
|
const normalized_industry = (industry || 'general').toLowerCase().trim();
|
|
const normalized_audience = (target_audience || 'general').toLowerCase().trim();
|
|
|
|
const cache_string = `${normalized_keywords.join(',')}|${normalized_industry}|${normalized_audience}`;
|
|
const hash = this.simpleHash(cache_string);
|
|
|
|
return `${this.CACHE_KEY_PREFIX}${this.CACHE_VERSION}_${hash}`;
|
|
}
|
|
|
|
/**
|
|
* Simple hash function for cache keys
|
|
*/
|
|
private simpleHash(str: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const char = str.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash; // Convert to 32-bit integer
|
|
}
|
|
return Math.abs(hash).toString(36);
|
|
}
|
|
|
|
/**
|
|
* Check if a cache entry is still valid
|
|
*/
|
|
private isEntryValid(entry: CachedResearchEntry): boolean {
|
|
const now = new Date();
|
|
const expiresAt = new Date(entry.expires_at);
|
|
return now < expiresAt;
|
|
}
|
|
|
|
/**
|
|
* Clean up expired entries
|
|
*/
|
|
private cleanupExpiredEntries(): void {
|
|
const keys = Object.keys(localStorage);
|
|
const expiredKeys: string[] = [];
|
|
|
|
keys.forEach(key => {
|
|
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
|
try {
|
|
const entry = JSON.parse(localStorage.getItem(key) || '{}') as CachedResearchEntry;
|
|
if (!this.isEntryValid(entry)) {
|
|
expiredKeys.push(key);
|
|
}
|
|
} catch (error) {
|
|
// Invalid JSON, remove it
|
|
expiredKeys.push(key);
|
|
}
|
|
}
|
|
});
|
|
|
|
expiredKeys.forEach(key => {
|
|
localStorage.removeItem(key);
|
|
console.log(`Removed expired cache entry: ${key}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get cache size in MB
|
|
*/
|
|
private getCacheSizeMB(): number {
|
|
let totalSize = 0;
|
|
const keys = Object.keys(localStorage);
|
|
|
|
keys.forEach(key => {
|
|
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
|
const value = localStorage.getItem(key) || '';
|
|
totalSize += key.length + value.length;
|
|
}
|
|
});
|
|
|
|
return totalSize / (1024 * 1024); // Convert to MB
|
|
}
|
|
|
|
/**
|
|
* Evict oldest entries if cache is too large
|
|
*/
|
|
private evictOldestEntries(): void {
|
|
const keys = Object.keys(localStorage);
|
|
const cacheEntries: Array<{ key: string; created_at: string }> = [];
|
|
|
|
keys.forEach(key => {
|
|
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
|
try {
|
|
const entry = JSON.parse(localStorage.getItem(key) || '{}') as CachedResearchEntry;
|
|
cacheEntries.push({
|
|
key,
|
|
created_at: entry.created_at
|
|
});
|
|
} catch (error) {
|
|
// Invalid entry, remove it
|
|
localStorage.removeItem(key);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Sort by creation time (oldest first)
|
|
cacheEntries.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
|
|
// Remove oldest entries until we're under the limit
|
|
const maxSizeMB = this.MAX_CACHE_SIZE_MB;
|
|
while (this.getCacheSizeMB() > maxSizeMB && cacheEntries.length > 0) {
|
|
const oldest = cacheEntries.shift();
|
|
if (oldest) {
|
|
localStorage.removeItem(oldest.key);
|
|
console.log(`Evicted oldest cache entry: ${oldest.key}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cached research result
|
|
*/
|
|
getCachedResult(keywords: string[], industry: string, target_audience: string): BlogResearchResponse | null {
|
|
try {
|
|
this.cleanupExpiredEntries();
|
|
|
|
const cacheKey = this.generateCacheKey(keywords, industry, target_audience);
|
|
const cachedData = localStorage.getItem(cacheKey);
|
|
|
|
if (!cachedData) {
|
|
console.log(`Cache miss for keywords: ${keywords.join(', ')}`);
|
|
return null;
|
|
}
|
|
|
|
const entry = JSON.parse(cachedData) as CachedResearchEntry;
|
|
|
|
if (!this.isEntryValid(entry)) {
|
|
localStorage.removeItem(cacheKey);
|
|
console.log(`Cache entry expired for keywords: ${keywords.join(', ')}`);
|
|
return null;
|
|
}
|
|
|
|
console.log(`Cache hit for keywords: ${keywords.join(', ')} (saved API call)`);
|
|
return entry.result;
|
|
} catch (error) {
|
|
console.error('Error retrieving cached result:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cache a research result
|
|
*/
|
|
cacheResult(
|
|
keywords: string[],
|
|
industry: string,
|
|
target_audience: string,
|
|
result: BlogResearchResponse,
|
|
ttlHours: number = this.DEFAULT_TTL_HOURS
|
|
): void {
|
|
try {
|
|
this.cleanupExpiredEntries();
|
|
this.evictOldestEntries();
|
|
|
|
const cacheKey = this.generateCacheKey(keywords, industry, target_audience);
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + (ttlHours * 60 * 60 * 1000));
|
|
|
|
const entry: CachedResearchEntry = {
|
|
result,
|
|
keywords,
|
|
industry,
|
|
target_audience,
|
|
created_at: now.toISOString(),
|
|
expires_at: expiresAt.toISOString(),
|
|
cache_key: cacheKey
|
|
};
|
|
|
|
localStorage.setItem(cacheKey, JSON.stringify(entry));
|
|
console.log(`Cached research result for keywords: ${keywords.join(', ')}`);
|
|
} catch (error) {
|
|
console.error('Error caching result:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics
|
|
*/
|
|
getCacheStats(): CacheStats {
|
|
this.cleanupExpiredEntries();
|
|
|
|
const keys = Object.keys(localStorage);
|
|
let totalEntries = 0;
|
|
let expiredEntries = 0;
|
|
let validEntries = 0;
|
|
|
|
keys.forEach(key => {
|
|
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
|
totalEntries++;
|
|
try {
|
|
const entry = JSON.parse(localStorage.getItem(key) || '{}') as CachedResearchEntry;
|
|
if (this.isEntryValid(entry)) {
|
|
validEntries++;
|
|
} else {
|
|
expiredEntries++;
|
|
}
|
|
} catch (error) {
|
|
expiredEntries++;
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
total_entries: totalEntries,
|
|
expired_entries: expiredEntries,
|
|
valid_entries: validEntries,
|
|
cache_size_mb: this.getCacheSizeMB()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear all cached research results
|
|
*/
|
|
clearCache(): void {
|
|
const keys = Object.keys(localStorage);
|
|
keys.forEach(key => {
|
|
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
});
|
|
console.log('Research cache cleared');
|
|
}
|
|
|
|
/**
|
|
* Get all cached entries (for debugging)
|
|
*/
|
|
getAllCachedEntries(): CachedResearchEntry[] {
|
|
const entries: CachedResearchEntry[] = [];
|
|
const keys = Object.keys(localStorage);
|
|
|
|
keys.forEach(key => {
|
|
if (key.startsWith(this.CACHE_KEY_PREFIX)) {
|
|
try {
|
|
const entry = JSON.parse(localStorage.getItem(key) || '{}') as CachedResearchEntry;
|
|
entries.push(entry);
|
|
} catch (error) {
|
|
console.error(`Error parsing cache entry ${key}:`, error);
|
|
}
|
|
}
|
|
});
|
|
|
|
return entries.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const researchCache = new ResearchCacheService();
|
|
export default researchCache;
|