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)}"
|
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)])
|
@router.get("/graphs", dependencies=[Depends(combined_auth)])
|
||||||
async def get_knowledge_graph(
|
async def get_knowledge_graph(
|
||||||
label: str = Query(..., description="Label to get knowledge graph for"),
|
label: str = Query(..., description="Label to get knowledge graph for"),
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,87 @@ class NetworkXStorage(BaseGraphStorage):
|
||||||
# Return sorted list
|
# Return sorted list
|
||||||
return sorted(list(labels))
|
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(
|
async def get_knowledge_graph(
|
||||||
self,
|
self,
|
||||||
node_label: str,
|
node_label: str,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import axios, { AxiosError } from 'axios'
|
import axios, { AxiosError } from 'axios'
|
||||||
import { backendBaseUrl } from '@/lib/constants'
|
import { backendBaseUrl, popularLabelsDefaultLimit, searchLabelsDefaultLimit } from '@/lib/constants'
|
||||||
import { errorMessage } from '@/lib/utils'
|
import { errorMessage } from '@/lib/utils'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { navigationService } from '@/services/navigation'
|
import { navigationService } from '@/services/navigation'
|
||||||
|
|
@ -319,6 +319,16 @@ export const getGraphLabels = async (): Promise<string[]> => {
|
||||||
return response.data
|
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<
|
export const checkHealth = async (): Promise<
|
||||||
LightragStatus | { status: 'error'; message: string }
|
LightragStatus | { status: 'error'; message: string }
|
||||||
> => {
|
> => {
|
||||||
|
|
|
||||||
|
|
@ -2,124 +2,101 @@ import { useCallback, useEffect } from 'react'
|
||||||
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
import { labelListLimit, controlButtonVariant } from '@/lib/constants'
|
import {
|
||||||
import MiniSearch from 'minisearch'
|
dropdownDisplayLimit,
|
||||||
|
controlButtonVariant,
|
||||||
|
popularLabelsDefaultLimit,
|
||||||
|
searchLabelsDefaultLimit
|
||||||
|
} from '@/lib/constants'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
|
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
|
||||||
|
import { getPopularLabels, searchLabels } from '@/api/lightrag'
|
||||||
|
|
||||||
const GraphLabels = () => {
|
const GraphLabels = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const label = useSettingsStore.use.queryLabel()
|
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(() => {
|
if (history.length === 0) {
|
||||||
// Create search engine
|
// If no history exists, fetch popular labels and initialize
|
||||||
const searchEngine = new MiniSearch({
|
try {
|
||||||
idField: 'id',
|
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
|
||||||
fields: ['value'],
|
await SearchHistoryManager.initializeWithDefaults(popularLabels)
|
||||||
searchOptions: {
|
} catch (error) {
|
||||||
prefix: true,
|
console.error('Failed to initialize search history:', error)
|
||||||
fuzzy: 0.2,
|
// No fallback needed, API is the source of truth
|
||||||
boost: {
|
|
||||||
label: 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
// Add documents
|
|
||||||
const documents = allDatabaseLabels.map((str, index) => ({ id: index, value: str }))
|
|
||||||
searchEngine.addAll(documents)
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels: allDatabaseLabels,
|
|
||||||
searchEngine
|
|
||||||
}
|
}
|
||||||
}, [allDatabaseLabels])
|
|
||||||
|
initializeHistory()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchData = useCallback(
|
const fetchData = useCallback(
|
||||||
async (query?: string): Promise<string[]> => {
|
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
|
// Fallback to local history search
|
||||||
if (query) {
|
const history = SearchHistoryManager.getHistory()
|
||||||
// Search labels using MiniSearch
|
const queryLower = query.toLowerCase().trim()
|
||||||
result = searchEngine.search(query).map((r: { id: number }) => labels[r.id])
|
results = history
|
||||||
|
.filter(item => item.label.toLowerCase().includes(queryLower))
|
||||||
// Add middle-content matching if results are few
|
.map(item => item.label)
|
||||||
// This enables matching content in the middle of text, not just from the beginning
|
.slice(0, dropdownDisplayLimit)
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Always show '*' at the top, and remove duplicates
|
||||||
return result.length <= labelListLimit
|
const finalResults = ['*', ...results.filter(label => label !== '*')];
|
||||||
? result
|
return finalResults;
|
||||||
: [...result.slice(0, labelListLimit), '...']
|
|
||||||
},
|
},
|
||||||
[getSearchEngine]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validate label
|
const handleRefresh = useCallback(async () => {
|
||||||
useEffect(() => {
|
// Clear search history
|
||||||
|
SearchHistoryManager.clearHistory()
|
||||||
|
|
||||||
if (labelsFetchAttempted) {
|
// Reinitialize with popular labels
|
||||||
if (allDatabaseLabels.length > 1) {
|
try {
|
||||||
if (label && label !== '*' && !allDatabaseLabels.includes(label)) {
|
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
|
||||||
console.log(`Label "${label}" not in available labels, setting to "*"`);
|
await SearchHistoryManager.initializeWithDefaults(popularLabels)
|
||||||
useSettingsStore.getState().setQueryLabel('*');
|
} catch (error) {
|
||||||
} else {
|
console.error('Failed to reload popular labels:', error)
|
||||||
console.log(`Label "${label}" is valid`);
|
// No fallback needed
|
||||||
}
|
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [allDatabaseLabels, label, labelsFetchAttempted]);
|
// Reset fetch status flags to trigger UI refresh
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
// Reset fetch status flags
|
|
||||||
useGraphStore.getState().setLabelsFetchAttempted(false)
|
useGraphStore.getState().setLabelsFetchAttempted(false)
|
||||||
useGraphStore.getState().setGraphDataFetchAttempted(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('')
|
useGraphStore.getState().setLastSuccessfulQueryLabel('')
|
||||||
|
|
||||||
// Get current label
|
// Reset to default label to ensure consistency
|
||||||
const currentLabel = useSettingsStore.getState().queryLabel
|
useSettingsStore.getState().setQueryLabel('*')
|
||||||
|
|
||||||
// If current label is empty, use default label '*'
|
// Force a data refresh by incrementing the version counter in the graph store.
|
||||||
if (!currentLabel) {
|
// This is the reliable way to trigger a re-fetch of the graph data.
|
||||||
useSettingsStore.getState().setQueryLabel('*')
|
useGraphStore.getState().incrementGraphDataVersion()
|
||||||
} else {
|
|
||||||
// Trigger data reload
|
|
||||||
useSettingsStore.getState().setQueryLabel('')
|
|
||||||
setTimeout(() => {
|
|
||||||
useSettingsStore.getState().setQueryLabel(currentLabel)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -160,6 +137,11 @@ const GraphLabels = () => {
|
||||||
newLabel = '*';
|
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
|
// Reset graphDataFetchAttempted flag to ensure data fetch is triggered
|
||||||
useGraphStore.getState().setGraphDataFetchAttempted(false);
|
useGraphStore.getState().setGraphDataFetchAttempted(false);
|
||||||
|
|
||||||
|
|
@ -167,6 +149,7 @@ const GraphLabels = () => {
|
||||||
useSettingsStore.getState().setQueryLabel(newLabel);
|
useSettingsStore.getState().setQueryLabel(newLabel);
|
||||||
}}
|
}}
|
||||||
clearable={false} // Prevent clearing value on reselect
|
clearable={false} // Prevent clearing value on reselect
|
||||||
|
debounceTime={500}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -419,7 +419,7 @@ export default function QuerySettings() {
|
||||||
className="mr-1 cursor-pointer"
|
className="mr-1 cursor-pointer"
|
||||||
id="only_need_context"
|
id="only_need_context"
|
||||||
checked={querySettings.only_need_context}
|
checked={querySettings.only_need_context}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
handleChange('only_need_context', checked)
|
handleChange('only_need_context', checked)
|
||||||
if (checked) {
|
if (checked) {
|
||||||
handleChange('only_need_prompt', false)
|
handleChange('only_need_prompt', false)
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ export interface AsyncSelectProps<T> {
|
||||||
triggerTooltip?: string
|
triggerTooltip?: string
|
||||||
/** Allow clearing the selection */
|
/** Allow clearing the selection */
|
||||||
clearable?: boolean
|
clearable?: boolean
|
||||||
|
/** Debounce time in milliseconds */
|
||||||
|
debounceTime?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AsyncSelect<T>({
|
export function AsyncSelect<T>({
|
||||||
|
|
@ -84,7 +86,8 @@ export function AsyncSelect<T>({
|
||||||
searchInputClassName,
|
searchInputClassName,
|
||||||
noResultsMessage,
|
noResultsMessage,
|
||||||
triggerTooltip,
|
triggerTooltip,
|
||||||
clearable = true
|
clearable = true,
|
||||||
|
debounceTime = 150
|
||||||
}: AsyncSelectProps<T>) {
|
}: AsyncSelectProps<T>) {
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
@ -94,7 +97,7 @@ export function AsyncSelect<T>({
|
||||||
const [selectedValue, setSelectedValue] = useState(value)
|
const [selectedValue, setSelectedValue] = useState(value)
|
||||||
const [selectedOption, setSelectedOption] = useState<T | null>(null)
|
const [selectedOption, setSelectedOption] = useState<T | null>(null)
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : debounceTime)
|
||||||
const [originalOptions, setOriginalOptions] = useState<T[]>([])
|
const [originalOptions, setOriginalOptions] = useState<T[]>([])
|
||||||
const [initialValueDisplay, setInitialValueDisplay] = useState<React.ReactNode | null>(null)
|
const [initialValueDisplay, setInitialValueDisplay] = useState<React.ReactNode | null>(null)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -225,18 +225,6 @@ export type EdgeType = {
|
||||||
const fetchGraph = async (label: string, maxDepth: number, maxNodes: number) => {
|
const fetchGraph = async (label: string, maxDepth: number, maxNodes: number) => {
|
||||||
let rawData: any = null;
|
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
|
// Trigger GraphLabels component to check if the label is valid
|
||||||
// console.log('Setting labelsFetchAttempted to true');
|
// console.log('Setting labelsFetchAttempted to true');
|
||||||
useGraphStore.getState().setLabelsFetchAttempted(true)
|
useGraphStore.getState().setLabelsFetchAttempted(true)
|
||||||
|
|
@ -411,6 +399,7 @@ const useLightrangeGraph = () => {
|
||||||
const isFetching = useGraphStore.use.isFetching()
|
const isFetching = useGraphStore.use.isFetching()
|
||||||
const nodeToExpand = useGraphStore.use.nodeToExpand()
|
const nodeToExpand = useGraphStore.use.nodeToExpand()
|
||||||
const nodeToPrune = useGraphStore.use.nodeToPrune()
|
const nodeToPrune = useGraphStore.use.nodeToPrune()
|
||||||
|
const graphDataVersion = useGraphStore.use.graphDataVersion()
|
||||||
|
|
||||||
|
|
||||||
// Use ref to track if data has been loaded and initial load
|
// 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
|
state.setLastSuccessfulQueryLabel('') // Clear last successful query label on error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [queryLabel, maxQueryDepth, maxNodes, isFetching, t])
|
}, [queryLabel, maxQueryDepth, maxNodes, isFetching, t, graphDataVersion])
|
||||||
|
|
||||||
// Handle node expansion
|
// Handle node expansion
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,17 @@ export const edgeColorHighlighted = '#B2EBF2'
|
||||||
export const searchResultLimit = 50
|
export const searchResultLimit = 50
|
||||||
export const labelListLimit = 100
|
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 minNodeSize = 4
|
||||||
export const maxNodeSize = 20
|
export const maxNodeSize = 20
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -336,7 +336,7 @@
|
||||||
"label": "التسمية",
|
"label": "التسمية",
|
||||||
"placeholder": "ابحث في التسميات...",
|
"placeholder": "ابحث في التسميات...",
|
||||||
"andOthers": "و {{count}} آخرون",
|
"andOthers": "و {{count}} آخرون",
|
||||||
"refreshTooltip": "إعادة تحميل البيانات (بعد إضافة الملف)"
|
"refreshTooltip": "إعادة تعيين بيانات الرسم البياني وسجل البحث (مطلوب بعد تغيير المستندات)"
|
||||||
},
|
},
|
||||||
"emptyGraph": "فارغ (حاول إعادة التحميل)"
|
"emptyGraph": "فارغ (حاول إعادة التحميل)"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -336,7 +336,7 @@
|
||||||
"label": "Label",
|
"label": "Label",
|
||||||
"placeholder": "Search labels...",
|
"placeholder": "Search labels...",
|
||||||
"andOthers": "And {count} others",
|
"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)"
|
"emptyGraph": "Empty(Try Reload Again)"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -336,7 +336,7 @@
|
||||||
"label": "Étiquette",
|
"label": "Étiquette",
|
||||||
"placeholder": "Rechercher des étiquettes...",
|
"placeholder": "Rechercher des étiquettes...",
|
||||||
"andOthers": "Et {{count}} autres",
|
"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)"
|
"emptyGraph": "Vide (Essayez de recharger)"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -336,7 +336,7 @@
|
||||||
"label": "标签",
|
"label": "标签",
|
||||||
"placeholder": "搜索标签...",
|
"placeholder": "搜索标签...",
|
||||||
"andOthers": "还有 {count} 个",
|
"andOthers": "还有 {count} 个",
|
||||||
"refreshTooltip": "重载图形数据(添加文件后需重载)"
|
"refreshTooltip": "重置图形数据和搜索历史(文档变化后需重置)"
|
||||||
},
|
},
|
||||||
"emptyGraph": "无数据(请重载图形数据)"
|
"emptyGraph": "无数据(请重载图形数据)"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -336,7 +336,7 @@
|
||||||
"label": "標籤",
|
"label": "標籤",
|
||||||
"placeholder": "搜尋標籤...",
|
"placeholder": "搜尋標籤...",
|
||||||
"andOthers": "還有 {count} 個",
|
"andOthers": "還有 {count} 個",
|
||||||
"refreshTooltip": "重載圖形數據(新增檔案後需重載)"
|
"refreshTooltip": "重設圖形資料和搜尋歷史(文件變化後需重設)"
|
||||||
},
|
},
|
||||||
"emptyGraph": "無數據(請重載圖形數據)"
|
"emptyGraph": "無數據(請重載圖形數據)"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { createSelectors } from '@/lib/utils'
|
import { createSelectors } from '@/lib/utils'
|
||||||
import { DirectedGraph } from 'graphology'
|
import { DirectedGraph } from 'graphology'
|
||||||
import { getGraphLabels } from '@/api/lightrag'
|
|
||||||
import MiniSearch from 'minisearch'
|
import MiniSearch from 'minisearch'
|
||||||
|
|
||||||
export type RawNodeType = {
|
export type RawNodeType = {
|
||||||
|
|
@ -84,7 +83,6 @@ interface GraphState {
|
||||||
rawGraph: RawGraph | null
|
rawGraph: RawGraph | null
|
||||||
sigmaGraph: DirectedGraph | null
|
sigmaGraph: DirectedGraph | null
|
||||||
sigmaInstance: any | null
|
sigmaInstance: any | null
|
||||||
allDatabaseLabels: string[]
|
|
||||||
|
|
||||||
searchEngine: MiniSearch | null
|
searchEngine: MiniSearch | null
|
||||||
|
|
||||||
|
|
@ -113,8 +111,6 @@ interface GraphState {
|
||||||
|
|
||||||
setRawGraph: (rawGraph: RawGraph | null) => void
|
setRawGraph: (rawGraph: RawGraph | null) => void
|
||||||
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
|
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
|
||||||
setAllDatabaseLabels: (labels: string[]) => void
|
|
||||||
fetchAllDatabaseLabels: () => Promise<void>
|
|
||||||
setIsFetching: (isFetching: boolean) => void
|
setIsFetching: (isFetching: boolean) => void
|
||||||
|
|
||||||
// 搜索引擎方法
|
// 搜索引擎方法
|
||||||
|
|
@ -160,7 +156,6 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
|
||||||
rawGraph: null,
|
rawGraph: null,
|
||||||
sigmaGraph: null,
|
sigmaGraph: null,
|
||||||
sigmaInstance: null,
|
sigmaInstance: null,
|
||||||
allDatabaseLabels: ['*'],
|
|
||||||
|
|
||||||
typeColorMap: new Map<string, string>(),
|
typeColorMap: new Map<string, string>(),
|
||||||
|
|
||||||
|
|
@ -207,21 +202,6 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
|
||||||
set({ sigmaGraph });
|
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 }),
|
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }),
|
||||||
|
|
||||||
setSigmaInstance: (instance: any) => set({ sigmaInstance: instance }),
|
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