add graph viewer webui
This commit is contained in:
24
lightrag/api/graph_viewer_webui/.gitignore
vendored
Normal file
24
lightrag/api/graph_viewer_webui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
10
lightrag/api/graph_viewer_webui/.prettierrc.json
Normal file
10
lightrag/api/graph_viewer_webui/.prettierrc.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"endOfLine": "crlf",
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
12
lightrag/api/graph_viewer_webui/README.md
Normal file
12
lightrag/api/graph_viewer_webui/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# LightRag Graph Viewer WebUI
|
||||||
|
|
||||||
|
## Install [Bun](https://bun.sh/docs/installation)
|
||||||
|
|
||||||
|
|
||||||
|
## Install Dependencies
|
||||||
|
|
||||||
|
`bun install --frozen-lockfile`
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
`bun run build`
|
1015
lightrag/api/graph_viewer_webui/bun.lock
Normal file
1015
lightrag/api/graph_viewer_webui/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
34
lightrag/api/graph_viewer_webui/eslint.config.js
Normal file
34
lightrag/api/graph_viewer_webui/eslint.config.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import stylisticJs from '@stylistic/eslint-plugin-js'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import prettier from 'eslint-config-prettier'
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
|
||||||
|
export default tseslint.config({ ignores: ['dist'] }, prettier, {
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser
|
||||||
|
},
|
||||||
|
settings: { react: { version: '19.0' } },
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
'@stylistic/js': stylisticJs,
|
||||||
|
react
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
...react.configs['jsx-runtime'].rules,
|
||||||
|
'@stylistic/js/indent': ['error', 2],
|
||||||
|
'@stylistic/js/quotes': ['error', 'single'],
|
||||||
|
'@typescript-eslint/no-explicit-any': ['off']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
13
lightrag/api/graph_viewer_webui/index.html
Normal file
13
lightrag/api/graph_viewer_webui/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Lightrag Graph Viewer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
68
lightrag/api/graph_viewer_webui/package.json
Normal file
68
lightrag/api/graph_viewer_webui/package.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "lightrag-graph-vierer-webui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bunx --bun vite",
|
||||||
|
"build": "bunx --bun vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "bunx --bun vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@faker-js/faker": "^9.4.0",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@react-sigma/core": "^5.0.2",
|
||||||
|
"@react-sigma/graph-search": "^5.0.3",
|
||||||
|
"@react-sigma/layout-circlepack": "^5.0.2",
|
||||||
|
"@react-sigma/layout-circular": "^5.0.2",
|
||||||
|
"@react-sigma/layout-force": "^5.0.2",
|
||||||
|
"@react-sigma/layout-forceatlas2": "^5.0.2",
|
||||||
|
"@react-sigma/layout-noverlap": "^5.0.2",
|
||||||
|
"@react-sigma/layout-random": "^5.0.2",
|
||||||
|
"@react-sigma/minimap": "^5.0.2",
|
||||||
|
"@sigma/edge-curve": "^3.1.0",
|
||||||
|
"@sigma/node-border": "^3.0.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.0.4",
|
||||||
|
"graphology": "^0.26.0",
|
||||||
|
"graphology-generators": "^0.11.2",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"seedrandom": "^3.0.5",
|
||||||
|
"sigma": "^3.0.1",
|
||||||
|
"tailwind-merge": "^3.0.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.20.0",
|
||||||
|
"@stylistic/eslint-plugin-js": "^3.1.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.4",
|
||||||
|
"@types/bun": "^1.2.2",
|
||||||
|
"@types/node": "^22.13.1",
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"@types/seedrandom": "^3.0.8",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
|
"eslint": "^9.20.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-react": "^7.37.4",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.18",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"graphology-types": "^0.24.8",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"tailwindcss": "^4.0.4",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "~5.7.3",
|
||||||
|
"typescript-eslint": "^8.23.0",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
|
}
|
1
lightrag/api/graph_viewer_webui/public/vite.svg
Normal file
1
lightrag/api/graph_viewer_webui/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
14
lightrag/api/graph_viewer_webui/src/App.tsx
Normal file
14
lightrag/api/graph_viewer_webui/src/App.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import ThemeProvider from '@/components/ThemeProvider'
|
||||||
|
import { GraphViewer } from '@/GraphViewer'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider defaultTheme="system" storageKey="lightrag-viewer-webui-theme">
|
||||||
|
<div className="h-screen w-screen">
|
||||||
|
<GraphViewer />
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
172
lightrag/api/graph_viewer_webui/src/GraphViewer.tsx
Normal file
172
lightrag/api/graph_viewer_webui/src/GraphViewer.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
// import { MiniMap } from '@react-sigma/minimap'
|
||||||
|
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
|
||||||
|
import { Settings as SigmaSettings } from 'sigma/settings'
|
||||||
|
import { GraphSearchOption } from '@react-sigma/graph-search'
|
||||||
|
import { EdgeArrowProgram, NodePointProgram, NodeCircleProgram } from 'sigma/rendering'
|
||||||
|
import { NodeBorderProgram } from '@sigma/node-border'
|
||||||
|
import EdgeCurveProgram, { EdgeCurvedArrowProgram } from '@sigma/edge-curve'
|
||||||
|
|
||||||
|
import FocusOnNode from '@/components/FocusOnNode'
|
||||||
|
import LayoutsControl from '@/components/LayoutsControl'
|
||||||
|
import GraphControl from '@/components/GraphControl'
|
||||||
|
import ThemeToggle from '@/components/ThemeToggle'
|
||||||
|
import ZoomControl from '@/components/ZoomControl'
|
||||||
|
import FullScreenControl from '@/components/FullScreenControl'
|
||||||
|
import Settings from '@/components/Settings'
|
||||||
|
import GraphSearch from '@/components/GraphSearch'
|
||||||
|
|
||||||
|
import { useSettingsStore } from '@/lib/settings'
|
||||||
|
|
||||||
|
import '@react-sigma/core/lib/style.css'
|
||||||
|
import '@react-sigma/graph-search/lib/style.css'
|
||||||
|
|
||||||
|
// Sigma settings
|
||||||
|
const defaultSigmaSettings: Partial<SigmaSettings> = {
|
||||||
|
allowInvalidContainer: true,
|
||||||
|
defaultNodeType: 'default',
|
||||||
|
defaultEdgeType: 'curvedArrow',
|
||||||
|
renderEdgeLabels: false,
|
||||||
|
edgeProgramClasses: {
|
||||||
|
arrow: EdgeArrowProgram,
|
||||||
|
curvedArrow: EdgeCurvedArrowProgram,
|
||||||
|
curvedNoArrow: EdgeCurveProgram
|
||||||
|
},
|
||||||
|
nodeProgramClasses: {
|
||||||
|
default: NodeBorderProgram,
|
||||||
|
circel: NodeCircleProgram,
|
||||||
|
point: NodePointProgram
|
||||||
|
},
|
||||||
|
labelGridCellSize: 60,
|
||||||
|
labelRenderedSizeThreshold: 12,
|
||||||
|
enableEdgeEvents: true,
|
||||||
|
labelColor: {
|
||||||
|
color: '#000',
|
||||||
|
attribute: 'labelColor'
|
||||||
|
},
|
||||||
|
edgeLabelColor: {
|
||||||
|
color: '#000',
|
||||||
|
attribute: 'labelColor'
|
||||||
|
},
|
||||||
|
edgeLabelSize: 8,
|
||||||
|
labelSize: 12
|
||||||
|
// minEdgeThickness: 2
|
||||||
|
// labelFont: 'Lato, sans-serif'
|
||||||
|
}
|
||||||
|
|
||||||
|
const GraphEvents = () => {
|
||||||
|
const registerEvents = useRegisterEvents()
|
||||||
|
const sigma = useSigma()
|
||||||
|
const [draggedNode, setDraggedNode] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Register the events
|
||||||
|
registerEvents({
|
||||||
|
downNode: (e) => {
|
||||||
|
setDraggedNode(e.node)
|
||||||
|
sigma.getGraph().setNodeAttribute(e.node, 'highlighted', true)
|
||||||
|
},
|
||||||
|
// On mouse move, if the drag mode is enabled, we change the position of the draggedNode
|
||||||
|
mousemovebody: (e) => {
|
||||||
|
if (!draggedNode) return
|
||||||
|
// Get new position of node
|
||||||
|
const pos = sigma.viewportToGraph(e)
|
||||||
|
sigma.getGraph().setNodeAttribute(draggedNode, 'x', pos.x)
|
||||||
|
sigma.getGraph().setNodeAttribute(draggedNode, 'y', pos.y)
|
||||||
|
|
||||||
|
// Prevent sigma to move camera:
|
||||||
|
e.preventSigmaDefault()
|
||||||
|
e.original.preventDefault()
|
||||||
|
e.original.stopPropagation()
|
||||||
|
},
|
||||||
|
// On mouse up, we reset the autoscale and the dragging mode
|
||||||
|
mouseup: () => {
|
||||||
|
if (draggedNode) {
|
||||||
|
setDraggedNode(null)
|
||||||
|
sigma.getGraph().removeNodeAttribute(draggedNode, 'highlighted')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Disable the autoscale at the first down interaction
|
||||||
|
mousedown: () => {
|
||||||
|
if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [registerEvents, sigma, draggedNode])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphViewer = () => {
|
||||||
|
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
||||||
|
const [focusedNode, setFocusedNode] = useState<string | null>(null)
|
||||||
|
const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings)
|
||||||
|
const [autoMoveToFocused, setAutoMoveToFocused] = useState(false)
|
||||||
|
|
||||||
|
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||||
|
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||||
|
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSigmaSettings({
|
||||||
|
...defaultSigmaSettings,
|
||||||
|
enableEdgeEvents,
|
||||||
|
renderEdgeLabels
|
||||||
|
})
|
||||||
|
}, [enableEdgeEvents, renderEdgeLabels])
|
||||||
|
|
||||||
|
const onFocus = useCallback(
|
||||||
|
(value: GraphSearchOption | null) => {
|
||||||
|
if (value === null) setFocusedNode(null)
|
||||||
|
else if (value.type === 'nodes') setFocusedNode(value.id)
|
||||||
|
},
|
||||||
|
[setFocusedNode]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSelect = useCallback(
|
||||||
|
(value: GraphSearchOption | null) => {
|
||||||
|
if (value === null) setSelectedNode(null)
|
||||||
|
else if (value.type === 'nodes') {
|
||||||
|
setAutoMoveToFocused(true)
|
||||||
|
setSelectedNode(value.id)
|
||||||
|
setTimeout(() => setAutoMoveToFocused(false), 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setSelectedNode, setAutoMoveToFocused]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SigmaContainer settings={sigmaSettings} className="!bg-background !size-full overflow-hidden">
|
||||||
|
<GraphControl
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
setSelectedNode={setSelectedNode}
|
||||||
|
focusedNode={focusedNode}
|
||||||
|
setFocusedNode={setFocusedNode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{enableNodeDrag && <GraphEvents />}
|
||||||
|
|
||||||
|
<FocusOnNode node={focusedNode ?? selectedNode} move={autoMoveToFocused} />
|
||||||
|
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<GraphSearch
|
||||||
|
type="nodes"
|
||||||
|
value={selectedNode ? { type: 'nodes', id: selectedNode } : null}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onChange={onSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background/20 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||||
|
<Settings />
|
||||||
|
<ZoomControl />
|
||||||
|
<LayoutsControl />
|
||||||
|
<FullScreenControl />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||||
|
<MiniMap width="100px" height="100px" />
|
||||||
|
</div> */}
|
||||||
|
</SigmaContainer>
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,27 @@
|
|||||||
|
import { useCamera, useSigma } from '@react-sigma/core'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that highlights a node and centers the camera on it.
|
||||||
|
*/
|
||||||
|
const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) => {
|
||||||
|
const sigma = useSigma()
|
||||||
|
const { gotoNode } = useCamera()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
|
||||||
|
}
|
||||||
|
}, [node, move, sigma, gotoNode])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FocusOnNode
|
@@ -0,0 +1,27 @@
|
|||||||
|
import { useFullScreen } from '@react-sigma/core'
|
||||||
|
import { MaximizeIcon, MinimizeIcon } from 'lucide-react'
|
||||||
|
import { controlButtonVariant } from '@/lib/constants'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that toggles full screen mode.
|
||||||
|
*/
|
||||||
|
const FullScreenControl = () => {
|
||||||
|
const { isFullScreen, toggle } = useFullScreen()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isFullScreen ? (
|
||||||
|
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Windowed">
|
||||||
|
<MinimizeIcon />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Full Screen">
|
||||||
|
<MaximizeIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FullScreenControl
|
194
lightrag/api/graph_viewer_webui/src/components/GraphControl.tsx
Normal file
194
lightrag/api/graph_viewer_webui/src/components/GraphControl.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
||||||
|
// import { useLayoutCircular } from '@react-sigma/layout-circular'
|
||||||
|
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'
|
||||||
|
import useLightragGraph, { EdgeType, NodeType } from '@/hooks/useLightragGraph'
|
||||||
|
import useTheme from '@/hooks/useTheme'
|
||||||
|
import * as Constants from '@/lib/constants'
|
||||||
|
import { useSettingsStore } from '@/lib/settings'
|
||||||
|
|
||||||
|
const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
|
||||||
|
if (ev.type.startsWith('mouse')) {
|
||||||
|
if ((ev as MouseEvent).buttons !== 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const GraphControl = ({
|
||||||
|
disableHoverEffect,
|
||||||
|
selectedNode,
|
||||||
|
setSelectedNode,
|
||||||
|
focusedNode,
|
||||||
|
setFocusedNode
|
||||||
|
}: {
|
||||||
|
disableHoverEffect?: boolean
|
||||||
|
selectedNode: string | null
|
||||||
|
setSelectedNode: (node: string | null) => void
|
||||||
|
focusedNode: string | null
|
||||||
|
setFocusedNode: (node: string | null) => void
|
||||||
|
}) => {
|
||||||
|
const { lightrageGraph } = useLightragGraph()
|
||||||
|
const sigma = useSigma<NodeType, EdgeType>()
|
||||||
|
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
|
||||||
|
const setSettings = useSetSettings<NodeType, EdgeType>()
|
||||||
|
const loadGraph = useLoadGraph<NodeType, EdgeType>()
|
||||||
|
const { assign: assignLayout } = useLayoutForceAtlas2({
|
||||||
|
iterations: 20
|
||||||
|
})
|
||||||
|
const [focusedEdge, setfocusedEdge] = useState<string | null>(null)
|
||||||
|
const [selectedEdge, setSelectedEdge] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When component mount
|
||||||
|
* => load the graph
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
// Create & load the graph
|
||||||
|
const graph = lightrageGraph()
|
||||||
|
loadGraph(graph)
|
||||||
|
if (!(graph as any).__force_applied) {
|
||||||
|
assignLayout()
|
||||||
|
Object.assign(graph, { __force_applied: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the events
|
||||||
|
registerEvents({
|
||||||
|
enterNode: (event) => {
|
||||||
|
if (!isButtonPressed(event.event.original)) {
|
||||||
|
setFocusedNode(event.node)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leaveNode: (event) => {
|
||||||
|
if (!isButtonPressed(event.event.original)) {
|
||||||
|
setFocusedNode(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clickNode: (event) => {
|
||||||
|
setSelectedNode(event.node)
|
||||||
|
setSelectedEdge(null)
|
||||||
|
},
|
||||||
|
clickEdge: (event) => {
|
||||||
|
setSelectedEdge(event.edge)
|
||||||
|
setSelectedNode(null)
|
||||||
|
},
|
||||||
|
enterEdge: (event) => {
|
||||||
|
if (!isButtonPressed(event.event.original)) {
|
||||||
|
setfocusedEdge(event.edge)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leaveEdge: (event) => {
|
||||||
|
if (!isButtonPressed(event.event.original)) {
|
||||||
|
setfocusedEdge(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clickStage: () => {
|
||||||
|
setSelectedEdge(null)
|
||||||
|
setSelectedNode(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [assignLayout, loadGraph, registerEvents, lightrageGraph, setFocusedNode, setSelectedNode])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When component mount or hovered node change
|
||||||
|
* => Setting the sigma reducers
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const isDarkTheme = theme === 'dark'
|
||||||
|
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
|
||||||
|
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
|
||||||
|
|
||||||
|
setSettings({
|
||||||
|
nodeReducer: (node, data) => {
|
||||||
|
const graph = sigma.getGraph()
|
||||||
|
const newData: NodeType & {
|
||||||
|
labelColor?: string
|
||||||
|
borderColor?: string
|
||||||
|
} = { ...data, highlighted: data.highlighted || false, labelColor }
|
||||||
|
|
||||||
|
if (!disableHoverEffect) {
|
||||||
|
newData.highlighted = false
|
||||||
|
const _focusedNode = focusedNode || selectedNode
|
||||||
|
const _focusedEdge = focusedEdge || selectedEdge
|
||||||
|
|
||||||
|
if (_focusedNode) {
|
||||||
|
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
||||||
|
newData.highlighted = true
|
||||||
|
if (node === selectedNode) {
|
||||||
|
newData.borderColor = Constants.nodeBorderColorSelected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (_focusedEdge) {
|
||||||
|
if (graph.extremities(_focusedEdge).includes(node)) {
|
||||||
|
newData.highlighted = true
|
||||||
|
newData.size = 3
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return newData
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newData.highlighted) {
|
||||||
|
if (isDarkTheme) {
|
||||||
|
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newData.color = Constants.nodeColorDisabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newData
|
||||||
|
},
|
||||||
|
edgeReducer: (edge, data) => {
|
||||||
|
const graph = sigma.getGraph()
|
||||||
|
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
|
||||||
|
|
||||||
|
if (!disableHoverEffect) {
|
||||||
|
const _focusedNode = focusedNode || selectedNode
|
||||||
|
|
||||||
|
if (_focusedNode) {
|
||||||
|
if (hideUnselectedEdges) {
|
||||||
|
if (!graph.extremities(edge).includes(_focusedNode)) {
|
||||||
|
newData.hidden = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (graph.extremities(edge).includes(_focusedNode)) {
|
||||||
|
newData.color = Constants.edgeColorHighlighted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (focusedEdge || selectedEdge) {
|
||||||
|
if (edge === selectedEdge) {
|
||||||
|
newData.color = Constants.edgeColorSelected
|
||||||
|
} else if (edge === focusedEdge) {
|
||||||
|
newData.color = Constants.edgeColorHighlighted
|
||||||
|
} else if (hideUnselectedEdges) {
|
||||||
|
newData.hidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
selectedNode,
|
||||||
|
focusedNode,
|
||||||
|
selectedEdge,
|
||||||
|
focusedEdge,
|
||||||
|
setSettings,
|
||||||
|
sigma,
|
||||||
|
disableHoverEffect,
|
||||||
|
theme,
|
||||||
|
hideUnselectedEdges
|
||||||
|
])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GraphControl
|
||||||
|
|
103
lightrag/api/graph_viewer_webui/src/components/GraphSearch.tsx
Normal file
103
lightrag/api/graph_viewer_webui/src/components/GraphSearch.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { FC, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
EdgeById,
|
||||||
|
NodeById,
|
||||||
|
useGraphSearch,
|
||||||
|
GraphSearchInputProps,
|
||||||
|
GraphSearchContextProvider,
|
||||||
|
GraphSearchContextProviderProps
|
||||||
|
} from '@react-sigma/graph-search'
|
||||||
|
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
||||||
|
import { searchResultLimit } from '@/lib/constants'
|
||||||
|
|
||||||
|
interface OptionItem {
|
||||||
|
id: string
|
||||||
|
type: 'nodes' | 'edges' | 'message'
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionComponent(item: OptionItem) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{item.type === 'nodes' && <NodeById id={item.id} />}
|
||||||
|
{item.type === 'edges' && <EdgeById id={item.id} />}
|
||||||
|
{item.type === 'message' && <div>{item.message}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = '__message_item'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component thats display the search input.
|
||||||
|
*/
|
||||||
|
export const GraphSearchInput = ({
|
||||||
|
onChange,
|
||||||
|
onFocus,
|
||||||
|
type,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
onChange: GraphSearchInputProps['onChange']
|
||||||
|
onFocus?: GraphSearchInputProps['onFocus']
|
||||||
|
type?: GraphSearchInputProps['type']
|
||||||
|
value?: GraphSearchInputProps['value']
|
||||||
|
}) => {
|
||||||
|
const { search } = useGraphSearch()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading the options while the user is typing.
|
||||||
|
*/
|
||||||
|
const loadOptions = useCallback(
|
||||||
|
async (query?: string): Promise<OptionItem[]> => {
|
||||||
|
if (onFocus) onFocus(null)
|
||||||
|
if (!query) return []
|
||||||
|
const result = (await search(query, type)) as OptionItem[]
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
return result.length <= searchResultLimit
|
||||||
|
? result
|
||||||
|
: [
|
||||||
|
...result.slice(0, searchResultLimit),
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
id: messageId,
|
||||||
|
message: `And ${result.length - searchResultLimit} others`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
[type, search, onFocus]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncSelect
|
||||||
|
className="w-52 rounded-xl border-1 backdrop-blur-lg"
|
||||||
|
fetcher={loadOptions}
|
||||||
|
renderOption={OptionComponent}
|
||||||
|
getOptionValue={(item) => item.id}
|
||||||
|
value={value && value.type !== 'message' ? value.id : null}
|
||||||
|
onChange={(id) => {
|
||||||
|
if (id !== messageId && type) onChange(id ? { id, type } : null)
|
||||||
|
}}
|
||||||
|
onFocus={(id) => {
|
||||||
|
if (id !== messageId && onFocus && type) onFocus(id ? { id, type } : null)
|
||||||
|
}}
|
||||||
|
label={'item'}
|
||||||
|
preload={false}
|
||||||
|
placeholder="Type search here..."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that display the search.
|
||||||
|
*/
|
||||||
|
const GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({
|
||||||
|
minisearchOptions,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<GraphSearchContextProvider minisearchOptions={minisearchOptions}>
|
||||||
|
<GraphSearchInput {...props} />
|
||||||
|
</GraphSearchContextProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default GraphSearch
|
@@ -0,0 +1,177 @@
|
|||||||
|
import { useSigma } from '@react-sigma/core'
|
||||||
|
import { animateNodes } from 'sigma/utils'
|
||||||
|
import { useLayoutCirclepack } from '@react-sigma/layout-circlepack'
|
||||||
|
import { useLayoutCircular } from '@react-sigma/layout-circular'
|
||||||
|
import { LayoutHook, LayoutWorkerHook, WorkerLayoutControlProps } from '@react-sigma/layout-core'
|
||||||
|
import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
|
||||||
|
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||||
|
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
|
||||||
|
import { useLayoutRandom } from '@react-sigma/layout-random'
|
||||||
|
import { useCallback, useMemo, useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||||
|
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/Command'
|
||||||
|
import { controlButtonVariant } from '@/lib/constants'
|
||||||
|
|
||||||
|
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
type LayoutName =
|
||||||
|
| 'Circular'
|
||||||
|
| 'Circlepack'
|
||||||
|
| 'Random'
|
||||||
|
| 'Noverlaps'
|
||||||
|
| 'Force Directed'
|
||||||
|
| 'Force Atlas'
|
||||||
|
|
||||||
|
const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => {
|
||||||
|
const sigma = useSigma()
|
||||||
|
const { stop, start, isRunning } = layout
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init component when Sigma or component settings change.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigma) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// we run the algo
|
||||||
|
let timeout: number | null = null
|
||||||
|
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
|
||||||
|
start()
|
||||||
|
// set a timeout to stop it
|
||||||
|
timeout =
|
||||||
|
autoRunFor > 0
|
||||||
|
? window.setTimeout(() => { stop() }, autoRunFor) // prettier-ignore
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
//cleaning
|
||||||
|
return () => {
|
||||||
|
stop()
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [autoRunFor, start, stop, sigma])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => (isRunning ? stop() : start())}
|
||||||
|
tooltip={isRunning ? 'Stop the layout animation' : 'Start the layout animation'}
|
||||||
|
variant={controlButtonVariant}
|
||||||
|
>
|
||||||
|
{isRunning ? <PauseIcon /> : <PlayIcon />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that controls the layout of the graph.
|
||||||
|
*/
|
||||||
|
const LayoutsControl = () => {
|
||||||
|
const sigma = useSigma()
|
||||||
|
const [layout, setLayout] = useState<LayoutName>('Circular')
|
||||||
|
const [opened, setOpened] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const layoutCircular = useLayoutCircular()
|
||||||
|
const layoutCirclepack = useLayoutCirclepack()
|
||||||
|
const layoutRandom = useLayoutRandom()
|
||||||
|
const layoutNoverlap = useLayoutNoverlap({ settings: { margin: 1 } })
|
||||||
|
const layoutForce = useLayoutForce({ maxIterations: 20 })
|
||||||
|
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: 20 })
|
||||||
|
const workerNoverlap = useWorkerLayoutNoverlap()
|
||||||
|
const workerForce = useWorkerLayoutForce()
|
||||||
|
const workerForceAtlas2 = useWorkerLayoutForceAtlas2()
|
||||||
|
|
||||||
|
const layouts = useMemo(() => {
|
||||||
|
return {
|
||||||
|
Circular: {
|
||||||
|
layout: layoutCircular
|
||||||
|
},
|
||||||
|
Circlepack: {
|
||||||
|
layout: layoutCirclepack
|
||||||
|
},
|
||||||
|
Random: {
|
||||||
|
layout: layoutRandom
|
||||||
|
},
|
||||||
|
Noverlaps: {
|
||||||
|
layout: layoutNoverlap,
|
||||||
|
worker: workerNoverlap
|
||||||
|
},
|
||||||
|
'Force Directed': {
|
||||||
|
layout: layoutForce,
|
||||||
|
worker: workerForce
|
||||||
|
},
|
||||||
|
'Force Atlas': {
|
||||||
|
layout: layoutForceAtlas2,
|
||||||
|
worker: workerForceAtlas2
|
||||||
|
}
|
||||||
|
} as { [key: string]: { layout: LayoutHook; worker?: LayoutWorkerHook } }
|
||||||
|
}, [
|
||||||
|
layoutCirclepack,
|
||||||
|
layoutCircular,
|
||||||
|
layoutForce,
|
||||||
|
layoutForceAtlas2,
|
||||||
|
layoutNoverlap,
|
||||||
|
layoutRandom,
|
||||||
|
workerForce,
|
||||||
|
workerNoverlap,
|
||||||
|
workerForceAtlas2
|
||||||
|
])
|
||||||
|
|
||||||
|
const runLayout = useCallback(
|
||||||
|
(newLayout: LayoutName) => {
|
||||||
|
console.debug(newLayout)
|
||||||
|
const { positions } = layouts[newLayout].layout
|
||||||
|
animateNodes(sigma.getGraph(), positions(), { duration: 500 })
|
||||||
|
setLayout(newLayout)
|
||||||
|
},
|
||||||
|
[layouts, sigma]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{layouts[layout] && 'worker' in layouts[layout] && (
|
||||||
|
<WorkerLayoutControl layout={layouts[layout].worker!} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Popover open={opened} onOpenChange={setOpened}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={controlButtonVariant}
|
||||||
|
onClick={() => setOpened((e: boolean) => !e)}
|
||||||
|
tooltip="Layout Graph"
|
||||||
|
>
|
||||||
|
<GripIcon />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="right" align="center" className="p-1">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
{Object.keys(layouts).map((name) => (
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
runLayout(name as LayoutName)
|
||||||
|
}}
|
||||||
|
key={name}
|
||||||
|
className="cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LayoutsControl
|
102
lightrag/api/graph_viewer_webui/src/components/Settings.tsx
Normal file
102
lightrag/api/graph_viewer_webui/src/components/Settings.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||||
|
import { Checkbox } from '@/components/ui/Checkbox'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { controlButtonVariant } from '@/lib/constants'
|
||||||
|
import { useSettingsStore } from '@/lib/settings'
|
||||||
|
|
||||||
|
import { SettingsIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays a checkbox with a label.
|
||||||
|
*/
|
||||||
|
const LabeledCheckBox = ({
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
|
label
|
||||||
|
}: {
|
||||||
|
checked: boolean
|
||||||
|
onCheckedChange: () => void
|
||||||
|
label: string
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
|
||||||
|
<label
|
||||||
|
htmlFor="terms"
|
||||||
|
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays a popover with settings options.
|
||||||
|
*/
|
||||||
|
export default function Settings() {
|
||||||
|
const [opened, setOpened] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||||
|
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||||
|
const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
|
||||||
|
const showEdgeLabel = useSettingsStore.use.showEdgeLabel()
|
||||||
|
|
||||||
|
const setEnableNodeDrag = useCallback(
|
||||||
|
() => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const setEnableEdgeEvents = useCallback(
|
||||||
|
() => useSettingsStore.setState((pre) => ({ enableEdgeEvents: !pre.enableEdgeEvents })),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const setEnableHideUnselectedEdges = useCallback(
|
||||||
|
() =>
|
||||||
|
useSettingsStore.setState((pre) => ({
|
||||||
|
enableHideUnselectedEdges: !pre.enableHideUnselectedEdges
|
||||||
|
})),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const setShowEdgeLabel = useCallback(
|
||||||
|
() =>
|
||||||
|
useSettingsStore.setState((pre) => ({
|
||||||
|
showEdgeLabel: !pre.showEdgeLabel
|
||||||
|
})),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={opened} onOpenChange={setOpened}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant={controlButtonVariant} tooltip="Settings">
|
||||||
|
<SettingsIcon />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="right" align="start" className="p-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<LabeledCheckBox
|
||||||
|
checked={enableNodeDrag}
|
||||||
|
onCheckedChange={setEnableNodeDrag}
|
||||||
|
label="Node Draggable"
|
||||||
|
/>
|
||||||
|
<LabeledCheckBox
|
||||||
|
checked={enableEdgeEvents}
|
||||||
|
onCheckedChange={setEnableEdgeEvents}
|
||||||
|
label="Edge Events"
|
||||||
|
/>
|
||||||
|
<LabeledCheckBox
|
||||||
|
checked={enableHideUnselectedEdges}
|
||||||
|
onCheckedChange={setEnableHideUnselectedEdges}
|
||||||
|
label="Hide Unselected Edges"
|
||||||
|
/>
|
||||||
|
<LabeledCheckBox
|
||||||
|
checked={showEdgeLabel}
|
||||||
|
onCheckedChange={setShowEdgeLabel}
|
||||||
|
label="Show Edge Label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,57 @@
|
|||||||
|
import { createContext, useEffect, useState } from 'react'
|
||||||
|
import { Theme, useSettingsStore } from '@/lib/settings'
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeProviderState = {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ThemeProviderState = {
|
||||||
|
theme: 'system',
|
||||||
|
setTheme: () => null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that provides the theme state and setter function to its children.
|
||||||
|
*/
|
||||||
|
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(useSettingsStore.getState().theme)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
root.classList.add(systemTheme)
|
||||||
|
setTheme(systemTheme)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
useSettingsStore.getState().setTheme(theme)
|
||||||
|
setTheme(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ThemeProviderContext }
|
@@ -0,0 +1,27 @@
|
|||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import useTheme from '@/hooks/useTheme'
|
||||||
|
import { MoonIcon, SunIcon } from 'lucide-react'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { controlButtonVariant } from '@/lib/constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that toggles the theme between light and dark.
|
||||||
|
*/
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const setLight = useCallback(() => setTheme('light'), [setTheme])
|
||||||
|
const setDark = useCallback(() => setTheme('dark'), [setTheme])
|
||||||
|
|
||||||
|
if (theme === 'dark') {
|
||||||
|
return (
|
||||||
|
<Button onClick={setLight} variant={controlButtonVariant} tooltip="Switch to light theme">
|
||||||
|
<MoonIcon />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button onClick={setDark} variant={controlButtonVariant} tooltip="Switch to dark theme">
|
||||||
|
<SunIcon />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,33 @@
|
|||||||
|
import { useCamera } from '@react-sigma/core'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react'
|
||||||
|
import { controlButtonVariant } from '@/lib/constants'
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that provides zoom controls for the graph viewer.
|
||||||
|
*/
|
||||||
|
const ZoomControl = () => {
|
||||||
|
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
|
||||||
|
|
||||||
|
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
|
||||||
|
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
|
||||||
|
const handleResetZoom = useCallback(() => reset(), [reset])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip="Zoom In">
|
||||||
|
<ZoomInIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip="Zoom Out">
|
||||||
|
<ZoomOutIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant={controlButtonVariant} onClick={handleResetZoom} tooltip="Reset Zoom">
|
||||||
|
<FullscreenIcon />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ZoomControl
|
@@ -0,0 +1,238 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList
|
||||||
|
} from '@/components/ui/Command'
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
description?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AsyncSelectProps<T> {
|
||||||
|
/** Async function to fetch options */
|
||||||
|
fetcher: (query?: string) => Promise<T[]>
|
||||||
|
/** Preload all data ahead of time */
|
||||||
|
preload?: boolean
|
||||||
|
/** Function to filter options */
|
||||||
|
filterFn?: (option: T, query: string) => boolean
|
||||||
|
/** Function to render each option */
|
||||||
|
renderOption: (option: T) => React.ReactNode
|
||||||
|
/** Function to get the value from an option */
|
||||||
|
getOptionValue: (option: T) => string
|
||||||
|
/** Custom not found message */
|
||||||
|
notFound?: React.ReactNode
|
||||||
|
/** Custom loading skeleton */
|
||||||
|
loadingSkeleton?: React.ReactNode
|
||||||
|
/** Currently selected value */
|
||||||
|
value: string | null
|
||||||
|
/** Callback when selection changes */
|
||||||
|
onChange: (value: string) => void
|
||||||
|
/** Callback when focus changes */
|
||||||
|
onFocus: (value: string) => void
|
||||||
|
/** Label for the select field */
|
||||||
|
label: string
|
||||||
|
/** Placeholder text when no selection */
|
||||||
|
placeholder?: string
|
||||||
|
/** Disable the entire select */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Custom width for the popover */
|
||||||
|
width?: string | number
|
||||||
|
/** Custom class names */
|
||||||
|
className?: string
|
||||||
|
/** Custom trigger button class names */
|
||||||
|
triggerClassName?: string
|
||||||
|
/** Custom no results message */
|
||||||
|
noResultsMessage?: string
|
||||||
|
/** Allow clearing the selection */
|
||||||
|
clearable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AsyncSelect<T>({
|
||||||
|
fetcher,
|
||||||
|
preload,
|
||||||
|
filterFn,
|
||||||
|
renderOption,
|
||||||
|
getOptionValue,
|
||||||
|
notFound,
|
||||||
|
loadingSkeleton,
|
||||||
|
label,
|
||||||
|
placeholder = 'Select...',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onFocus,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
noResultsMessage
|
||||||
|
}: AsyncSelectProps<T>) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [options, setOptions] = useState<T[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [selectedValue, setSelectedValue] = useState(value)
|
||||||
|
const [focusedValue, setFocusedValue] = useState<string | null>(null)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
||||||
|
const [originalOptions, setOriginalOptions] = useState<T[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
setSelectedValue(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
// Effect for initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeOptions = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
// If we have a value, use it for the initial search
|
||||||
|
const data = value !== null ? await fetcher(value) : []
|
||||||
|
setOriginalOptions(data)
|
||||||
|
setOptions(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
initializeOptions()
|
||||||
|
}
|
||||||
|
}, [mounted, fetcher, value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOptions = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const data = await fetcher(debouncedSearchTerm)
|
||||||
|
setOriginalOptions(data)
|
||||||
|
setOptions(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
fetchOptions()
|
||||||
|
} else if (!preload) {
|
||||||
|
fetchOptions()
|
||||||
|
} else if (preload) {
|
||||||
|
if (debouncedSearchTerm) {
|
||||||
|
setOptions(
|
||||||
|
originalOptions.filter((option) =>
|
||||||
|
filterFn ? filterFn(option, debouncedSearchTerm) : true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setOptions(originalOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fetcher, debouncedSearchTerm, mounted, preload, filterFn])
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(currentValue: string) => {
|
||||||
|
console.log('handleSelect')
|
||||||
|
if (currentValue !== selectedValue) {
|
||||||
|
setSelectedValue(currentValue)
|
||||||
|
onChange(currentValue)
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
[selectedValue, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFocus = useCallback(
|
||||||
|
(currentValue: string) => {
|
||||||
|
if (currentValue !== focusedValue) {
|
||||||
|
setFocusedValue(currentValue)
|
||||||
|
onFocus(currentValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[focusedValue, onFocus]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(disabled && 'cursor-not-allowed opacity-50', className)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false} className="bg-transparent">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<CommandInput
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={searchTerm}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSearchTerm(value)
|
||||||
|
if (value && !open) setOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{loading && options.length > 0 && (
|
||||||
|
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CommandList className="max-h-auto" hidden={!open}>
|
||||||
|
{error && <div className="text-destructive p-4 text-center">{error}</div>}
|
||||||
|
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
|
||||||
|
{!loading &&
|
||||||
|
!error &&
|
||||||
|
options.length === 0 &&
|
||||||
|
(notFound || (
|
||||||
|
<CommandEmpty>{noResultsMessage ?? `No ${label.toLowerCase()} found.`}</CommandEmpty>
|
||||||
|
))}
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option, idx) => (
|
||||||
|
<>
|
||||||
|
<CommandItem
|
||||||
|
key={getOptionValue(option)}
|
||||||
|
value={getOptionValue(option)}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onMouseEnter={() => handleFocus(getOptionValue(option))}
|
||||||
|
>
|
||||||
|
{renderOption(option)}
|
||||||
|
</CommandItem>
|
||||||
|
{idx !== options.length - 1 && <div className="bg-foreground/10 h-[1px]" />}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefaultLoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem disabled>
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<div className="bg-muted h-6 w-6 animate-pulse rounded-full" />
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||||
|
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)
|
||||||
|
}
|
77
lightrag/api/graph_viewer_webui/src/components/ui/Button.tsx
Normal file
77
lightrag/api/graph_viewer_webui/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
tooltip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, tooltip, side = 'right', asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
if (!tooltip) {
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export type ButtonVariantType = Exclude<
|
||||||
|
NonNullable<Parameters<typeof buttonVariants>[0]>['variant'],
|
||||||
|
undefined
|
||||||
|
>
|
||||||
|
|
||||||
|
export default Button
|
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||||
|
import { Check } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
143
lightrag/api/graph_viewer_webui/src/components/ui/Command.tsx
Normal file
143
lightrag/api/graph_viewer_webui/src/components/ui/Command.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { type DialogProps } from '@radix-ui/react-dialog'
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Dialog, DialogContent } from './Dialog'
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
// eslint-disable-next-line react/no-unknown-property
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('bg-border -mx-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
// eslint-disable-next-line @stylistic/js/quotes
|
||||||
|
"data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CommandShortcut.displayName = 'CommandShortcut'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator
|
||||||
|
}
|
102
lightrag/api/graph_viewer_webui/src/components/ui/Dialog.tsx
Normal file
102
lightrag/api/graph_viewer_webui/src/components/ui/Dialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = 'DialogHeader'
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = 'DialogFooter'
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription
|
||||||
|
}
|
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
17
lightrag/api/graph_viewer_webui/src/hooks/useDebounce.tsx
Normal file
17
lightrag/api/graph_viewer_webui/src/hooks/useDebounce.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedValue(value)
|
||||||
|
}, delay)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [value, delay])
|
||||||
|
|
||||||
|
return debouncedValue
|
||||||
|
}
|
224
lightrag/api/graph_viewer_webui/src/hooks/useLightragGraph.tsx
Normal file
224
lightrag/api/graph_viewer_webui/src/hooks/useLightragGraph.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import Graph, { DirectedGraph } from 'graphology'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { randomColor } from '@/lib/utils'
|
||||||
|
import * as Constants from '@/lib/constants'
|
||||||
|
|
||||||
|
type RawNodeType = {
|
||||||
|
id: string
|
||||||
|
labels: string[]
|
||||||
|
properties: Record<string, any>
|
||||||
|
|
||||||
|
size: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
color: string
|
||||||
|
|
||||||
|
degree: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawEdgeType = {
|
||||||
|
id: string
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
type: string
|
||||||
|
properties: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
class RawGraph {
|
||||||
|
nodes: RawNodeType[] = []
|
||||||
|
edges: RawEdgeType[] = []
|
||||||
|
nodeIdMap: Record<string, number> = {}
|
||||||
|
edgeIdMap: Record<string, number> = {}
|
||||||
|
|
||||||
|
getNode = (nodeId: string) => {
|
||||||
|
const nodeIndex = this.nodeIdMap[nodeId]
|
||||||
|
if (nodeIndex !== undefined) {
|
||||||
|
return this.nodes[nodeIndex]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
getEdge = (edgeId: string) => {
|
||||||
|
const edgeIndex = this.edgeIdMap[edgeId]
|
||||||
|
if (edgeIndex !== undefined) {
|
||||||
|
return this.edges[edgeIndex]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateGraph = (graph: RawGraph) => {
|
||||||
|
if (!graph) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
if (!node.id || !node.labels || !node.properties) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (!edge.id || !edge.source || !edge.target || !edge.type || !edge.properties) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
const source = graph.getNode(edge.source)
|
||||||
|
const target = graph.getNode(edge.target)
|
||||||
|
if (source == undefined || target == undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeType = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
label: string
|
||||||
|
size: number
|
||||||
|
color: string
|
||||||
|
highlighted?: boolean
|
||||||
|
}
|
||||||
|
export type EdgeType = { label: string }
|
||||||
|
|
||||||
|
const fetchGraph = async (label: string) => {
|
||||||
|
const response = await fetch(`http://localhost:9621/graphs?label=${label}`)
|
||||||
|
const rawData = await response.json()
|
||||||
|
|
||||||
|
let rawGraph = null
|
||||||
|
|
||||||
|
if (rawData) {
|
||||||
|
const nodeIdMap: Record<string, number> = {}
|
||||||
|
const edgeIdMap: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.nodes.length; i++) {
|
||||||
|
const node = rawData.nodes[i]
|
||||||
|
nodeIdMap[node.id] = i
|
||||||
|
|
||||||
|
node.x = Math.random()
|
||||||
|
node.y = Math.random()
|
||||||
|
node.color = randomColor()
|
||||||
|
node.degree = 0
|
||||||
|
node.size = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.edges.length; i++) {
|
||||||
|
const edge = rawData.edges[i]
|
||||||
|
edgeIdMap[edge.id] = i
|
||||||
|
|
||||||
|
const source = nodeIdMap[edge.source]
|
||||||
|
const target = nodeIdMap[edge.target]
|
||||||
|
if (source !== undefined && source !== undefined) {
|
||||||
|
const sourceNode = rawData.nodes[source]
|
||||||
|
const targetNode = rawData.nodes[target]
|
||||||
|
sourceNode.degree += 1
|
||||||
|
targetNode.degree += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate node size
|
||||||
|
let minDegree = Number.MAX_SAFE_INTEGER
|
||||||
|
let maxDegree = 0
|
||||||
|
|
||||||
|
for (const node of rawData.nodes) {
|
||||||
|
minDegree = Math.min(minDegree, node.degree)
|
||||||
|
maxDegree = Math.max(maxDegree, node.degree)
|
||||||
|
}
|
||||||
|
const range = maxDegree - minDegree
|
||||||
|
if (range > 0) {
|
||||||
|
const scale = Constants.maxNodeSize - Constants.minNodeSize
|
||||||
|
for (const node of rawData.nodes) {
|
||||||
|
node.size = Math.round(
|
||||||
|
Constants.minNodeSize + scale * Math.pow((node.degree - minDegree) / range, 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawGraph = new RawGraph()
|
||||||
|
rawGraph.nodes = rawData.nodes
|
||||||
|
rawGraph.edges = rawData.edges
|
||||||
|
rawGraph.nodeIdMap = nodeIdMap
|
||||||
|
rawGraph.edgeIdMap = edgeIdMap
|
||||||
|
|
||||||
|
if (!validateGraph(rawGraph)) {
|
||||||
|
rawGraph = null
|
||||||
|
console.error('Invalid graph data')
|
||||||
|
}
|
||||||
|
console.log('Graph data loaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.debug({ data: JSON.parse(JSON.stringify(rawData)) })
|
||||||
|
return rawGraph
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphCache: {
|
||||||
|
label: string | null
|
||||||
|
rawGraph: RawGraph | null
|
||||||
|
convertedGraph: DirectedGraph | null
|
||||||
|
} = {
|
||||||
|
label: null,
|
||||||
|
rawGraph: null,
|
||||||
|
convertedGraph: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const useLightrangeGraph = () => {
|
||||||
|
const [fetchLabel, setFetchLabel] = useState<string>('*')
|
||||||
|
const [rawGraph, setRawGraph] = useState<RawGraph | null>(graphCache.rawGraph)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetchLabel) {
|
||||||
|
if (graphCache.label !== fetchLabel) {
|
||||||
|
fetchGraph(fetchLabel).then((data) => {
|
||||||
|
graphCache.convertedGraph = null
|
||||||
|
graphCache.rawGraph = data
|
||||||
|
graphCache.label = fetchLabel
|
||||||
|
setRawGraph(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setRawGraph(null)
|
||||||
|
}
|
||||||
|
}, [fetchLabel, setRawGraph])
|
||||||
|
|
||||||
|
const lightrageGraph = useCallback(() => {
|
||||||
|
if (graphCache.convertedGraph) {
|
||||||
|
return graphCache.convertedGraph as Graph<NodeType, EdgeType>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the graph
|
||||||
|
const graph = new DirectedGraph()
|
||||||
|
|
||||||
|
for (const rawNode of rawGraph?.nodes ?? []) {
|
||||||
|
graph.addNode(rawNode.id, {
|
||||||
|
label: rawNode.labels.join(' '),
|
||||||
|
color: rawNode.color,
|
||||||
|
x: rawNode.x,
|
||||||
|
y: rawNode.y,
|
||||||
|
size: rawNode.size,
|
||||||
|
// for node-border
|
||||||
|
borderColor: Constants.nodeBorderColor,
|
||||||
|
borderSize: 0.2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawEdge of rawGraph?.edges ?? []) {
|
||||||
|
graph.addDirectedEdge(rawEdge.source, rawEdge.target, {
|
||||||
|
label: rawEdge.type
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
graphCache.convertedGraph = graph
|
||||||
|
return graph as Graph<NodeType, EdgeType>
|
||||||
|
}, [rawGraph])
|
||||||
|
|
||||||
|
return { lightrageGraph, fetchLabel, setFetchLabel }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useLightrangeGraph
|
62
lightrag/api/graph_viewer_webui/src/hooks/useRandomGraph.tsx
Normal file
62
lightrag/api/graph_viewer_webui/src/hooks/useRandomGraph.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Faker, en, faker as fak } from '@faker-js/faker'
|
||||||
|
import Graph, { UndirectedGraph } from 'graphology'
|
||||||
|
import erdosRenyi from 'graphology-generators/random/erdos-renyi'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import seedrandom from 'seedrandom'
|
||||||
|
import { randomColor } from '@/lib/utils'
|
||||||
|
import * as Constants from '@/lib/constants'
|
||||||
|
|
||||||
|
export type NodeType = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
label: string
|
||||||
|
size: number
|
||||||
|
color: string
|
||||||
|
highlighted?: boolean
|
||||||
|
}
|
||||||
|
export type EdgeType = { label: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The goal of this file is to seed random generators if the query params 'seed' is present.
|
||||||
|
*/
|
||||||
|
const useRandomGraph = () => {
|
||||||
|
const [faker, setFaker] = useState<Faker>(fak)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Globally seed the Math.random
|
||||||
|
const params = new URLSearchParams(document.location.search)
|
||||||
|
const seed = params.get('seed') // is the string "Jonathan"
|
||||||
|
if (seed) {
|
||||||
|
seedrandom(seed, { global: true })
|
||||||
|
// seed faker with the random function
|
||||||
|
const f = new Faker({ locale: en })
|
||||||
|
f.seed(Math.random())
|
||||||
|
setFaker(f)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const randomGraph = useCallback(() => {
|
||||||
|
// Create the graph
|
||||||
|
const graph = erdosRenyi(UndirectedGraph, { order: 100, probability: 0.1 })
|
||||||
|
graph.nodes().forEach((node: string) => {
|
||||||
|
graph.mergeNodeAttributes(node, {
|
||||||
|
label: faker.person.fullName(),
|
||||||
|
size: faker.number.int({ min: Constants.minNodeSize, max: Constants.maxNodeSize }),
|
||||||
|
color: randomColor(),
|
||||||
|
x: Math.random(),
|
||||||
|
y: Math.random(),
|
||||||
|
// for node-border
|
||||||
|
borderColor: randomColor(),
|
||||||
|
borderSize: faker.number.float({ min: 0, max: 1, multipleOf: 0.1 }),
|
||||||
|
// for node-image
|
||||||
|
pictoColor: randomColor(),
|
||||||
|
image: faker.image.urlLoremFlickr()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return graph as Graph<NodeType, EdgeType>
|
||||||
|
}, [faker])
|
||||||
|
|
||||||
|
return { faker, randomColor, randomGraph }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useRandomGraph
|
12
lightrag/api/graph_viewer_webui/src/hooks/useTheme.tsx
Normal file
12
lightrag/api/graph_viewer_webui/src/hooks/useTheme.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
import { ThemeProviderContext } from '@/components/ThemeProvider'
|
||||||
|
|
||||||
|
const useTheme = () => {
|
||||||
|
const context = useContext(ThemeProviderContext)
|
||||||
|
|
||||||
|
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTheme
|
179
lightrag/api/graph_viewer_webui/src/index.css
Normal file
179
lightrag/api/graph_viewer_webui/src/index.css
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
|
||||||
|
--color-card: hsl(var(--card));
|
||||||
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
|
||||||
|
--color-chart-1: hsl(var(--chart-1));
|
||||||
|
--color-chart-2: hsl(var(--chart-2));
|
||||||
|
--color-chart-3: hsl(var(--chart-3));
|
||||||
|
--color-chart-4: hsl(var(--chart-4));
|
||||||
|
--color-chart-5: hsl(var(--chart-5));
|
||||||
|
|
||||||
|
--color-sidebar: hsl(var(--sidebar-background));
|
||||||
|
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||||
|
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
||||||
|
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
||||||
|
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
||||||
|
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
||||||
|
--color-sidebar-border: hsl(var(--sidebar-border));
|
||||||
|
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
||||||
|
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
||||||
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 3.9%;
|
||||||
|
--primary: 0 0% 9%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 0 0% 96.1%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96.1%;
|
||||||
|
--muted-foreground: 0 0% 45.1%;
|
||||||
|
--accent: 0 0% 96.1%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 89.8%;
|
||||||
|
--input: 0 0% 89.8%;
|
||||||
|
--ring: 0 0% 3.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar-background: 0 0% 98%;
|
||||||
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
|
--sidebar-accent: 240 4.8% 95.9%;
|
||||||
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 0 0% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 0 0% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 0 0% 9%;
|
||||||
|
--secondary: 0 0% 14.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 0 0% 14.9%;
|
||||||
|
--muted-foreground: 0 0% 63.9%;
|
||||||
|
--accent: 0 0% 14.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 14.9%;
|
||||||
|
--input: 0 0% 14.9%;
|
||||||
|
--ring: 0 0% 83.1%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
--sidebar-background: 240 5.9% 10%;
|
||||||
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
19
lightrag/api/graph_viewer_webui/src/lib/constants.ts
Normal file
19
lightrag/api/graph_viewer_webui/src/lib/constants.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ButtonVariantType } from '@/components/ui/Button'
|
||||||
|
|
||||||
|
export const controlButtonVariant: ButtonVariantType = 'ghost'
|
||||||
|
|
||||||
|
export const labelColorDarkTheme = '#B2EBF2'
|
||||||
|
export const LabelColorHighlightedDarkTheme = '#000'
|
||||||
|
|
||||||
|
export const nodeColorDisabled = '#E2E2E2'
|
||||||
|
export const nodeBorderColor = '#EEEEEE'
|
||||||
|
export const nodeBorderColorSelected = '#F57F17'
|
||||||
|
|
||||||
|
export const edgeColorDarkTheme = '#969696'
|
||||||
|
export const edgeColorSelected = '#F57F17'
|
||||||
|
export const edgeColorHighlighted = '#B2EBF2'
|
||||||
|
|
||||||
|
export const searchResultLimit = 20
|
||||||
|
|
||||||
|
export const minNodeSize = 4
|
||||||
|
export const maxNodeSize = 20
|
55
lightrag/api/graph_viewer_webui/src/lib/settings.ts
Normal file
55
lightrag/api/graph_viewer_webui/src/lib/settings.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { create, StoreApi, UseBoundStore } from 'zustand'
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
theme: Theme
|
||||||
|
enableNodeDrag: boolean
|
||||||
|
enableEdgeEvents: boolean
|
||||||
|
enableHideUnselectedEdges: boolean
|
||||||
|
showEdgeLabel: boolean
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSettingsStoreBase = create<SettingsState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
theme: 'system',
|
||||||
|
enableNodeDrag: true,
|
||||||
|
enableEdgeEvents: false,
|
||||||
|
enableHideUnselectedEdges: true,
|
||||||
|
showEdgeLabel: false,
|
||||||
|
|
||||||
|
setTheme: (theme: Theme) => set({ theme })
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'settings-storage',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
version: 2,
|
||||||
|
migrate: (state: any, version: number) => {
|
||||||
|
if (version < 2) {
|
||||||
|
state.showEdgeLabel = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
type WithSelectors<S> = S extends { getState: () => infer T }
|
||||||
|
? S & { use: { [K in keyof T]: () => T[K] } }
|
||||||
|
: never
|
||||||
|
|
||||||
|
const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {
|
||||||
|
const store = _store as WithSelectors<typeof _store>
|
||||||
|
store.use = {}
|
||||||
|
for (const k of Object.keys(store.getState())) {
|
||||||
|
;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
|
||||||
|
}
|
||||||
|
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSettingsStore = createSelectors(useSettingsStoreBase)
|
||||||
|
|
||||||
|
export { useSettingsStore, type Theme }
|
15
lightrag/api/graph_viewer_webui/src/lib/utils.ts
Normal file
15
lightrag/api/graph_viewer_webui/src/lib/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomColor() {
|
||||||
|
const digits = '0123456789abcdef'
|
||||||
|
let code = '#'
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
code += digits.charAt(Math.floor(Math.random() * 16))
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
10
lightrag/api/graph_viewer_webui/src/main.tsx
Normal file
10
lightrag/api/graph_viewer_webui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
1
lightrag/api/graph_viewer_webui/src/vite-env.d.ts
vendored
Normal file
1
lightrag/api/graph_viewer_webui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
30
lightrag/api/graph_viewer_webui/tsconfig.json
Normal file
30
lightrag/api/graph_viewer_webui/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
16
lightrag/api/graph_viewer_webui/vite.config.ts
Normal file
16
lightrag/api/graph_viewer_webui/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
import react from '@vitejs/plugin-react-swc'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
base: './'
|
||||||
|
})
|
Reference in New Issue
Block a user