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:
parent
6dcc902a09
commit
9db8f2fce5
15 changed files with 525 additions and 130 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
> => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@
|
|||
"label": "التسمية",
|
||||
"placeholder": "ابحث في التسميات...",
|
||||
"andOthers": "و {{count}} آخرون",
|
||||
"refreshTooltip": "إعادة تحميل البيانات (بعد إضافة الملف)"
|
||||
"refreshTooltip": "إعادة تعيين بيانات الرسم البياني وسجل البحث (مطلوب بعد تغيير المستندات)"
|
||||
},
|
||||
"emptyGraph": "فارغ (حاول إعادة التحميل)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@
|
|||
"label": "标签",
|
||||
"placeholder": "搜索标签...",
|
||||
"andOthers": "还有 {count} 个",
|
||||
"refreshTooltip": "重载图形数据(添加文件后需重载)"
|
||||
"refreshTooltip": "重置图形数据和搜索历史(文档变化后需重置)"
|
||||
},
|
||||
"emptyGraph": "无数据(请重载图形数据)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@
|
|||
"label": "標籤",
|
||||
"placeholder": "搜尋標籤...",
|
||||
"andOthers": "還有 {count} 個",
|
||||
"refreshTooltip": "重載圖形數據(新增檔案後需重載)"
|
||||
"refreshTooltip": "重設圖形資料和搜尋歷史(文件變化後需重設)"
|
||||
},
|
||||
"emptyGraph": "無數據(請重載圖形數據)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
259
lightrag_webui/src/utils/SearchHistoryManager.ts
Normal file
259
lightrag_webui/src/utils/SearchHistoryManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue