| import { useCallback, useEffect, useState, useRef } from 'react' |
| import { AsyncSelect } from '@/components/ui/AsyncSelect' |
| import { useSettingsStore } from '@/stores/settings' |
| import { useGraphStore } from '@/stores/graph' |
| import { useBackendState } from '@/stores/state' |
| 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 dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger() |
| const [isRefreshing, setIsRefreshing] = useState(false) |
| const [refreshTrigger, setRefreshTrigger] = useState(0) |
| const [selectKey, setSelectKey] = useState(0) |
|
|
| |
| const pipelineBusy = useBackendState.use.pipelineBusy() |
| const prevPipelineBusy = useRef<boolean | undefined>(undefined) |
| const shouldRefreshPopularLabelsRef = useRef(false) |
|
|
| |
| const getRefreshTooltip = useCallback(() => { |
| if (isRefreshing) { |
| return t('graphPanel.graphLabels.refreshingTooltip') |
| } |
|
|
| if (!label || label === '*') { |
| return t('graphPanel.graphLabels.refreshGlobalTooltip') |
| } else { |
| return t('graphPanel.graphLabels.refreshCurrentLabelTooltip', { label }) |
| } |
| }, [label, t, isRefreshing]) |
|
|
| |
| useEffect(() => { |
| const initializeHistory = async () => { |
| const history = SearchHistoryManager.getHistory() |
|
|
| if (history.length === 0) { |
| |
| try { |
| const popularLabels = await getPopularLabels(popularLabelsDefaultLimit) |
| await SearchHistoryManager.initializeWithDefaults(popularLabels) |
| } catch (error) { |
| console.error('Failed to initialize search history:', error) |
| |
| } |
| } |
| } |
|
|
| initializeHistory() |
| }, []) |
|
|
| |
| useEffect(() => { |
| setSelectKey(prev => prev + 1) |
| }, [label]) |
|
|
| |
| useEffect(() => { |
| if (dropdownRefreshTrigger > 0) { |
| setSelectKey(prev => prev + 1) |
| } |
| }, [dropdownRefreshTrigger]) |
|
|
| |
| useEffect(() => { |
| if (prevPipelineBusy.current === true && pipelineBusy === false) { |
| console.log('Pipeline changed from busy to idle, marking for popular labels refresh') |
| shouldRefreshPopularLabelsRef.current = true |
| } |
| prevPipelineBusy.current = pipelineBusy |
| }, [pipelineBusy]) |
|
|
| |
| const reloadPopularLabels = useCallback(async () => { |
| if (!shouldRefreshPopularLabelsRef.current) return |
|
|
| console.log('Reloading popular labels (triggered by pipeline idle)') |
| try { |
| const popularLabels = await getPopularLabels(popularLabelsDefaultLimit) |
| SearchHistoryManager.clearHistory() |
|
|
| if (popularLabels.length === 0) { |
| const fallbackLabels = ['entity', 'relationship', 'document', 'concept'] |
| await SearchHistoryManager.initializeWithDefaults(fallbackLabels) |
| } else { |
| await SearchHistoryManager.initializeWithDefaults(popularLabels) |
| } |
| } catch (error) { |
| console.error('Failed to reload popular labels:', error) |
| const fallbackLabels = ['entity', 'relationship', 'document'] |
| SearchHistoryManager.clearHistory() |
| await SearchHistoryManager.initializeWithDefaults(fallbackLabels) |
| } finally { |
| |
| shouldRefreshPopularLabelsRef.current = false |
| } |
| }, []) |
|
|
| |
| const bumpDropdownData = useCallback(({ forceSelectKey = false } = {}) => { |
| setRefreshTrigger(prev => prev + 1) |
| if (forceSelectKey) { |
| setSelectKey(prev => prev + 1) |
| } |
| }, []) |
|
|
| const fetchData = useCallback( |
| async (query?: string): Promise<string[]> => { |
| let results: string[]; |
| if (!query || query.trim() === '' || query.trim() === '*') { |
| |
| results = SearchHistoryManager.getHistoryLabels(dropdownDisplayLimit) |
| } else { |
| |
| 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) |
|
|
| |
| 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) |
| } |
| } |
| |
| const finalResults = ['*', ...results.filter(label => label !== '*')]; |
| return finalResults; |
| }, |
| |
| [refreshTrigger] |
| ) |
|
|
| const handleRefresh = useCallback(async () => { |
| setIsRefreshing(true) |
|
|
| |
| useGraphStore.getState().setTypeColorMap(new Map<string, string>()) |
|
|
| try { |
| let currentLabel = label |
|
|
| |
| if (!currentLabel || currentLabel.trim() === '') { |
| useSettingsStore.getState().setQueryLabel('*') |
| currentLabel = '*' |
| } |
|
|
| |
| if (shouldRefreshPopularLabelsRef.current) { |
| await reloadPopularLabels() |
| bumpDropdownData({ forceSelectKey: true }) |
| } |
|
|
| if (currentLabel && currentLabel !== '*') { |
| |
| console.log(`Refreshing current label: ${currentLabel}`) |
|
|
| |
| useGraphStore.getState().setGraphDataFetchAttempted(false) |
| useGraphStore.getState().setLastSuccessfulQueryLabel('') |
|
|
| |
| useGraphStore.getState().incrementGraphDataVersion() |
|
|
| |
| |
| |
|
|
| } else { |
| |
| console.log('Refreshing global data and popular labels') |
|
|
| try { |
| |
| const popularLabels = await getPopularLabels(popularLabelsDefaultLimit) |
| SearchHistoryManager.clearHistory() |
|
|
| if (popularLabels.length === 0) { |
| |
| const fallbackLabels = ['entity', 'relationship', 'document', 'concept'] |
| await SearchHistoryManager.initializeWithDefaults(fallbackLabels) |
| } else { |
| await SearchHistoryManager.initializeWithDefaults(popularLabels) |
| } |
| } catch (error) { |
| console.error('Failed to reload popular labels:', error) |
| |
| const fallbackLabels = ['entity', 'relationship', 'document'] |
| SearchHistoryManager.clearHistory() |
| await SearchHistoryManager.initializeWithDefaults(fallbackLabels) |
| } |
|
|
| |
| useGraphStore.getState().setGraphDataFetchAttempted(false) |
| useGraphStore.getState().setLastSuccessfulQueryLabel('') |
|
|
| |
| useGraphStore.getState().incrementGraphDataVersion() |
|
|
| |
| await new Promise(resolve => setTimeout(resolve, 0)) |
|
|
| |
| setRefreshTrigger(prev => prev + 1) |
| setSelectKey(prev => prev + 1) |
| } |
| } catch (error) { |
| console.error('Error during refresh:', error) |
| } finally { |
| setIsRefreshing(false) |
| } |
| }, [label, reloadPopularLabels, bumpDropdownData]) |
|
|
| |
| const handleDropdownBeforeOpen = useCallback(async () => { |
| const currentLabel = useSettingsStore.getState().queryLabel |
| if (shouldRefreshPopularLabelsRef.current && (!currentLabel || currentLabel === '*')) { |
| await reloadPopularLabels() |
| bumpDropdownData() |
| } |
| }, [reloadPopularLabels, bumpDropdownData]) |
|
|
| return ( |
| <div className="flex items-center"> |
| {/* Always show refresh button */} |
| <Button |
| size="icon" |
| variant={controlButtonVariant} |
| onClick={handleRefresh} |
| tooltip={getRefreshTooltip()} |
| className="mr-2" |
| disabled={isRefreshing} |
| > |
| <RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} /> |
| </Button> |
| <div className="w-full min-w-[280px] max-w-[500px]"> |
| <AsyncSelect<string> |
| key={selectKey} // Force re-render when data changes |
| className="min-w-[300px]" |
| triggerClassName="max-h-8 w-full overflow-hidden" |
| searchInputClassName="max-h-8" |
| triggerTooltip={t('graphPanel.graphLabels.selectTooltip')} |
| fetcher={fetchData} |
| onBeforeOpen={handleDropdownBeforeOpen} |
| renderOption={(item) => ( |
| <div className="truncate" title={item}> |
| {item} |
| </div> |
| )} |
| getOptionValue={(item) => item} |
| getDisplayValue={(item) => ( |
| <div className="min-w-0 flex-1 truncate text-left" title={item}> |
| {item} |
| </div> |
| )} |
| notFound={<div className="py-6 text-center text-sm">{t('graphPanel.graphLabels.noLabels')}</div>} |
| ariaLabel={t('graphPanel.graphLabels.label')} |
| placeholder={t('graphPanel.graphLabels.placeholder')} |
| searchPlaceholder={t('graphPanel.graphLabels.placeholder')} |
| noResultsMessage={t('graphPanel.graphLabels.noLabels')} |
| value={label !== null ? label : '*'} |
| onChange={(newLabel) => { |
| const currentLabel = useSettingsStore.getState().queryLabel; |
| |
| // select the last item means query all |
| if (newLabel === '...') { |
| newLabel = '*'; |
| } |
| |
| // Handle reselecting the same label |
| if (newLabel === currentLabel && 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 |
| useGraphStore.getState().setGraphDataFetchAttempted(false); |
| |
| // Update the label to trigger data loading |
| useSettingsStore.getState().setQueryLabel(newLabel); |
| |
| // Force graph re-render and reset zoom/scale (must be AFTER setQueryLabel) |
| useGraphStore.getState().incrementGraphDataVersion(); |
| }} |
| clearable={false} // Prevent clearing value on reselect |
| debounceTime={500} |
| /> |
| </div> |
| </div> |
| ) |
| } |
| |
| export default GraphLabels |
| |