- Add popular/search label endpoints - Implement SearchHistoryManager utility - Replace client-side with server search - Add graph data version tracking - Update UI for better label discovery
259 lines
7.6 KiB
TypeScript
259 lines
7.6 KiB
TypeScript
import { searchHistoryMaxItems, searchHistoryVersion } from '@/lib/constants'
|
|
|
|
/**
|
|
* SearchHistoryManager - Manages search history persistence in localStorage
|
|
*
|
|
* This utility class handles:
|
|
* - Storing and retrieving search history from localStorage
|
|
* - Managing history size limits
|
|
* - Sorting by access time and frequency
|
|
* - Version compatibility
|
|
*/
|
|
|
|
export interface SearchHistoryItem {
|
|
label: string // Label name
|
|
lastAccessed: number // Last access timestamp
|
|
accessCount: number // Access count for sorting optimization
|
|
}
|
|
|
|
export interface SearchHistoryData {
|
|
items: SearchHistoryItem[]
|
|
version: string // Data version for compatibility
|
|
workspace?: string // Workspace isolation (if needed)
|
|
}
|
|
|
|
export class SearchHistoryManager {
|
|
private static readonly STORAGE_KEY = 'lightrag_search_history'
|
|
private static readonly MAX_HISTORY = searchHistoryMaxItems
|
|
private static readonly VERSION = searchHistoryVersion
|
|
|
|
/**
|
|
* Get search history from localStorage
|
|
* @returns Array of search history items sorted by last accessed time (descending)
|
|
*/
|
|
static getHistory(): SearchHistoryItem[] {
|
|
try {
|
|
const data = localStorage.getItem(this.STORAGE_KEY)
|
|
if (!data) return []
|
|
|
|
const parsed: SearchHistoryData = JSON.parse(data)
|
|
|
|
// Version compatibility check
|
|
if (parsed.version !== this.VERSION) {
|
|
console.warn(`Search history version mismatch. Expected ${this.VERSION}, got ${parsed.version}. Clearing history.`)
|
|
this.clearHistory()
|
|
return []
|
|
}
|
|
|
|
// Ensure items is an array
|
|
if (!Array.isArray(parsed.items)) {
|
|
console.warn('Invalid search history format. Clearing history.')
|
|
this.clearHistory()
|
|
return []
|
|
}
|
|
|
|
// Sort by last accessed time (descending) then by access count (descending)
|
|
return parsed.items.sort((a, b) => {
|
|
if (b.lastAccessed !== a.lastAccessed) {
|
|
return b.lastAccessed - a.lastAccessed
|
|
}
|
|
return (b.accessCount || 0) - (a.accessCount || 0)
|
|
})
|
|
} catch (error) {
|
|
console.error('Error reading search history:', error)
|
|
this.clearHistory()
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a label to search history (or update if exists)
|
|
* @param label Label to add to history
|
|
*/
|
|
static addToHistory(label: string): void {
|
|
if (!label || typeof label !== 'string' || label.trim() === '') {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const history = this.getHistory()
|
|
const now = Date.now()
|
|
const trimmedLabel = label.trim()
|
|
|
|
// Find existing item
|
|
const existingIndex = history.findIndex(item => item.label === trimmedLabel)
|
|
|
|
if (existingIndex >= 0) {
|
|
// Update existing item
|
|
const existingItem = history[existingIndex]
|
|
existingItem.lastAccessed = now
|
|
existingItem.accessCount = (existingItem.accessCount || 0) + 1
|
|
|
|
// Move to front (will be sorted properly when saved)
|
|
history.splice(existingIndex, 1)
|
|
history.unshift(existingItem)
|
|
} else {
|
|
// Add new item to the beginning
|
|
history.unshift({
|
|
label: trimmedLabel,
|
|
lastAccessed: now,
|
|
accessCount: 1
|
|
})
|
|
}
|
|
|
|
// Limit history size
|
|
if (history.length > this.MAX_HISTORY) {
|
|
history.splice(this.MAX_HISTORY)
|
|
}
|
|
|
|
// Save to localStorage
|
|
const data: SearchHistoryData = {
|
|
items: history,
|
|
version: this.VERSION
|
|
}
|
|
|
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
|
|
} catch (error) {
|
|
console.error('Error saving search history:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all search history
|
|
*/
|
|
static clearHistory(): void {
|
|
try {
|
|
localStorage.removeItem(this.STORAGE_KEY)
|
|
} catch (error) {
|
|
console.error('Error clearing search history:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize history with default popular labels if empty
|
|
* @param popularLabels Array of popular labels to use as defaults
|
|
*/
|
|
static async initializeWithDefaults(popularLabels: string[]): Promise<void> {
|
|
const history = this.getHistory()
|
|
|
|
if (history.length === 0 && popularLabels.length > 0) {
|
|
try {
|
|
const now = Date.now()
|
|
const defaultItems: SearchHistoryItem[] = popularLabels.map((label, index) => ({
|
|
label: label.trim(),
|
|
lastAccessed: now - index, // Ensure proper ordering
|
|
accessCount: 0 // Mark as default/popular items
|
|
}))
|
|
|
|
const data: SearchHistoryData = {
|
|
items: defaultItems,
|
|
version: this.VERSION
|
|
}
|
|
|
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
|
|
} catch (error) {
|
|
console.error('Error initializing search history with defaults:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get recent searches (items with accessCount > 0)
|
|
* @param limit Maximum number of recent searches to return
|
|
* @returns Array of recent search items
|
|
*/
|
|
static getRecentSearches(limit: number = 10): SearchHistoryItem[] {
|
|
const history = this.getHistory()
|
|
return history
|
|
.filter(item => item.accessCount > 0)
|
|
.slice(0, limit)
|
|
}
|
|
|
|
/**
|
|
* Get popular recommendations (items with accessCount = 0, i.e., defaults)
|
|
* @param limit Maximum number of recommendations to return
|
|
* @returns Array of popular recommendation items
|
|
*/
|
|
static getPopularRecommendations(limit?: number): SearchHistoryItem[] {
|
|
const history = this.getHistory()
|
|
const recommendations = history.filter(item => item.accessCount === 0)
|
|
return limit ? recommendations.slice(0, limit) : recommendations
|
|
}
|
|
|
|
/**
|
|
* Get all history items as simple string array
|
|
* @param limit Maximum number of items to return
|
|
* @returns Array of label strings
|
|
*/
|
|
static getHistoryLabels(limit?: number): string[] {
|
|
const history = this.getHistory()
|
|
const labels = history.map(item => item.label)
|
|
return limit ? labels.slice(0, limit) : labels
|
|
}
|
|
|
|
/**
|
|
* Check if a label exists in history
|
|
* @param label Label to check
|
|
* @returns True if label exists in history
|
|
*/
|
|
static hasLabel(label: string): boolean {
|
|
if (!label || typeof label !== 'string') return false
|
|
const history = this.getHistory()
|
|
return history.some(item => item.label === label.trim())
|
|
}
|
|
|
|
/**
|
|
* Remove a specific label from history
|
|
* @param label Label to remove
|
|
*/
|
|
static removeLabel(label: string): void {
|
|
if (!label || typeof label !== 'string') return
|
|
|
|
try {
|
|
const history = this.getHistory()
|
|
const trimmedLabel = label.trim()
|
|
const filteredHistory = history.filter(item => item.label !== trimmedLabel)
|
|
|
|
if (filteredHistory.length !== history.length) {
|
|
const data: SearchHistoryData = {
|
|
items: filteredHistory,
|
|
version: this.VERSION
|
|
}
|
|
|
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
|
|
}
|
|
} catch (error) {
|
|
console.error('Error removing label from search history:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get storage statistics
|
|
* @returns Object with history statistics
|
|
*/
|
|
static getStats(): {
|
|
totalItems: number
|
|
recentSearches: number
|
|
popularRecommendations: number
|
|
storageSize: number
|
|
} {
|
|
const history = this.getHistory()
|
|
const recentCount = history.filter(item => item.accessCount > 0).length
|
|
const popularCount = history.filter(item => item.accessCount === 0).length
|
|
|
|
let storageSize = 0
|
|
try {
|
|
const data = localStorage.getItem(this.STORAGE_KEY)
|
|
storageSize = data ? data.length : 0
|
|
} catch {
|
|
// Ignore error
|
|
}
|
|
|
|
return {
|
|
totalItems: history.length,
|
|
recentSearches: recentCount,
|
|
popularRecommendations: popularCount,
|
|
storageSize
|
|
}
|
|
}
|
|
}
|