Merge branch 'improve-property-tooltip' into feat-node-expand

This commit is contained in:
yangdx
2025-03-13 02:37:09 +08:00
19 changed files with 340 additions and 176 deletions

View File

@@ -423,12 +423,24 @@ def create_app(args):
"update_status": update_status,
}
# Custom StaticFiles class to prevent caching of HTML files
class NoCacheStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
response = await super().get_response(path, scope)
if path.endswith(".html"):
response.headers["Cache-Control"] = (
"no-cache, no-store, must-revalidate"
)
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
# Webui mount webui/index.html
static_dir = Path(__file__).parent / "webui"
static_dir.mkdir(exist_ok=True)
app.mount(
"/webui",
StaticFiles(directory=static_dir, html=True, check_dir=True),
NoCacheStaticFiles(directory=static_dir, html=True, check_dir=True),
name="webui",
)

View File

@@ -99,6 +99,37 @@ class DocsStatusesResponse(BaseModel):
statuses: Dict[DocStatus, List[DocStatusResponse]] = {}
class PipelineStatusResponse(BaseModel):
"""Response model for pipeline status
Attributes:
autoscanned: Whether auto-scan has started
busy: Whether the pipeline is currently busy
job_name: Current job name (e.g., indexing files/indexing texts)
job_start: Job start time as ISO format string (optional)
docs: Total number of documents to be indexed
batchs: Number of batches for processing documents
cur_batch: Current processing batch
request_pending: Flag for pending request for processing
latest_message: Latest message from pipeline processing
history_messages: List of history messages
"""
autoscanned: bool = False
busy: bool = False
job_name: str = "Default Job"
job_start: Optional[str] = None
docs: int = 0
batchs: int = 0
cur_batch: int = 0
request_pending: bool = False
latest_message: str = ""
history_messages: Optional[List[str]] = None
class Config:
extra = "allow" # Allow additional fields from the pipeline status
class DocumentManager:
def __init__(
self,
@@ -247,7 +278,7 @@ async def pipeline_enqueue_file(rag: LightRAG, file_path: Path) -> bool:
if global_args["main_args"].document_loading_engine == "DOCLING":
if not pm.is_installed("docling"): # type: ignore
pm.install("docling")
from docling.document_converter import DocumentConverter
from docling.document_converter import DocumentConverter # type: ignore
converter = DocumentConverter()
result = converter.convert(file_path)
@@ -266,7 +297,7 @@ async def pipeline_enqueue_file(rag: LightRAG, file_path: Path) -> bool:
if global_args["main_args"].document_loading_engine == "DOCLING":
if not pm.is_installed("docling"): # type: ignore
pm.install("docling")
from docling.document_converter import DocumentConverter
from docling.document_converter import DocumentConverter # type: ignore
converter = DocumentConverter()
result = converter.convert(file_path)
@@ -286,7 +317,7 @@ async def pipeline_enqueue_file(rag: LightRAG, file_path: Path) -> bool:
if global_args["main_args"].document_loading_engine == "DOCLING":
if not pm.is_installed("docling"): # type: ignore
pm.install("docling")
from docling.document_converter import DocumentConverter
from docling.document_converter import DocumentConverter # type: ignore
converter = DocumentConverter()
result = converter.convert(file_path)
@@ -307,7 +338,7 @@ async def pipeline_enqueue_file(rag: LightRAG, file_path: Path) -> bool:
if global_args["main_args"].document_loading_engine == "DOCLING":
if not pm.is_installed("docling"): # type: ignore
pm.install("docling")
from docling.document_converter import DocumentConverter
from docling.document_converter import DocumentConverter # type: ignore
converter = DocumentConverter()
result = converter.convert(file_path)
@@ -718,17 +749,33 @@ def create_document_routes(
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@router.get("/pipeline_status", dependencies=[Depends(optional_api_key)])
async def get_pipeline_status():
@router.get(
"/pipeline_status",
dependencies=[Depends(optional_api_key)],
response_model=PipelineStatusResponse,
)
async def get_pipeline_status() -> PipelineStatusResponse:
"""
Get the current status of the document indexing pipeline.
This endpoint returns information about the current state of the document processing pipeline,
including whether it's busy, the current job name, when it started, how many documents
are being processed, how many batches there are, and which batch is currently being processed.
including the processing status, progress information, and history messages.
Returns:
dict: A dictionary containing the pipeline status information
PipelineStatusResponse: A response object containing:
- autoscanned (bool): Whether auto-scan has started
- busy (bool): Whether the pipeline is currently busy
- job_name (str): Current job name (e.g., indexing files/indexing texts)
- job_start (str, optional): Job start time as ISO format string
- docs (int): Total number of documents to be indexed
- batchs (int): Number of batches for processing documents
- cur_batch (int): Current processing batch
- request_pending (bool): Flag for pending request for processing
- latest_message (str): Latest message from pipeline processing
- history_messages (List[str], optional): List of history messages
Raises:
HTTPException: If an error occurs while retrieving pipeline status (500)
"""
try:
from lightrag.kg.shared_storage import get_namespace_data
@@ -746,7 +793,7 @@ def create_document_routes(
if status_dict.get("job_start"):
status_dict["job_start"] = str(status_dict["job_start"])
return status_dict
return PipelineStatusResponse(**status_dict)
except Exception as e:
logger.error(f"Error getting pipeline status: {str(e)}")
logger.error(traceback.format_exc())

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="icon" type="image/svg+xml" href="./logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title>
<script type="module" crossorigin src="./assets/index-B9TRs-Wk.js"></script>
<script type="module" crossorigin src="./assets/index-Cs3ZbEon.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DRGuXfZw.css">
</head>
<body>

View File

@@ -661,7 +661,7 @@ class Neo4JStorage(BaseGraphStorage):
WITH collect({node: n}) AS filtered_nodes
UNWIND filtered_nodes AS node_info
WITH collect(node_info.node) AS kept_nodes, filtered_nodes
MATCH (a)-[r]-(b)
OPTIONAL MATCH (a)-[r]-(b)
WHERE a IN kept_nodes AND b IN kept_nodes
RETURN filtered_nodes AS node_info,
collect(DISTINCT r) AS relationships
@@ -704,7 +704,7 @@ class Neo4JStorage(BaseGraphStorage):
WITH collect({node: node}) AS filtered_nodes
UNWIND filtered_nodes AS node_info
WITH collect(node_info.node) AS kept_nodes, filtered_nodes
MATCH (a)-[r]-(b)
OPTIONAL MATCH (a)-[r]-(b)
WHERE a IN kept_nodes AND b IN kept_nodes
RETURN filtered_nodes AS node_info,
collect(DISTINCT r) AS relationships

View File

@@ -2,6 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title>

View File

@@ -13,15 +13,24 @@ const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) =>
* When the selected item changes, highlighted the node and center the camera on it.
*/
useEffect(() => {
if (!node) return
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
if (move) {
gotoNode(node)
if (node) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
gotoNode(node)
} else {
// If no node is selected but move is true, reset to default view
sigma.setCustomBBox(null)
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 })
}
useGraphStore.getState().setMoveToSelectedNode(false)
} else if (node) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
}
return () => {
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
if (node) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
}
}
}, [node, move, sigma, gotoNode])

View File

@@ -1,10 +1,11 @@
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
import Graph from 'graphology'
// import { useLayoutCircular } from '@react-sigma/layout-circular'
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useEffect } from 'react'
// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'
import useLightragGraph, { EdgeType, NodeType } from '@/hooks/useLightragGraph'
import { EdgeType, NodeType } from '@/hooks/useLightragGraph'
import useTheme from '@/hooks/useTheme'
import * as Constants from '@/lib/constants'
@@ -21,7 +22,6 @@ const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
}
const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {
const { lightrageGraph } = useLightragGraph()
const sigma = useSigma<NodeType, EdgeType>()
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
const setSettings = useSetSettings<NodeType, EdgeType>()
@@ -38,17 +38,18 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const focusedNode = useGraphStore.use.focusedNode()
const selectedEdge = useGraphStore.use.selectedEdge()
const focusedEdge = useGraphStore.use.focusedEdge()
const sigmaGraph = useGraphStore.use.sigmaGraph()
/**
* When component mount or maxIterations changes
* => load the graph and apply layout
*/
useEffect(() => {
// Create & load the graph
const graph = lightrageGraph()
loadGraph(graph)
assignLayout()
}, [assignLayout, loadGraph, lightrageGraph, maxIterations])
if (sigmaGraph) {
loadGraph(sigmaGraph as unknown as Graph<NodeType, EdgeType>)
assignLayout()
}
}, [assignLayout, loadGraph, sigmaGraph, maxIterations])
/**
* When component mount

View File

@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
const GraphLabels = () => {
const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel()
const graphLabels = useGraphStore.use.graphLabels()
const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
const getSearchEngine = useCallback(() => {
// Create search engine
@@ -26,14 +26,14 @@ const GraphLabels = () => {
})
// Add documents
const documents = graphLabels.map((str, index) => ({ id: index, value: str }))
const documents = allDatabaseLabels.map((str, index) => ({ id: index, value: str }))
searchEngine.addAll(documents)
return {
labels: graphLabels,
labels: allDatabaseLabels,
searchEngine
}
}, [graphLabels])
}, [allDatabaseLabels])
const fetchData = useCallback(
async (query?: string): Promise<string[]> => {
@@ -47,24 +47,11 @@ const GraphLabels = () => {
return result.length <= labelListLimit
? result
: [...result.slice(0, labelListLimit), t('graphLabels.andOthers', { count: result.length - labelListLimit })]
: [...result.slice(0, labelListLimit), '...']
},
[getSearchEngine]
)
const setQueryLabel = useCallback((newLabel: string) => {
if (newLabel.startsWith('And ') && newLabel.endsWith(' others')) return
const currentLabel = useSettingsStore.getState().queryLabel
// When selecting the same label (except '*'), switch to '*'
if (newLabel === currentLabel && newLabel !== '*') {
useSettingsStore.getState().setQueryLabel('*')
} else {
useSettingsStore.getState().setQueryLabel(newLabel)
}
}, [])
return (
<AsyncSelect<string>
className="ml-2"
@@ -78,8 +65,20 @@ const GraphLabels = () => {
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
label={t('graphPanel.graphLabels.label')}
placeholder={t('graphPanel.graphLabels.placeholder')}
value={label !== null ? label : ''}
onChange={setQueryLabel}
value={label !== null ? label : '*'}
onChange={(newLabel) => {
const currentLabel = useSettingsStore.getState().queryLabel
if (newLabel === '...') {
newLabel = '*'
}
if (newLabel === currentLabel && newLabel !== '*') {
// 选择相同标签时切换到'*'
useSettingsStore.getState().setQueryLabel('*')
} else {
useSettingsStore.getState().setQueryLabel(newLabel)
}
}}
clearable={false} // Prevent clearing value on reselect
/>
)

View File

@@ -85,8 +85,19 @@ export const GraphSearchInput = ({
const loadOptions = useCallback(
async (query?: string): Promise<OptionItem[]> => {
if (onFocus) onFocus(null)
if (!query || !searchEngine) return []
const result: OptionItem[] = searchEngine.search(query).map((r) => ({
if (!graph || !searchEngine) return []
// If no query, return first searchResultLimit nodes
if (!query) {
const nodeIds = graph.nodes().slice(0, searchResultLimit)
return nodeIds.map(id => ({
id,
type: 'nodes'
}))
}
// If has query, search nodes
const result: OptionItem[] = searchEngine.search(query).map((r: { id: string }) => ({
id: r.id,
type: 'nodes'
}))
@@ -103,7 +114,7 @@ export const GraphSearchInput = ({
}
]
},
[searchEngine, onFocus]
[graph, searchEngine, onFocus, t]
)
return (

View File

@@ -193,7 +193,7 @@ export function AsyncSearch<T>({
</div>
)}
</div>
<CommandList hidden={!open || debouncedSearchTerm.length === 0}>
<CommandList hidden={!open}>
{error && <div className="text-destructive p-4 text-center">{error}</div>}
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
{!loading &&

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
// import { MiniMap } from '@react-sigma/minimap'
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
import { Settings as SigmaSettings } from 'sigma/settings'
@@ -91,8 +91,12 @@ const GraphEvents = () => {
}
},
// Disable the autoscale at the first down interaction
mousedown: () => {
if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox())
mousedown: (e) => {
// Only set custom BBox if it's a drag operation (mouse button is pressed)
const mouseEvent = e.original as MouseEvent;
if (mouseEvent.buttons !== 0 && !sigma.getCustomBBox()) {
sigma.setCustomBBox(sigma.getBBox())
}
}
})
}, [registerEvents, sigma, draggedNode])
@@ -102,6 +106,7 @@ const GraphEvents = () => {
const GraphViewer = () => {
const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings)
const sigmaRef = useRef<any>(null)
const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode()
@@ -144,7 +149,11 @@ const GraphViewer = () => {
)
return (
<SigmaContainer settings={sigmaSettings} className="!bg-background !size-full overflow-hidden">
<SigmaContainer
settings={sigmaSettings}
className="!bg-background !size-full overflow-hidden"
ref={sigmaRef}
>
<GraphControl />
{enableNodeDrag && <GraphEvents />}

View File

@@ -162,13 +162,37 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
}
const useLightrangeGraph = () => {
// Use useRef to maintain lastQueryLabel state between renders
const lastQueryLabelRef = useRef({ label: '', maxQueryDepth: 0, minDegree: 0 })
const queryLabel = useSettingsStore.use.queryLabel()
const rawGraph = useGraphStore.use.rawGraph()
const sigmaGraph = useGraphStore.use.sigmaGraph()
const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()
const minDegree = useSettingsStore.use.graphMinDegree()
const isFetching = useGraphStore.use.isFetching()
// Fetch all database labels on mount
useEffect(() => {
useGraphStore.getState().fetchAllDatabaseLabels()
}, [])
// Use ref to track fetch status
const fetchStatusRef = useRef<Record<string, boolean>>({});
// Track previous parameters to detect actual changes
const prevParamsRef = useRef({ queryLabel, maxQueryDepth, minDegree });
// Reset fetch status only when parameters actually change
useEffect(() => {
const prevParams = prevParamsRef.current;
if (prevParams.queryLabel !== queryLabel ||
prevParams.maxQueryDepth !== maxQueryDepth ||
prevParams.minDegree !== minDegree) {
useGraphStore.getState().setIsFetching(false);
// Reset fetch status for new parameters
fetchStatusRef.current = {};
// Update previous parameters
prevParamsRef.current = { queryLabel, maxQueryDepth, minDegree };
}
}, [queryLabel, maxQueryDepth, minDegree, isFetching])
const getNode = useCallback(
(nodeId: string) => {
@@ -186,17 +210,21 @@ const useLightrangeGraph = () => {
useEffect(() => {
if (queryLabel) {
// Always fetch data for "*" label
// For other labels, only fetch when parameters change
const shouldUpdate = true;
const fetchKey = `${queryLabel}-${maxQueryDepth}-${minDegree}`;
if (shouldUpdate) {
lastQueryLabelRef.current = {
label: queryLabel,
maxQueryDepth,
minDegree
// Only fetch if we haven't fetched this combination in the current component lifecycle
if (!isFetching && !fetchStatusRef.current[fetchKey]) {
const state = useGraphStore.getState();
// Clear selection and highlighted nodes before fetching new graph
state.clearSelection();
if (state.sigmaGraph) {
state.sigmaGraph.forEachNode((node) => {
state.sigmaGraph?.setNodeAttribute(node, 'highlighted', false);
});
}
state.setIsFetching(true);
fetchStatusRef.current[fetchKey] = true;
fetchGraph(queryLabel, maxQueryDepth, minDegree).then((data) => {
const state = useGraphStore.getState()
const newSigmaGraph = createSigmaGraph(data)
@@ -208,7 +236,7 @@ const useLightrangeGraph = () => {
state.setSigmaGraph(newSigmaGraph)
state.setRawGraph(data)
// Extract labels from graph data
// Extract labels from current graph data
if (data) {
const labelSet = new Set<string>();
for (const node of data.nodes) {
@@ -227,6 +255,21 @@ const useLightrangeGraph = () => {
// Ensure * is there eventhough there is no graph data
state.setGraphLabels(['*']);
}
// Fetch all database labels after graph update
state.fetchAllDatabaseLabels();
if (!data) {
// If data is invalid, remove the fetch flag to allow retry
delete fetchStatusRef.current[fetchKey];
}
// Reset fetching state after all updates are complete
// Reset camera view by triggering FocusOnNode component
state.setMoveToSelectedNode(true);
state.setIsFetching(false);
}).catch(() => {
// Reset fetching state and remove flag in case of error
useGraphStore.getState().setIsFetching(false);
delete fetchStatusRef.current[fetchKey];
})
}
} else {
@@ -234,7 +277,7 @@ const useLightrangeGraph = () => {
state.reset()
state.setSigmaGraph(new DirectedGraph())
}
}, [queryLabel, maxQueryDepth, minDegree])
}, [queryLabel, maxQueryDepth, minDegree, isFetching])
const lightrageGraph = useCallback(() => {
if (sigmaGraph) {

View File

@@ -15,8 +15,8 @@ export const edgeColorDarkTheme = '#969696'
export const edgeColorSelected = '#F57F17'
export const edgeColorHighlighted = '#B2EBF2'
export const searchResultLimit = 20
export const labelListLimit = 40
export const searchResultLimit = 50
export const labelListLimit = 100
export const minNodeSize = 4
export const maxNodeSize = 20

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand'
import { createSelectors } from '@/lib/utils'
import { DirectedGraph } from 'graphology'
import { getGraphLabels } from '@/api/lightrag'
export type RawNodeType = {
id: string
@@ -66,8 +67,10 @@ interface GraphState {
rawGraph: RawGraph | null
sigmaGraph: DirectedGraph | null
graphLabels: string[]
allDatabaseLabels: string[]
moveToSelectedNode: boolean
isFetching: boolean
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void
setFocusedNode: (nodeId: string | null) => void
@@ -81,6 +84,9 @@ interface GraphState {
setRawGraph: (rawGraph: RawGraph | null) => void
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
setGraphLabels: (labels: string[]) => void
setAllDatabaseLabels: (labels: string[]) => void
fetchAllDatabaseLabels: () => Promise<void>
setIsFetching: (isFetching: boolean) => void
}
const useGraphStoreBase = create<GraphState>()((set) => ({
@@ -90,11 +96,14 @@ const useGraphStoreBase = create<GraphState>()((set) => ({
focusedEdge: null,
moveToSelectedNode: false,
isFetching: false,
rawGraph: null,
sigmaGraph: null,
graphLabels: ['*'],
allDatabaseLabels: ['*'],
setIsFetching: (isFetching: boolean) => set({ isFetching }),
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) =>
set({ selectedNode: nodeId, moveToSelectedNode }),
setFocusedNode: (nodeId: string | null) => set({ focusedNode: nodeId }),
@@ -128,6 +137,18 @@ const useGraphStoreBase = create<GraphState>()((set) => ({
setGraphLabels: (labels: string[]) => set({ graphLabels: labels }),
setAllDatabaseLabels: (labels: string[]) => set({ allDatabaseLabels: labels }),
fetchAllDatabaseLabels: async () => {
try {
const labels = await getGraphLabels();
set({ allDatabaseLabels: ['*', ...labels] });
} catch (error) {
console.error('Failed to fetch all database labels:', error);
set({ allDatabaseLabels: ['*'] });
}
},
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode })
}))

View File

@@ -171,7 +171,7 @@ const useSettingsStoreBase = create<SettingsState>()(
{
name: 'settings-storage',
storage: createJSONStorage(() => localStorage),
version: 7,
version: 8,
migrate: (state: any, version: number) => {
if (version < 2) {
state.showEdgeLabel = false
@@ -208,7 +208,13 @@ const useSettingsStoreBase = create<SettingsState>()(
}
if (version < 7) {
state.graphQueryMaxDepth = 3
state.graphLayoutMaxIterations = 10
state.graphLayoutMaxIterations = 15
}
if (version < 8) {
state.enableNodeDrag = true
state.enableHideUnselectedEdges = true
state.enableEdgeEvents = false
state.graphMinDegree = 0
}
return state
}