Add auto-refresh of popular labels when pipeline completes

• Monitor pipeline busy->idle transitions
• Reload labels on dropdown open if needed
• Add onBeforeOpen callback to AsyncSelect
• Clear refresh flags after processing
• Improve label sync with backend state

(cherry picked from commit 58c83f9da5)
This commit is contained in:
yangdx 2025-10-31 04:45:35 +08:00 committed by Raphaël MANSUY
parent a7330f0b95
commit 4cc6388742
2 changed files with 146 additions and 42 deletions

View file

@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState, useRef } 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 { useBackendState } from '@/stores/state'
import { import {
dropdownDisplayLimit, dropdownDisplayLimit,
controlButtonVariant, controlButtonVariant,
@ -17,10 +18,16 @@ 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 dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger()
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
const [selectKey, setSelectKey] = useState(0) const [selectKey, setSelectKey] = useState(0)
// Pipeline state monitoring
const pipelineBusy = useBackendState.use.pipelineBusy()
const prevPipelineBusy = useRef<boolean | undefined>(undefined)
const shouldRefreshPopularLabelsRef = useRef(false)
// Dynamic tooltip based on current label state // Dynamic tooltip based on current label state
const getRefreshTooltip = useCallback(() => { const getRefreshTooltip = useCallback(() => {
if (isRefreshing) { if (isRefreshing) {
@ -54,6 +61,61 @@ const GraphLabels = () => {
initializeHistory() initializeHistory()
}, []) }, [])
// Force AsyncSelect to re-render when label changes externally (e.g., from entity rename/merge)
useEffect(() => {
setSelectKey(prev => prev + 1)
}, [label])
// Force AsyncSelect to re-render when dropdown refresh is triggered (e.g., after entity rename)
useEffect(() => {
if (dropdownRefreshTrigger > 0) {
setSelectKey(prev => prev + 1)
}
}, [dropdownRefreshTrigger])
// Monitor pipeline state changes: busy -> idle
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])
// Helper: Reload popular labels from backend
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 {
// Always clear the flag
shouldRefreshPopularLabelsRef.current = false
}
}, [])
// Helper: Bump dropdown data to trigger refresh
const bumpDropdownData = useCallback(({ forceSelectKey = false } = {}) => {
setRefreshTrigger(prev => prev + 1)
if (forceSelectKey) {
setSelectKey(prev => prev + 1)
}
}, [])
const fetchData = useCallback( const fetchData = useCallback(
async (query?: string): Promise<string[]> => { async (query?: string): Promise<string[]> => {
let results: string[] = []; let results: string[] = [];
@ -102,6 +164,12 @@ const GraphLabels = () => {
currentLabel = '*' currentLabel = '*'
} }
// Scenario 1: Manual refresh - reload popular labels if flag is set (regardless of current label)
if (shouldRefreshPopularLabelsRef.current) {
await reloadPopularLabels()
bumpDropdownData({ forceSelectKey: true })
}
if (currentLabel && currentLabel !== '*') { if (currentLabel && currentLabel !== '*') {
// Scenario 1: Has specific label, try to refresh current label // Scenario 1: Has specific label, try to refresh current label
console.log(`Refreshing current label: ${currentLabel}`) console.log(`Refreshing current label: ${currentLabel}`)
@ -122,7 +190,7 @@ const GraphLabels = () => {
console.log('Refreshing global data and popular labels') console.log('Refreshing global data and popular labels')
try { try {
// Re-fetch popular labels and update search history // Re-fetch popular labels and update search history (if not already done)
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit) const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
SearchHistoryManager.clearHistory() SearchHistoryManager.clearHistory()
@ -160,7 +228,16 @@ const GraphLabels = () => {
} finally { } finally {
setIsRefreshing(false) setIsRefreshing(false)
} }
}, [label]) }, [label, reloadPopularLabels, bumpDropdownData])
// Handle dropdown before open - reload popular labels if needed
const handleDropdownBeforeOpen = useCallback(async () => {
const currentLabel = useSettingsStore.getState().queryLabel
if (shouldRefreshPopularLabelsRef.current && (!currentLabel || currentLabel === '*')) {
await reloadPopularLabels()
bumpDropdownData()
}
}, [reloadPopularLabels, bumpDropdownData])
return ( return (
<div className="flex items-center"> <div className="flex items-center">
@ -175,49 +252,63 @@ const GraphLabels = () => {
> >
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button> </Button>
<AsyncSelect<string> <div className="w-full min-w-[280px] max-w-[500px]">
key={selectKey} // Force re-render when data changes <AsyncSelect<string>
className="min-w-[300px]" key={selectKey} // Force re-render when data changes
triggerClassName="max-h-8" className="min-w-[300px]"
searchInputClassName="max-h-8" triggerClassName="max-h-8 w-full overflow-hidden"
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')} searchInputClassName="max-h-8"
fetcher={fetchData} triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
renderOption={(item) => <div style={{ whiteSpace: 'pre' }}>{item}</div>} fetcher={fetchData}
getOptionValue={(item) => item} onBeforeOpen={handleDropdownBeforeOpen}
getDisplayValue={(item) => <div style={{ whiteSpace: 'pre' }}>{item}</div>} renderOption={(item) => (
notFound={<div className="py-6 text-center text-sm">{t('graphPanel.graphLabels.noLabels')}</div>} <div className="truncate" title={item}>
ariaLabel={t('graphPanel.graphLabels.label')} {item}
placeholder={t('graphPanel.graphLabels.placeholder')} </div>
searchPlaceholder={t('graphPanel.graphLabels.placeholder')} )}
noResultsMessage={t('graphPanel.graphLabels.noLabels')} getOptionValue={(item) => item}
value={label !== null ? label : '*'} getDisplayValue={(item) => (
onChange={(newLabel) => { <div className="min-w-0 flex-1 truncate text-left" title={item}>
const currentLabel = useSettingsStore.getState().queryLabel; {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 // select the last item means query all
if (newLabel === '...') { if (newLabel === '...') {
newLabel = '*'; newLabel = '*';
} }
// Handle reselecting the same label // Handle reselecting the same label
if (newLabel === currentLabel && newLabel !== '*') { if (newLabel === currentLabel && newLabel !== '*') {
newLabel = '*'; newLabel = '*';
} }
// Add selected label to search history (except for special cases) // Add selected label to search history (except for special cases)
if (newLabel && newLabel !== '*' && newLabel !== '...' && newLabel.trim() !== '') { if (newLabel && newLabel !== '*' && newLabel !== '...' && newLabel.trim() !== '') {
SearchHistoryManager.addToHistory(newLabel); 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);
// Update the label to trigger data loading // Update the label to trigger data loading
useSettingsStore.getState().setQueryLabel(newLabel); useSettingsStore.getState().setQueryLabel(newLabel);
}}
clearable={false} // Prevent clearing value on reselect // Force graph re-render and reset zoom/scale (must be AFTER setQueryLabel)
debounceTime={500} useGraphStore.getState().incrementGraphDataVersion();
/> }}
clearable={false} // Prevent clearing value on reselect
debounceTime={500}
/>
</div>
</div> </div>
) )
} }

View file

@ -43,6 +43,8 @@ export interface AsyncSelectProps<T> {
value: string value: string
/** Callback when selection changes */ /** Callback when selection changes */
onChange: (value: string) => void onChange: (value: string) => void
/** Callback before opening the dropdown (async supported) */
onBeforeOpen?: () => void | Promise<void>
/** Accessibility label for the select field */ /** Accessibility label for the select field */
ariaLabel?: string ariaLabel?: string
/** Placeholder text when no selection */ /** Placeholder text when no selection */
@ -83,6 +85,7 @@ export function AsyncSelect<T>({
searchPlaceholder, searchPlaceholder,
value, value,
onChange, onChange,
onBeforeOpen,
disabled = false, disabled = false,
className, className,
triggerClassName, triggerClassName,
@ -196,8 +199,18 @@ export function AsyncSelect<T>({
[selectedValue, onChange, clearable, options, getOptionValue] [selectedValue, onChange, clearable, options, getOptionValue]
) )
const handleOpenChange = useCallback(
async (newOpen: boolean) => {
if (newOpen && onBeforeOpen) {
await onBeforeOpen()
}
setOpen(newOpen)
},
[onBeforeOpen]
)
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"