diff --git a/lightrag/api/routers/graph_routes.py b/lightrag/api/routers/graph_routes.py index 42c20e6a..881e4742 100644 --- a/lightrag/api/routers/graph_routes.py +++ b/lightrag/api/routers/graph_routes.py @@ -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"), diff --git a/lightrag/kg/networkx_impl.py b/lightrag/kg/networkx_impl.py index 09668f6e..11565434 100644 --- a/lightrag/kg/networkx_impl.py +++ b/lightrag/kg/networkx_impl.py @@ -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, diff --git a/lightrag_webui/src/api/lightrag.ts b/lightrag_webui/src/api/lightrag.ts index a47c8e4c..322cc734 100644 --- a/lightrag_webui/src/api/lightrag.ts +++ b/lightrag_webui/src/api/lightrag.ts @@ -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 => { return response.data } +export const getPopularLabels = async (limit: number = popularLabelsDefaultLimit): Promise => { + const response = await axiosInstance.get(`/graph/label/popular?limit=${limit}`) + return response.data +} + +export const searchLabels = async (query: string, limit: number = searchLabelsDefaultLimit): Promise => { + 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 } > => { diff --git a/lightrag_webui/src/components/graph/GraphLabels.tsx b/lightrag_webui/src/components/graph/GraphLabels.tsx index 4938ee05..ddc22f77 100644 --- a/lightrag_webui/src/components/graph/GraphLabels.tsx +++ b/lightrag_webui/src/components/graph/GraphLabels.tsx @@ -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 => { - 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} /> ) diff --git a/lightrag_webui/src/components/retrieval/QuerySettings.tsx b/lightrag_webui/src/components/retrieval/QuerySettings.tsx index 010916df..ddcfe031 100644 --- a/lightrag_webui/src/components/retrieval/QuerySettings.tsx +++ b/lightrag_webui/src/components/retrieval/QuerySettings.tsx @@ -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) diff --git a/lightrag_webui/src/components/ui/AsyncSelect.tsx b/lightrag_webui/src/components/ui/AsyncSelect.tsx index 9a5a17f8..4b88c1c9 100644 --- a/lightrag_webui/src/components/ui/AsyncSelect.tsx +++ b/lightrag_webui/src/components/ui/AsyncSelect.tsx @@ -63,6 +63,8 @@ export interface AsyncSelectProps { triggerTooltip?: string /** Allow clearing the selection */ clearable?: boolean + /** Debounce time in milliseconds */ + debounceTime?: number } export function AsyncSelect({ @@ -84,7 +86,8 @@ export function AsyncSelect({ searchInputClassName, noResultsMessage, triggerTooltip, - clearable = true + clearable = true, + debounceTime = 150 }: AsyncSelectProps) { const [mounted, setMounted] = useState(false) const [open, setOpen] = useState(false) @@ -94,7 +97,7 @@ export function AsyncSelect({ const [selectedValue, setSelectedValue] = useState(value) const [selectedOption, setSelectedOption] = useState(null) const [searchTerm, setSearchTerm] = useState('') - const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150) + const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : debounceTime) const [originalOptions, setOriginalOptions] = useState([]) const [initialValueDisplay, setInitialValueDisplay] = useState(null) diff --git a/lightrag_webui/src/hooks/useLightragGraph.tsx b/lightrag_webui/src/hooks/useLightragGraph.tsx index 9fbc9208..ec2edd77 100644 --- a/lightrag_webui/src/hooks/useLightragGraph.tsx +++ b/lightrag_webui/src/hooks/useLightragGraph.tsx @@ -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(() => { diff --git a/lightrag_webui/src/lib/constants.ts b/lightrag_webui/src/lib/constants.ts index 30b13f4f..21c91869 100644 --- a/lightrag_webui/src/lib/constants.ts +++ b/lightrag_webui/src/lib/constants.ts @@ -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 diff --git a/lightrag_webui/src/locales/ar.json b/lightrag_webui/src/locales/ar.json index 4a932c28..48c68f1f 100644 --- a/lightrag_webui/src/locales/ar.json +++ b/lightrag_webui/src/locales/ar.json @@ -336,7 +336,7 @@ "label": "التسمية", "placeholder": "ابحث في التسميات...", "andOthers": "و {{count}} آخرون", - "refreshTooltip": "إعادة تحميل البيانات (بعد إضافة الملف)" + "refreshTooltip": "إعادة تعيين بيانات الرسم البياني وسجل البحث (مطلوب بعد تغيير المستندات)" }, "emptyGraph": "فارغ (حاول إعادة التحميل)" }, diff --git a/lightrag_webui/src/locales/en.json b/lightrag_webui/src/locales/en.json index 293cba3f..0421e1e1 100644 --- a/lightrag_webui/src/locales/en.json +++ b/lightrag_webui/src/locales/en.json @@ -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)" }, diff --git a/lightrag_webui/src/locales/fr.json b/lightrag_webui/src/locales/fr.json index 1906ac88..5a6fc15c 100644 --- a/lightrag_webui/src/locales/fr.json +++ b/lightrag_webui/src/locales/fr.json @@ -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)" }, diff --git a/lightrag_webui/src/locales/zh.json b/lightrag_webui/src/locales/zh.json index bb1f96c2..e7bb3c03 100644 --- a/lightrag_webui/src/locales/zh.json +++ b/lightrag_webui/src/locales/zh.json @@ -336,7 +336,7 @@ "label": "标签", "placeholder": "搜索标签...", "andOthers": "还有 {count} 个", - "refreshTooltip": "重载图形数据(添加文件后需重载)" + "refreshTooltip": "重置图形数据和搜索历史(文档变化后需重置)" }, "emptyGraph": "无数据(请重载图形数据)" }, diff --git a/lightrag_webui/src/locales/zh_TW.json b/lightrag_webui/src/locales/zh_TW.json index f648b56f..74ec53de 100644 --- a/lightrag_webui/src/locales/zh_TW.json +++ b/lightrag_webui/src/locales/zh_TW.json @@ -336,7 +336,7 @@ "label": "標籤", "placeholder": "搜尋標籤...", "andOthers": "還有 {count} 個", - "refreshTooltip": "重載圖形數據(新增檔案後需重載)" + "refreshTooltip": "重設圖形資料和搜尋歷史(文件變化後需重設)" }, "emptyGraph": "無數據(請重載圖形數據)" }, diff --git a/lightrag_webui/src/stores/graph.ts b/lightrag_webui/src/stores/graph.ts index fb035cb9..dea5079b 100644 --- a/lightrag_webui/src/stores/graph.ts +++ b/lightrag_webui/src/stores/graph.ts @@ -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 setIsFetching: (isFetching: boolean) => void // 搜索引擎方法 @@ -160,7 +156,6 @@ const useGraphStoreBase = create()((set, get) => ({ rawGraph: null, sigmaGraph: null, sigmaInstance: null, - allDatabaseLabels: ['*'], typeColorMap: new Map(), @@ -207,21 +202,6 @@ const useGraphStoreBase = create()((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 }), diff --git a/lightrag_webui/src/utils/SearchHistoryManager.ts b/lightrag_webui/src/utils/SearchHistoryManager.ts new file mode 100644 index 00000000..0af592a7 --- /dev/null +++ b/lightrag_webui/src/utils/SearchHistoryManager.ts @@ -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 { + 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 + } + } +}