feat: Add popular labels and search APIs with history management

- 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
This commit is contained in:
yangdx 2025-09-20 02:03:47 +08:00
parent 6dcc902a09
commit 9db8f2fce5
15 changed files with 525 additions and 130 deletions

View file

@ -45,6 +45,85 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
status_code=500, detail=f"Error getting graph labels: {str(e)}"
)
@router.get("/graph/label/popular", dependencies=[Depends(combined_auth)])
async def get_popular_labels(
limit: int = Query(
300, description="Maximum number of popular labels to return", ge=1, le=1000
),
):
"""
Get popular labels by node degree (most connected entities)
Args:
limit (int): Maximum number of labels to return (default: 300, max: 1000)
Returns:
List[str]: List of popular labels sorted by degree (highest first)
"""
try:
# Check if the storage has the get_popular_labels method
if hasattr(rag.chunk_entity_relation_graph, "get_popular_labels"):
return await rag.chunk_entity_relation_graph.get_popular_labels(limit)
else:
# Fallback to get_graph_labels for compatibility
logger.warning(
"Storage doesn't support get_popular_labels, falling back to get_graph_labels"
)
all_labels = await rag.get_graph_labels()
return all_labels[:limit]
except Exception as e:
logger.error(f"Error getting popular labels: {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting popular labels: {str(e)}"
)
@router.get("/graph/label/search", dependencies=[Depends(combined_auth)])
async def search_labels(
q: str = Query(..., description="Search query string"),
limit: int = Query(
50, description="Maximum number of search results to return", ge=1, le=100
),
):
"""
Search labels with fuzzy matching
Args:
q (str): Search query string
limit (int): Maximum number of results to return (default: 50, max: 100)
Returns:
List[str]: List of matching labels sorted by relevance
"""
try:
# Check if the storage has the search_labels method
if hasattr(rag.chunk_entity_relation_graph, "search_labels"):
return await rag.chunk_entity_relation_graph.search_labels(q, limit)
else:
# Fallback to client-side filtering for compatibility
logger.warning(
"Storage doesn't support search_labels, falling back to client-side filtering"
)
all_labels = await rag.get_graph_labels()
query_lower = q.lower().strip()
if not query_lower:
return []
# Simple client-side filtering
matches = []
for label in all_labels:
if query_lower in label.lower():
matches.append(label)
return matches[:limit]
except Exception as e:
logger.error(f"Error searching labels with query '{q}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error searching labels: {str(e)}"
)
@router.get("/graphs", dependencies=[Depends(combined_auth)])
async def get_knowledge_graph(
label: str = Query(..., description="Label to get knowledge graph for"),

View file

@ -212,6 +212,87 @@ class NetworkXStorage(BaseGraphStorage):
# Return sorted list
return sorted(list(labels))
async def get_popular_labels(self, limit: int = 300) -> list[str]:
"""
Get popular labels by node degree (most connected entities)
Args:
limit: Maximum number of labels to return
Returns:
List of labels sorted by degree (highest first)
"""
graph = await self._get_graph()
# Get degrees of all nodes and sort by degree descending
degrees = dict(graph.degree())
sorted_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)
# Return top labels limited by the specified limit
popular_labels = [str(node) for node, _ in sorted_nodes[:limit]]
logger.debug(
f"[{self.workspace}] Retrieved {len(popular_labels)} popular labels (limit: {limit})"
)
return popular_labels
async def search_labels(self, query: str, limit: int = 50) -> list[str]:
"""
Search labels with fuzzy matching
Args:
query: Search query string
limit: Maximum number of results to return
Returns:
List of matching labels sorted by relevance
"""
graph = await self._get_graph()
query_lower = query.lower().strip()
if not query_lower:
return []
# Collect matching nodes with relevance scores
matches = []
for node in graph.nodes():
node_str = str(node)
node_lower = node_str.lower()
# Skip if no match
if query_lower not in node_lower:
continue
# Calculate relevance score
# Exact match gets highest score
if node_lower == query_lower:
score = 1000
# Prefix match gets high score
elif node_lower.startswith(query_lower):
score = 500
# Contains match gets base score, with bonus for shorter strings
else:
# Shorter strings with matches are more relevant
score = 100 - len(node_str)
# Bonus for word boundary matches
if f" {query_lower}" in node_lower or f"_{query_lower}" in node_lower:
score += 50
matches.append((node_str, score))
# Sort by relevance score (desc) then alphabetically
matches.sort(key=lambda x: (-x[1], x[0]))
# Return top matches limited by the specified limit
search_results = [match[0] for match in matches[:limit]]
logger.debug(
f"[{self.workspace}] Search query '{query}' returned {len(search_results)} results (limit: {limit})"
)
return search_results
async def get_knowledge_graph(
self,
node_label: str,

View file

@ -1,5 +1,5 @@
import axios, { AxiosError } from 'axios'
import { backendBaseUrl } from '@/lib/constants'
import { backendBaseUrl, popularLabelsDefaultLimit, searchLabelsDefaultLimit } from '@/lib/constants'
import { errorMessage } from '@/lib/utils'
import { useSettingsStore } from '@/stores/settings'
import { navigationService } from '@/services/navigation'
@ -319,6 +319,16 @@ export const getGraphLabels = async (): Promise<string[]> => {
return response.data
}
export const getPopularLabels = async (limit: number = popularLabelsDefaultLimit): Promise<string[]> => {
const response = await axiosInstance.get(`/graph/label/popular?limit=${limit}`)
return response.data
}
export const searchLabels = async (query: string, limit: number = searchLabelsDefaultLimit): Promise<string[]> => {
const response = await axiosInstance.get(`/graph/label/search?q=${encodeURIComponent(query)}&limit=${limit}`)
return response.data
}
export const checkHealth = async (): Promise<
LightragStatus | { status: 'error'; message: string }
> => {

View file

@ -2,124 +2,101 @@ import { useCallback, useEffect } from 'react'
import { AsyncSelect } from '@/components/ui/AsyncSelect'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import { labelListLimit, controlButtonVariant } from '@/lib/constants'
import MiniSearch from 'minisearch'
import {
dropdownDisplayLimit,
controlButtonVariant,
popularLabelsDefaultLimit,
searchLabelsDefaultLimit
} from '@/lib/constants'
import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react'
import Button from '@/components/ui/Button'
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
import { getPopularLabels, searchLabels } from '@/api/lightrag'
const GraphLabels = () => {
const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel()
const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
const labelsFetchAttempted = useGraphStore.use.labelsFetchAttempted()
// Remove initial label fetch effect as it's now handled by fetchGraph based on lastSuccessfulQueryLabel
// Initialize search history on component mount
useEffect(() => {
const initializeHistory = async () => {
const history = SearchHistoryManager.getHistory()
const getSearchEngine = useCallback(() => {
// Create search engine
const searchEngine = new MiniSearch({
idField: 'id',
fields: ['value'],
searchOptions: {
prefix: true,
fuzzy: 0.2,
boost: {
label: 2
if (history.length === 0) {
// If no history exists, fetch popular labels and initialize
try {
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
await SearchHistoryManager.initializeWithDefaults(popularLabels)
} catch (error) {
console.error('Failed to initialize search history:', error)
// No fallback needed, API is the source of truth
}
}
})
// Add documents
const documents = allDatabaseLabels.map((str, index) => ({ id: index, value: str }))
searchEngine.addAll(documents)
return {
labels: allDatabaseLabels,
searchEngine
}
}, [allDatabaseLabels])
initializeHistory()
}, [])
const fetchData = useCallback(
async (query?: string): Promise<string[]> => {
const { labels, searchEngine } = getSearchEngine()
let results: string[] = [];
if (!query || query.trim() === '' || query.trim() === '*') {
// Empty query: return search history
results = SearchHistoryManager.getHistoryLabels(dropdownDisplayLimit)
} else {
// Non-empty query: call backend search API
try {
const apiResults = await searchLabels(query.trim(), searchLabelsDefaultLimit)
results = apiResults.length <= dropdownDisplayLimit
? apiResults
: [...apiResults.slice(0, dropdownDisplayLimit), '...']
} catch (error) {
console.error('Search API failed, falling back to local history search:', error)
let result: string[] = labels
if (query) {
// Search labels using MiniSearch
result = searchEngine.search(query).map((r: { id: number }) => labels[r.id])
// Add middle-content matching if results are few
// This enables matching content in the middle of text, not just from the beginning
if (result.length < 15) {
// Get already matched labels to avoid duplicates
const matchedLabels = new Set(result)
// Perform middle-content matching on all labels
const middleMatchResults = labels.filter(label => {
// Skip already matched labels
if (matchedLabels.has(label)) return false
// Match if label contains query string but doesn't start with it
return label &&
typeof label === 'string' &&
!label.toLowerCase().startsWith(query.toLowerCase()) &&
label.toLowerCase().includes(query.toLowerCase())
})
// Merge results
result = [...result, ...middleMatchResults]
// Fallback to local history search
const history = SearchHistoryManager.getHistory()
const queryLower = query.toLowerCase().trim()
results = history
.filter(item => item.label.toLowerCase().includes(queryLower))
.map(item => item.label)
.slice(0, dropdownDisplayLimit)
}
}
return result.length <= labelListLimit
? result
: [...result.slice(0, labelListLimit), '...']
// Always show '*' at the top, and remove duplicates
const finalResults = ['*', ...results.filter(label => label !== '*')];
return finalResults;
},
[getSearchEngine]
[]
)
// Validate label
useEffect(() => {
const handleRefresh = useCallback(async () => {
// Clear search history
SearchHistoryManager.clearHistory()
if (labelsFetchAttempted) {
if (allDatabaseLabels.length > 1) {
if (label && label !== '*' && !allDatabaseLabels.includes(label)) {
console.log(`Label "${label}" not in available labels, setting to "*"`);
useSettingsStore.getState().setQueryLabel('*');
} else {
console.log(`Label "${label}" is valid`);
}
} else if (label && allDatabaseLabels.length <= 1 && label && label !== '*') {
console.log('Available labels list is empty, setting label to empty');
useSettingsStore.getState().setQueryLabel('');
}
useGraphStore.getState().setLabelsFetchAttempted(false)
// Reinitialize with popular labels
try {
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
await SearchHistoryManager.initializeWithDefaults(popularLabels)
} catch (error) {
console.error('Failed to reload popular labels:', error)
// No fallback needed
}
}, [allDatabaseLabels, label, labelsFetchAttempted]);
const handleRefresh = useCallback(() => {
// Reset fetch status flags
// Reset fetch status flags to trigger UI refresh
useGraphStore.getState().setLabelsFetchAttempted(false)
useGraphStore.getState().setGraphDataFetchAttempted(false)
// Clear last successful query label to ensure labels are fetched
// Clear last successful query label to ensure labels are fetched,
// which is the key to forcing a data refresh.
useGraphStore.getState().setLastSuccessfulQueryLabel('')
// Get current label
const currentLabel = useSettingsStore.getState().queryLabel
// Reset to default label to ensure consistency
useSettingsStore.getState().setQueryLabel('*')
// If current label is empty, use default label '*'
if (!currentLabel) {
useSettingsStore.getState().setQueryLabel('*')
} else {
// Trigger data reload
useSettingsStore.getState().setQueryLabel('')
setTimeout(() => {
useSettingsStore.getState().setQueryLabel(currentLabel)
}, 0)
}
// Force a data refresh by incrementing the version counter in the graph store.
// This is the reliable way to trigger a re-fetch of the graph data.
useGraphStore.getState().incrementGraphDataVersion()
}, []);
return (
@ -160,6 +137,11 @@ const GraphLabels = () => {
newLabel = '*';
}
// Add selected label to search history (except for special cases)
if (newLabel && newLabel !== '*' && newLabel !== '...' && newLabel.trim() !== '') {
SearchHistoryManager.addToHistory(newLabel);
}
// Reset graphDataFetchAttempted flag to ensure data fetch is triggered
useGraphStore.getState().setGraphDataFetchAttempted(false);
@ -167,6 +149,7 @@ const GraphLabels = () => {
useSettingsStore.getState().setQueryLabel(newLabel);
}}
clearable={false} // Prevent clearing value on reselect
debounceTime={500}
/>
</div>
)

View file

@ -419,7 +419,7 @@ export default function QuerySettings() {
className="mr-1 cursor-pointer"
id="only_need_context"
checked={querySettings.only_need_context}
onCheckedChange={(checked) => {
onCheckedChange={(checked) => {
handleChange('only_need_context', checked)
if (checked) {
handleChange('only_need_prompt', false)

View file

@ -63,6 +63,8 @@ export interface AsyncSelectProps<T> {
triggerTooltip?: string
/** Allow clearing the selection */
clearable?: boolean
/** Debounce time in milliseconds */
debounceTime?: number
}
export function AsyncSelect<T>({
@ -84,7 +86,8 @@ export function AsyncSelect<T>({
searchInputClassName,
noResultsMessage,
triggerTooltip,
clearable = true
clearable = true,
debounceTime = 150
}: AsyncSelectProps<T>) {
const [mounted, setMounted] = useState(false)
const [open, setOpen] = useState(false)
@ -94,7 +97,7 @@ export function AsyncSelect<T>({
const [selectedValue, setSelectedValue] = useState(value)
const [selectedOption, setSelectedOption] = useState<T | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : debounceTime)
const [originalOptions, setOriginalOptions] = useState<T[]>([])
const [initialValueDisplay, setInitialValueDisplay] = useState<React.ReactNode | null>(null)

View file

@ -225,18 +225,6 @@ export type EdgeType = {
const fetchGraph = async (label: string, maxDepth: number, maxNodes: number) => {
let rawData: any = null;
// Check if we need to fetch all database labels first
const lastSuccessfulQueryLabel = useGraphStore.getState().lastSuccessfulQueryLabel;
if (!lastSuccessfulQueryLabel) {
console.log('Last successful queryLabel is empty');
try {
await useGraphStore.getState().fetchAllDatabaseLabels();
} catch (e) {
console.error('Failed to fetch all database labels:', e);
// Continue with graph fetch even if labels fetch fails
}
}
// Trigger GraphLabels component to check if the label is valid
// console.log('Setting labelsFetchAttempted to true');
useGraphStore.getState().setLabelsFetchAttempted(true)
@ -411,6 +399,7 @@ const useLightrangeGraph = () => {
const isFetching = useGraphStore.use.isFetching()
const nodeToExpand = useGraphStore.use.nodeToExpand()
const nodeToPrune = useGraphStore.use.nodeToPrune()
const graphDataVersion = useGraphStore.use.graphDataVersion()
// Use ref to track if data has been loaded and initial load
@ -597,7 +586,7 @@ const useLightrangeGraph = () => {
state.setLastSuccessfulQueryLabel('') // Clear last successful query label on error
})
}
}, [queryLabel, maxQueryDepth, maxNodes, isFetching, t])
}, [queryLabel, maxQueryDepth, maxNodes, isFetching, t, graphDataVersion])
// Handle node expansion
useEffect(() => {

View file

@ -20,6 +20,17 @@ export const edgeColorHighlighted = '#B2EBF2'
export const searchResultLimit = 50
export const labelListLimit = 100
// Search History Configuration
export const searchHistoryMaxItems = 500
export const searchHistoryVersion = '1.0'
// API Request Limits
export const popularLabelsDefaultLimit = 300
export const searchLabelsDefaultLimit = 50
// UI Display Limits
export const dropdownDisplayLimit = 300
export const minNodeSize = 4
export const maxNodeSize = 20

View file

@ -336,7 +336,7 @@
"label": "التسمية",
"placeholder": "ابحث في التسميات...",
"andOthers": "و {{count}} آخرون",
"refreshTooltip": "إعادة تحميل البيانات (بعد إضافة الملف)"
"refreshTooltip": "إعادة تعيين بيانات الرسم البياني وسجل البحث (مطلوب بعد تغيير المستندات)"
},
"emptyGraph": "فارغ (حاول إعادة التحميل)"
},

View file

@ -336,7 +336,7 @@
"label": "Label",
"placeholder": "Search labels...",
"andOthers": "And {count} others",
"refreshTooltip": "Reload data(After file added)"
"refreshTooltip": "Reset graph data and search history (required after document changes)"
},
"emptyGraph": "Empty(Try Reload Again)"
},

View file

@ -336,7 +336,7 @@
"label": "Étiquette",
"placeholder": "Rechercher des étiquettes...",
"andOthers": "Et {{count}} autres",
"refreshTooltip": "Recharger les données (Après l'ajout de fichier)"
"refreshTooltip": "Réinitialiser les données du graphe et l'historique de recherche (requis après modification des documents)"
},
"emptyGraph": "Vide (Essayez de recharger)"
},

View file

@ -336,7 +336,7 @@
"label": "标签",
"placeholder": "搜索标签...",
"andOthers": "还有 {count} 个",
"refreshTooltip": "重载图形数据(添加文件后需重载)"
"refreshTooltip": "重置图形数据和搜索历史(文档变化后需重置)"
},
"emptyGraph": "无数据(请重载图形数据)"
},

View file

@ -336,7 +336,7 @@
"label": "標籤",
"placeholder": "搜尋標籤...",
"andOthers": "還有 {count} 個",
"refreshTooltip": "重載圖形數據(新增檔案後需重載)"
"refreshTooltip": "重設圖形資料和搜尋歷史(文件變化後需重設)"
},
"emptyGraph": "無數據(請重載圖形數據)"
},

View file

@ -1,7 +1,6 @@
import { create } from 'zustand'
import { createSelectors } from '@/lib/utils'
import { DirectedGraph } from 'graphology'
import { getGraphLabels } from '@/api/lightrag'
import MiniSearch from 'minisearch'
export type RawNodeType = {
@ -84,7 +83,6 @@ interface GraphState {
rawGraph: RawGraph | null
sigmaGraph: DirectedGraph | null
sigmaInstance: any | null
allDatabaseLabels: string[]
searchEngine: MiniSearch | null
@ -113,8 +111,6 @@ interface GraphState {
setRawGraph: (rawGraph: RawGraph | null) => void
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
setAllDatabaseLabels: (labels: string[]) => void
fetchAllDatabaseLabels: () => Promise<void>
setIsFetching: (isFetching: boolean) => void
// 搜索引擎方法
@ -160,7 +156,6 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
rawGraph: null,
sigmaGraph: null,
sigmaInstance: null,
allDatabaseLabels: ['*'],
typeColorMap: new Map<string, string>(),
@ -207,21 +202,6 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
set({ sigmaGraph });
},
setAllDatabaseLabels: (labels: string[]) => set({ allDatabaseLabels: labels }),
fetchAllDatabaseLabels: async () => {
try {
console.log('Fetching all database labels...');
const labels = await getGraphLabels();
set({ allDatabaseLabels: ['*', ...labels] });
return;
} catch (error) {
console.error('Failed to fetch all database labels:', error);
set({ allDatabaseLabels: ['*'] });
throw error;
}
},
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }),
setSigmaInstance: (instance: any) => set({ sigmaInstance: instance }),

View file

@ -0,0 +1,259 @@
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
}
}
}