chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:54 +08:00
commit 4a4a1fed67
721 changed files with 262090 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Development environment configuration
VITE_BACKEND_URL=http://localhost:9621
VITE_API_PROXY=true
VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status,/static
# LightRAG WebUI — build-time configuration template.
#
# Primary use case: hosting multiple LightRAG instances on one host behind a
# reverse proxy that routes by site prefix:
# https://host/site01/webui/ → WebUI for instance #1
# https://host/site02/webui/ → WebUI for instance #2
# Each site needs its OWN WebUI build with its own prefix values, because
# Vite STATICALLY REPLACES these into the bundle at build time. There is no
# runtime override yet — see the project root `env.example` for the matching
# backend variables (LIGHTRAG_API_PREFIX / LIGHTRAG_WEBUI_PATH).
#
# How to use:
# - Copy to `.env` (always loaded) or `.env.production` (only loaded for
# `bun run build`), or pass on the command line:
# VITE_API_PREFIX=/site01 VITE_WEBUI_PREFIX=/site01/webui/ bun run build
# - Build output goes to `../lightrag/api/webui/`. To produce one bundle per
# site, build once per site and stash the output (e.g. into a per-site
# container image or a per-site directory mounted by the server).
# Browser-visible URL prefix used as `axios.baseURL`, in `fetch()` template
# strings, and as the iframe `src` for the API docs page.
#
# Must equal the backend `LIGHTRAG_API_PREFIX` for the same site (the prefix
# the reverse proxy strips before forwarding to FastAPI). Empty / "/" → no
# prefix (single-instance / no reverse-proxy deployment).
#
# Example for site01: VITE_API_PREFIX=/site01
# VITE_API_PREFIX=/site01
# Browser-visible URL prefix where the WebUI itself is served. Used as
# Vite's `base` option (asset URL prefix in index.html) and by `<a>` links.
#
# Must equal LIGHTRAG_API_PREFIX + LIGHTRAG_WEBUI_PATH + "/" — i.e. the FULL
# path the user's browser sees, including any reverse-proxy prefix in front
# of the in-app mount path. Trailing "/" is required by Vite; the
# normalization helper enforces it automatically.
#
# Example for site01 (WebUI at https://host/site01/webui/):
# VITE_WEBUI_PREFIX=/site01/webui/
# VITE_WEBUI_PREFIX=/site01/webui/
+24
View 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
View 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"]
}
+113
View File
@@ -0,0 +1,113 @@
# LightRAG WebUI
LightRAG WebUI is a React-based web interface for interacting with the LightRAG system. It provides a user-friendly interface for querying, managing, and exploring LightRAG's functionalities.
## Installation
### Using Bun (recommended)
1. **Install Bun:**
If you haven't already installed Bun, follow the official documentation: [https://bun.sh/docs/installation](https://bun.sh/docs/installation)
2. **Install Dependencies:**
In the `lightrag_webui` directory, run the following command to install project dependencies:
```bash
bun install --frozen-lockfile
```
3. **Build the Project:**
Run the following command to build the project:
```bash
bun run build
```
This command will bundle the project and output the built files to the `lightrag/api/webui` directory.
### Using Node.js / npm (alternative)
If Bun is unavailable or the Bun build fails in your environment (e.g., older Linux distributions, restricted environments, or Bun version incompatibilities), you can use Node.js instead:
```bash
npm install
npm run build
```
> **Note:** Tests (`bun test`) still require Bun. All other scripts (`dev`, `build`, `preview`, `lint`) work with both Bun and Node.js/npm.
## Development
- **Start the Development Server:**
```bash
# With Bun
bun run dev
# With Node.js/npm
npm run dev
```
## Script Commands
The following are some commonly used script commands defined in `package.json`:
| Command | Description |
|---------|-------------|
| `bun run dev` / `npm run dev` | Starts the development server |
| `bun run build` / `npm run build` | Builds the project for production |
| `bun run lint` / `npm run lint` | Runs the linter |
| `bun run preview` / `npm run preview` | Previews the production build |
| `bun run build:bun` | Builds using Bun runtime explicitly |
| `bun test` | Runs tests (Bun only) |
## Troubleshooting
### `bun run build` fails silently or with exit code 1
This can happen due to Bun version incompatibilities or restricted environments. Try:
```bash
npm install
npm run build
```
### `could not open bin metadata file` / `corrupted node_modules directory` (WSL)
```
error: could not open bin metadata file
Bun failed to remap this bin to its proper location within node_modules.
This is an indication of a corrupted node_modules directory.
```
This is a [known Bun issue](https://github.com/oven-sh/bun/issues) that surfaces on WSL. Bun installs package binaries by remapping them into `node_modules/.bin`, and that step fails when `node_modules` lives on a Windows-mounted drive (a path under `/mnt/c`, `/mnt/d`, etc.). WSL exposes those drives through the `drvfs`/`9p` filesystem, which does not support the link/metadata operations Bun relies on, so the `.bin` entries end up corrupted even right after a successful `bun install`.
Fixes, in order of preference:
1. **Move the project into the Linux filesystem.** Clone/copy LightRAG somewhere under your WSL home (e.g. `~/LightRAG`) instead of `/mnt/c/...`, then reinstall:
```bash
rm -rf node_modules
bun install --frozen-lockfile
bun run build
```
This is the recommended fix — building from a Windows-mounted path is slow and fragile regardless of this specific error.
2. **If you must stay on the mounted drive, use Node.js/npm instead of Bun** (see *Using Node.js / npm* above):
```bash
rm -rf node_modules
npm install
npm run build
```
3. **Make sure Bun is up to date** (`bun upgrade`) — older Bun releases hit this remapping bug more often.
### `Cannot find package '@/lib'`
This error occurred in older versions when the Vite config used a TypeScript path alias (`@/`) that only Bun could resolve at config load time. This has been fixed by using a relative import in `vite.config.ts`.
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+22
View File
@@ -0,0 +1,22 @@
# Development environment configuration
VITE_BACKEND_URL=http://localhost:9621
VITE_API_PROXY=true
VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status,/static
# ──────────────────────────────────────────────────────────────────────────
# Optional: simulate a reverse-proxied multi-site deployment in `bun run dev`.
# Leave empty for the default no-prefix dev workflow.
#
# When set, `vite.config.ts` injects `window.__LIGHTRAG_CONFIG__` into the
# dev `index.html` (the same mechanism the FastAPI server uses in
# production), and `server.proxy` keys are prefixed automatically so e.g.
# `/site01/documents/...` is forwarded to the backend running with
# LIGHTRAG_API_PREFIX=/site01.
#
# The matching webuiPrefix is derived as `${VITE_DEV_API_PREFIX}/webui/`;
# you only need to set this one variable. Empty / "/" → no prefix.
#
# See docs/MultiSiteDeployment.md for end-to-end examples.
#
# Example for site01: VITE_DEV_API_PREFIX=/site01
# VITE_DEV_API_PREFIX=
+29
View File
@@ -0,0 +1,29 @@
VITE_BACKEND_URL=http://localhost:9621
VITE_API_PROXY=true
VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status
# LightRAG WebUI — runtime configuration template.
#
# IMPORTANT: VITE_API_PREFIX and VITE_WEBUI_PREFIX have been removed. The
# browser-visible URL prefixes are now resolved at REQUEST time from the
# backend's LIGHTRAG_API_PREFIX and LIGHTRAG_WEBUI_PATH (see project root
# `env.example`). One WebUI build is reusable across any number of
# reverse-proxy prefixes / Docker instances — no per-site rebuild needed.
#
# How it works:
# - Build artifacts contain `<!-- __LIGHTRAG_RUNTIME_CONFIG__ -->` and use
# relative asset URLs (`./assets/...`).
# - On each request, the FastAPI server replaces the placeholder with
# `<script>window.__LIGHTRAG_CONFIG__ = { apiPrefix, webuiPrefix }</script>`.
# - The SPA reads `window.__LIGHTRAG_CONFIG__` for `axios.baseURL`, fetch
# templates, and in-app links.
#
# This file only needs to define dev-server proxy settings; copy to `.env`
# if you also want to run `bun run dev` from a deployed checkout.
#
# For dev-time multi-site simulation (running `bun run dev` against a
# backend that is already configured with a site prefix), use
# `VITE_DEV_API_PREFIX` and `VITE_DEV_WEBUI_PREFIX` — see
# `env.development.smaple`.
#
# End-to-end deployment guide: docs/MultiSiteDeployment.md
+36
View File
@@ -0,0 +1,36 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import stylistic from '@stylistic/eslint-plugin'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-config-prettier'
import react from 'eslint-plugin-react'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended, prettier],
files: ['**/*.{ts,tsx,js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
},
settings: { react: { version: '19.0' } },
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'@stylistic': stylistic,
react
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
'@stylistic/indent': ['error', 2],
'@stylistic/quotes': ['error', 'single'],
'@typescript-eslint/no-explicit-any': ['off']
}
}
)
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="icon" type="image/png" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title>
<!-- __LIGHTRAG_RUNTIME_CONFIG__ -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
{
"name": "lightrag-webui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"dev:bun": "bunx --bun vite",
"build:bun": "bunx --bun vite build",
"preview:bun": "bunx --bun vite preview"
},
"dependencies": {
"@faker-js/faker": "^10.5.0",
"@radix-ui/react-alert-dialog": "^1.1.17",
"@radix-ui/react-checkbox": "^1.3.5",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-progress": "^1.1.10",
"@radix-ui/react-scroll-area": "^1.2.12",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.10",
"@radix-ui/react-use-controllable-state": "^1.2.3",
"@react-sigma/core": "^5.0.6",
"@react-sigma/graph-search": "^5.0.6",
"@react-sigma/layout-circlepack": "^5.0.6",
"@react-sigma/layout-circular": "^5.0.6",
"@react-sigma/layout-force": "^5.0.6",
"@react-sigma/layout-forceatlas2": "^5.0.6",
"@react-sigma/layout-noverlap": "^5.0.6",
"@react-sigma/layout-random": "^5.0.6",
"@react-sigma/minimap": "^5.0.6",
"@sigma/edge-curve": "^3.1.0",
"@sigma/node-border": "^3.0.0",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.18.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"graphology": "^0.26.0",
"graphology-generators": "^0.11.2",
"graphology-layout": "^0.6.1",
"graphology-layout-force": "^0.2.4",
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
"i18next": "^26.3.4",
"katex": "^0.17.0",
"mermaid": "^11.16.0",
"lucide-react": "^1.21.0",
"minisearch": "^7.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-dropzone": "^15.0.0",
"react-error-boundary": "^6.1.2",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"react-number-format": "^5.4.5",
"react-router-dom": "^7.18.0",
"react-select": "^5.10.2",
"react-syntax-highlighter": "^16.1.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-react": "^8.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"seedrandom": "^3.0.5",
"sigma": "^3.0.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwind-scrollbar": "^4.0.2",
"typography": "^0.16.24",
"unist-util-visit": "^5.1.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/bun": "^1.3.14",
"@tailwindcss/vite": "^4.3.2",
"@types/katex": "^0.16.8",
"@types/node": "^25.9.4",
"@tailwindcss/typography": "^0.5.20",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react-i18next": "^8.1.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/seedrandom": "^3.0.8",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^10.6.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"graphology-types": "^0.24.8",
"prettier": "^3.9.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"typescript-eslint": "^8.62.0",
"tailwindcss": "^4.3.2",
"tailwindcss-animate": "^1.0.7",
"typescript": "~6.0.3",
"vite": "^8.1.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

+232
View File
@@ -0,0 +1,232 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import ThemeProvider from '@/components/ThemeProvider'
import TabVisibilityProvider from '@/contexts/TabVisibilityProvider'
import ApiKeyAlert from '@/components/ApiKeyAlert'
import StatusIndicator from '@/components/status/StatusIndicator'
import { SiteInfo, webuiPrefix } from '@/lib/constants'
import { useBackendState, useAuthStore } from '@/stores/state'
import { useSettingsStore } from '@/stores/settings'
import { getAuthStatus } from '@/api/lightrag'
import SiteHeader from '@/features/SiteHeader'
import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'
import { ZapIcon } from 'lucide-react'
import GraphViewer from '@/features/GraphViewer'
import DocumentManager from '@/features/DocumentManager'
import RetrievalView from '@/features/RetrievalView'
import ApiSite from '@/features/ApiSite'
import { Tabs, TabsContent } from '@/components/ui/Tabs'
function App() {
const message = useBackendState.use.message()
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
const currentTab = useSettingsStore.use.currentTab()
const [apiKeyAlertOpen, setApiKeyAlertOpen] = useState(false)
const [initializing, setInitializing] = useState(true) // Add initializing state
const versionCheckRef = useRef(false); // Prevent duplicate calls in Vite dev mode
const healthCheckInitializedRef = useRef(false); // Prevent duplicate health checks in Vite dev mode
const handleApiKeyAlertOpenChange = useCallback((open: boolean) => {
setApiKeyAlertOpen(open)
if (!open) {
useBackendState.getState().clear()
}
}, [])
// Track component mount status with useRef
const isMountedRef = useRef(true);
// Set up mount/unmount status tracking
useEffect(() => {
isMountedRef.current = true;
// Handle page reload/unload
const handleBeforeUnload = () => {
isMountedRef.current = false;
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
isMountedRef.current = false;
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []);
// Health check - can be disabled
useEffect(() => {
// Health check function
const performHealthCheck = async () => {
try {
// Only perform health check if component is still mounted
if (isMountedRef.current) {
await useBackendState.getState().check();
}
} catch (error) {
console.error('Health check error:', error);
}
};
// Set health check function in the store
useBackendState.getState().setHealthCheckFunction(performHealthCheck);
if (!enableHealthCheck || apiKeyAlertOpen) {
useBackendState.getState().clearHealthCheckTimer();
return;
}
// On first mount or when enableHealthCheck becomes true and apiKeyAlertOpen is false,
// perform an immediate health check and start the timer
if (!healthCheckInitializedRef.current) {
healthCheckInitializedRef.current = true;
}
// Start/reset the health check timer using the store
useBackendState.getState().resetHealthCheckTimer();
// Component unmount cleanup
return () => {
useBackendState.getState().clearHealthCheckTimer();
};
}, [enableHealthCheck, apiKeyAlertOpen]);
// Version check - independent and executed only once
useEffect(() => {
const checkVersion = async () => {
// Prevent duplicate calls in Vite dev mode
if (versionCheckRef.current) return;
versionCheckRef.current = true;
// Check if version info was already obtained in login page
const versionCheckedFromLogin = sessionStorage.getItem('VERSION_CHECKED_FROM_LOGIN') === 'true';
if (versionCheckedFromLogin) {
setInitializing(false); // Skip initialization if already checked
return;
}
try {
setInitializing(true); // Start initialization
// Get version info
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
const status = await getAuthStatus();
// If auth is not configured and a new token is returned, use the new token
if (!status.auth_configured && status.access_token) {
useAuthStore.getState().login(
status.access_token, // Use the new token
true, // Guest mode
status.core_version,
status.api_version,
status.webui_title || null,
status.webui_description || null
);
} else if (token && (status.core_version || status.api_version || status.webui_title || status.webui_description)) {
// Otherwise use the old token (if it exists)
const isGuestMode = status.auth_mode === 'disabled' || useAuthStore.getState().isGuestMode;
useAuthStore.getState().login(
token,
isGuestMode,
status.core_version,
status.api_version,
status.webui_title || null,
status.webui_description || null
);
}
// Set flag to indicate version info has been checked
sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');
} catch (error) {
console.error('Failed to get version info:', error);
} finally {
// Ensure initializing is set to false even if there's an error
setInitializing(false);
}
};
// Execute version check
checkVersion();
}, []); // Empty dependency array ensures it only runs once on mount
const handleTabChange = useCallback(
(tab: string) => useSettingsStore.getState().setCurrentTab(tab as any),
[]
)
// React to backend message changes during render rather than via useEffect
// (avoids cascading renders flagged by react-hooks/set-state-in-effect)
const [previousMessage, setPreviousMessage] = useState(message)
if (message !== previousMessage) {
setPreviousMessage(message)
if (message && (message.includes(InvalidApiKeyError) || message.includes(RequireApiKeError))) {
setApiKeyAlertOpen(true)
}
}
return (
<ThemeProvider>
<TabVisibilityProvider>
{initializing ? (
// Loading state while initializing with simplified header
<div className="flex h-screen w-screen flex-col">
{/* Simplified header during initialization - matches SiteHeader structure */}
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
<div className="min-w-[200px] w-auto flex items-center">
<a href={webuiPrefix} className="flex items-center gap-2">
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
</a>
</div>
{/* Empty middle section to maintain layout */}
<div className="flex h-10 flex-1 items-center justify-center">
</div>
{/* Empty right section to maintain layout */}
<nav className="w-[200px] flex items-center justify-end">
</nav>
</header>
{/* Loading indicator in content area */}
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto"></div>
<p>Initializing...</p>
</div>
</div>
</div>
) : (
// Main content after initialization
<main className="flex h-screen w-screen overflow-hidden">
<Tabs
defaultValue={currentTab}
className="!m-0 flex grow flex-col !p-0 overflow-hidden"
onValueChange={handleTabChange}
>
<SiteHeader />
<div className="relative grow">
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0 overflow-auto">
<DocumentManager />
</TabsContent>
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
<GraphViewer />
</TabsContent>
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
<RetrievalView />
</TabsContent>
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
<ApiSite />
</TabsContent>
</div>
</Tabs>
{enableHealthCheck && <StatusIndicator />}
<ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} />
</main>
)}
</TabVisibilityProvider>
</ThemeProvider>
)
}
export default App
+95
View File
@@ -0,0 +1,95 @@
import '@/lib/extensions'; // Import all global extensions
import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { useAuthStore } from '@/stores/state'
import { navigationService } from '@/services/navigation'
import { Toaster } from 'sonner'
import App from './App'
import LoginPage from '@/features/LoginPage'
import ThemeProvider from '@/components/ThemeProvider'
const AppContent = () => {
const [initializing, setInitializing] = useState(true)
const { isAuthenticated } = useAuthStore()
const navigate = useNavigate()
// Set navigate function for navigation service
useEffect(() => {
navigationService.setNavigate(navigate)
}, [navigate])
// Token validity check
useEffect(() => {
const checkAuth = async () => {
try {
const token = localStorage.getItem('LIGHTRAG-API-TOKEN')
if (token && isAuthenticated) {
setInitializing(false);
return;
}
if (!token) {
useAuthStore.getState().logout()
}
} catch (error) {
console.error('Auth initialization error:', error)
if (!isAuthenticated) {
useAuthStore.getState().logout()
}
} finally {
setInitializing(false)
}
}
checkAuth()
return () => {
}
}, [isAuthenticated])
// Redirect effect for protected routes
useEffect(() => {
if (!initializing && !isAuthenticated) {
const currentPath = window.location.hash.slice(1);
if (currentPath !== '/login') {
console.log('Not authenticated, redirecting to login');
navigate('/login');
}
}
}, [initializing, isAuthenticated, navigate]);
// Show nothing while initializing
if (initializing) {
return null
}
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={isAuthenticated ? <App /> : null}
/>
</Routes>
)
}
const AppRouter = () => {
return (
<ThemeProvider>
<Router>
<AppContent />
<Toaster
position="bottom-center"
theme="system"
closeButton
richColors
/>
</Router>
</ThemeProvider>
)
}
export default AppRouter
@@ -0,0 +1,515 @@
import { afterEach, beforeAll, describe, expect, mock, test } from 'bun:test'
// ---------------------------------------------------------------------------
// Mock dependencies BEFORE importing the module under test
// ---------------------------------------------------------------------------
const storageData = new Map<string, string>()
const storageMock = {
getItem: (key: string) => storageData.get(key) ?? null,
setItem: (key: string, value: string) => { storageData.set(key, value) },
removeItem: (key: string) => { storageData.delete(key) },
clear: () => { storageData.clear() },
}
Object.defineProperty(globalThis, 'localStorage', {
value: storageMock,
configurable: true,
})
Object.defineProperty(globalThis, 'sessionStorage', {
value: storageMock,
configurable: true,
})
// Mock zustand stores — both return a vanilla store-like object with getState()
let storeApiKey: string | null = null
let storeIsGuestMode = false
const fakeSettingsStore = { getState: () => ({ apiKey: storeApiKey }) }
const fakeAuthStore = {
getState: () => ({
isGuestMode: storeIsGuestMode,
login: () => {},
setTokenRenewal: () => {},
}),
}
mock.module('@/stores/settings', () => ({ useSettingsStore: fakeSettingsStore }))
mock.module('@/stores/state', () => ({ useAuthStore: fakeAuthStore }))
mock.module('@/services/navigation', () => ({
navigationService: { navigateToLogin: () => {} },
}))
mock.module('@/lib/utils', () => ({
errorMessage: (error: any) =>
error instanceof Error ? error.message : `${error}`,
}))
mock.module('@/lib/constants', () => ({
backendBaseUrl: 'http://localhost:9621',
popularLabelsDefaultLimit: 300,
searchLabelsDefaultLimit: 50,
}))
// Mock axios — the module calls axios.create() at top level and
// axios.get() in silentRefreshGuestToken
mock.module('axios', () => {
const instance = {
get: () =>
Promise.resolve({
data: {
access_token: 'mock-guest-token',
auth_configured: false,
core_version: '1.0',
api_version: '1.0',
},
headers: {},
}),
post: () => Promise.resolve({ data: {}, headers: {} }),
interceptors: {
request: { use: () => {} },
response: { use: () => {} },
},
}
// The default export from axios is the main axios function, which also has
// .create, .get, .post, etc. as static methods.
const axiosFn: any = () => Promise.resolve({ data: {}, headers: {} })
axiosFn.create = () => instance
axiosFn.get = instance.get
axiosFn.post = instance.post
axiosFn.interceptors = instance.interceptors
return { default: axiosFn, __esModule: true }
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a minimal QueryRequest payload. */
const makeQueryRequest = (overrides = {}) => ({
query: 'test query',
mode: 'mix' as const,
...overrides,
})
/**
* Create a ReadableStream from an array of Uint8Array chunks. Bun's Response
* constructor accepts a ReadableStream directly.
*/
function makeNdjsonResponse(lines: string[], status = 200): Response {
const encoder = new TextEncoder()
const body = lines.map((l) => encoder.encode(l + '\n'))
// Build a simple async iterable readable stream
const stream = new ReadableStream({
start(controller) {
for (const chunk of body) {
controller.enqueue(chunk)
}
controller.close()
},
})
return new Response(stream, { status })
}
/** Build a non-streaming error response (text body). */
function makeTextResponse(body: string, status: number): Response {
return new Response(body, { status })
}
/**
* Install a mocked implementation onto globalThis.fetch. Bun's `mock()` returns
* a `Mock` that lacks the DOM `fetch.preconnect` static, so the assignment is
* cast through `unknown` to satisfy the `typeof fetch` type.
*/
function installFetchMock(
impl: (url: string, init?: RequestInit) => Response | Promise<Response>
): void {
globalThis.fetch = mock(impl) as unknown as typeof fetch
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
let apiModule: typeof import('./lightrag')
beforeAll(async () => {
apiModule = await import('./lightrag')
})
afterEach(() => {
storageData.clear()
storeApiKey = null
storeIsGuestMode = false
})
describe('queryTextStream — normal path', () => {
test('parses NDJSON response chunks and calls onChunk', async () => {
const chunks: string[] = []
const errors: string[] = []
installFetchMock(() =>
makeNdjsonResponse([
'{"response": "Hello"}',
'{"response": " World"}',
'{"response": "!"}',
])
)
await apiModule.queryTextStream(
makeQueryRequest(),
(c) => chunks.push(c),
(e) => errors.push(e)
)
expect(chunks).toEqual(['Hello', ' World', '!'])
expect(errors).toEqual([])
})
test('forwards error lines to onError', async () => {
const chunks: string[] = []
const errors: string[] = []
installFetchMock(() =>
makeNdjsonResponse([
'{"response": "ok"}',
'{"error": "Something went wrong"}',
'{"response": "more"}',
])
)
await apiModule.queryTextStream(
makeQueryRequest(),
(c) => chunks.push(c),
(e) => errors.push(e)
)
expect(chunks).toEqual(['ok', 'more'])
expect(errors).toEqual(['Something went wrong'])
})
test('skips malformed JSON lines', async () => {
const chunks: string[] = []
installFetchMock(() =>
makeNdjsonResponse([
'not valid json',
'{"response": "valid"}',
'{broken',
])
)
await apiModule.queryTextStream(
makeQueryRequest(),
(c) => chunks.push(c),
() => {}
)
expect(chunks).toEqual(['valid'])
})
test('handles multi-byte characters split across chunks', async () => {
const chunks: string[] = []
const encoder = new TextEncoder()
// "Hello 😀🌍" — split the emoji bytes across chunks
const fullJson = '{"response": "Hello 😀🌍"}\n'
const fullBytes = encoder.encode(fullJson)
// Split at an arbitrary point inside the emoji sequence
const splitAt = 28
const part1 = fullBytes.slice(0, splitAt)
const part2 = fullBytes.slice(splitAt)
const stream = new ReadableStream({
start(controller) {
controller.enqueue(part1)
controller.enqueue(part2)
controller.close()
},
})
installFetchMock(() => new Response(stream, { status: 200 }))
await apiModule.queryTextStream(
makeQueryRequest(),
(c) => chunks.push(c),
() => {}
)
expect(chunks).toEqual(['Hello 😀🌍'])
})
test('calls onError when final buffer is unparseable (truncated stream)', async () => {
// Simulate a stream cut off mid-line with no trailing newline.
// The residual buffer after the stream ends will be an incomplete JSON
// object that can't be parsed.
const encoder = new TextEncoder()
const jsonLine = '{"response": "ok"}\n'
const truncatedLine = '{"response": "incom'
const body = new Uint8Array([
...encoder.encode(jsonLine),
...encoder.encode(truncatedLine),
])
const stream = new ReadableStream({
start(controller) {
controller.enqueue(body)
controller.close()
},
})
installFetchMock(() => new Response(stream, { status: 200 }))
const chunks: string[] = []
const errors: string[] = []
await apiModule.queryTextStream(
makeQueryRequest(),
(c) => chunks.push(c),
(e) => errors.push(e)
)
expect(chunks).toEqual(['ok'])
expect(errors).toEqual([
'Response stream ended with incomplete data — the response may be truncated.',
])
})
})
describe('queryTextStream — abort / stop button', () => {
test('exits silently on user abort (signal.aborted)', async () => {
const controller = new AbortController()
controller.abort()
installFetchMock(() => {
// fetch should reject since the signal is already aborted
return Promise.reject(new DOMException('Aborted', 'AbortError'))
})
let errorCalled = false
await apiModule.queryTextStream(
makeQueryRequest(),
() => {},
() => { errorCalled = true },
controller.signal
)
expect(errorCalled).toBe(false)
})
test('exits silently when AbortError is thrown without a signal', async () => {
installFetchMock(() =>
Promise.reject(new DOMException('Aborted', 'AbortError'))
)
let errorCalled = false
await apiModule.queryTextStream(
makeQueryRequest(),
() => {},
() => { errorCalled = true }
)
expect(errorCalled).toBe(false)
})
})
describe('queryTextStream — HTTP errors', () => {
const errorCases = [
{
status: 403,
expectedMessage:
'You do not have permission to access this resource (403 Forbidden)',
},
{
status: 404,
expectedMessage: 'The requested resource does not exist (404 Not Found)',
},
{
status: 429,
expectedMessage:
'Too many requests, please try again later (429 Too Many Requests)',
},
{
status: 500,
expectedMessage:
'Server error, please try again later (500)',
},
{
status: 502,
expectedMessage:
'Server error, please try again later (502)',
},
{
status: 503,
expectedMessage:
'Server error, please try again later (503)',
},
]
for (const { status, expectedMessage } of errorCases) {
test(`shows friendly message for HTTP ${status}`, async () => {
installFetchMock(() =>
makeTextResponse('{"error":"details"}', status)
)
let capturedError = ''
await apiModule.queryTextStream(
makeQueryRequest(),
() => {},
(e) => { capturedError = e }
)
expect(capturedError).toBe(expectedMessage)
})
}
})
describe('queryTextStream — network errors', () => {
test('shows network error for Failed to fetch', async () => {
installFetchMock(() =>
Promise.reject(new TypeError('Failed to fetch'))
)
let capturedError = ''
await apiModule.queryTextStream(
makeQueryRequest(),
() => {},
(e) => { capturedError = e }
)
expect(capturedError).toBe(
'Network connection error, please check your internet connection'
)
})
test('shows network error for NetworkError', async () => {
installFetchMock(() =>
Promise.reject(new Error('NetworkError: connection refused'))
)
let capturedError = ''
await apiModule.queryTextStream(
makeQueryRequest(),
() => {},
(e) => { capturedError = e }
)
expect(capturedError).toBe(
'Network connection error, please check your internet connection'
)
})
})
describe('queryTextStream — auth headers', () => {
test('includes Bearer token when stored', async () => {
storageData.set('LIGHTRAG-API-TOKEN', 'test-jwt-token')
let capturedHeaders: HeadersInit | undefined
installFetchMock((_url: string, init?: RequestInit) => {
capturedHeaders = init?.headers
return makeNdjsonResponse(['{"response": "ok"}'])
})
await apiModule.queryTextStream(
makeQueryRequest(),
() => {},
() => {}
)
// The module builds headers as a plain object literal, so the captured
// RequestInit.headers is a Record we can assert against directly.
const sentHeaders = capturedHeaders as Record<string, string>
expect(sentHeaders).toBeDefined()
expect(sentHeaders['Authorization']).toBe('Bearer test-jwt-token')
expect(sentHeaders['Accept']).toBe('application/x-ndjson')
expect(sentHeaders['Content-Type']).toBe('application/json')
})
test('omits Bearer token when none is stored', async () => {
let capturedHeaders: HeadersInit | undefined
installFetchMock((_url: string, init?: RequestInit) => {
capturedHeaders = init?.headers
return makeNdjsonResponse(['{"response": "ok"}'])
})
await apiModule.queryTextStream(
makeQueryRequest(),
() => {},
() => {}
)
const sentHeaders = capturedHeaders as Record<string, string>
expect(sentHeaders['Authorization']).toBeUndefined()
})
test('calls /query/stream endpoint', async () => {
let capturedUrl = ''
installFetchMock((url: string) => {
capturedUrl = url
return makeNdjsonResponse(['{"response": "ok"}'])
})
await apiModule.queryTextStream(
makeQueryRequest(),
() => {},
() => {}
)
expect(capturedUrl).toBe('http://localhost:9621/query/stream')
})
})
describe('queryTextStream — guest-token 401 retry', () => {
test('retries with refreshed guest token on 401', async () => {
storageData.set('LIGHTRAG-API-TOKEN', 'expired-guest-token')
storeIsGuestMode = true
let callCount = 0
installFetchMock(() => {
callCount++
if (callCount === 1) {
return makeTextResponse('{"error":"unauthorized"}', 401)
}
return makeNdjsonResponse(['{"response": "retry ok"}'])
})
const chunks: string[] = []
await apiModule.queryTextStream(
makeQueryRequest(),
(c) => chunks.push(c),
() => {}
)
expect(callCount).toBe(2)
expect(chunks).toEqual(['retry ok'])
})
test('classifies a non-auth HTTP error on the retried stream (e.g. 429)', async () => {
storageData.set('LIGHTRAG-API-TOKEN', 'expired-guest-token')
storeIsGuestMode = true
let callCount = 0
installFetchMock(() => {
callCount++
if (callCount === 1) {
return makeTextResponse('{"error":"unauthorized"}', 401)
}
// Refresh succeeded, but the retried request is rate-limited.
return makeTextResponse('{"error":"rate limited"}', 429)
})
let capturedError = ''
await apiModule.queryTextStream(
makeQueryRequest(),
() => {},
(e) => {
capturedError = e
}
)
// The retry's 429 must be classified like any other HTTP error, NOT
// reported as an auth-refresh failure.
expect(callCount).toBe(2)
expect(capturedError).toBe(
'Too many requests, please try again later (429 Too Many Requests)'
)
})
})
+265
View File
@@ -0,0 +1,265 @@
import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
type DocumentsRequest = {
status_filter?: 'pending' | 'processing' | 'preprocessed' | 'processed' | 'failed' | null
page: number
page_size: number
sort_field: 'created_at' | 'updated_at' | 'id' | 'file_path'
sort_direction: 'asc' | 'desc'
}
type LightragApiModule = typeof import('./lightrag')
const storageMock = () => {
const data = new Map<string, string>()
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, value)
},
removeItem: (key: string) => {
data.delete(key)
},
clear: () => {
data.clear()
}
}
}
let apiModule: LightragApiModule
beforeAll(async () => {
Object.defineProperty(globalThis, 'localStorage', {
value: storageMock(),
configurable: true
})
Object.defineProperty(globalThis, 'sessionStorage', {
value: storageMock(),
configurable: true
})
apiModule = await import('./lightrag')
})
afterEach(() => {
apiModule.__resetPaginatedDocumentRequestsForTests()
})
describe('getDocumentsPaginated', () => {
test('issues a fresh request after aborting a timed-out in-flight request', async () => {
const request: DocumentsRequest = {
status_filter: null,
page: 1,
page_size: 20,
sort_field: 'updated_at',
sort_direction: 'desc'
}
let callCount = 0
const resolvers: Array<(value: any) => void> = []
apiModule.__setPaginatedDocumentsPostForTests((_request, controller) => {
callCount += 1
return new Promise((resolve, reject) => {
resolvers.push(resolve)
controller.signal.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true }
)
})
})
const firstRequest = apiModule.getDocumentsPaginated(request)
const secondRequest = apiModule.getDocumentsPaginated(request)
expect(callCount).toBe(1)
apiModule.abortDocumentsPaginated(request)
const [firstResult, secondResult] = await Promise.allSettled([
firstRequest,
secondRequest
])
expect(firstResult.status).toBe('rejected')
expect(secondResult.status).toBe('rejected')
const thirdRequest = apiModule.getDocumentsPaginated(request)
expect(callCount).toBe(2)
resolvers[1]({
documents: [],
pagination: {
page: 1,
page_size: 20,
total_count: 0,
total_pages: 0,
has_next: false,
has_prev: false
},
status_counts: { all: 0 }
})
await expect(thirdRequest).resolves.toEqual({
documents: [],
pagination: {
page: 1,
page_size: 20,
total_count: 0,
total_pages: 0,
has_next: false,
has_prev: false
},
status_counts: { all: 0 }
})
})
test('times out hanging requests and allows a fresh retry', async () => {
const request: DocumentsRequest = {
status_filter: null,
page: 1,
page_size: 20,
sort_field: 'updated_at',
sort_direction: 'desc'
}
let callCount = 0
const resolvers: Array<(value: any) => void> = []
apiModule.__setPaginatedDocumentsPostForTests((_request, controller) => {
callCount += 1
return new Promise((resolve, reject) => {
resolvers.push(resolve)
controller.signal.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true }
)
})
})
await expect(
apiModule.getDocumentsPaginatedWithTimeout(request, 1)
).rejects.toThrow('Document fetch timeout')
expect(callCount).toBe(1)
const retryRequest = apiModule.getDocumentsPaginated(request)
expect(callCount).toBe(2)
resolvers[1]({
documents: [],
pagination: {
page: 1,
page_size: 20,
total_count: 0,
total_pages: 0,
has_next: false,
has_prev: false
},
status_counts: { all: 0 }
})
await expect(retryRequest).resolves.toEqual({
documents: [],
pagination: {
page: 1,
page_size: 20,
total_count: 0,
total_pages: 0,
has_next: false,
has_prev: false
},
status_counts: { all: 0 }
})
})
test('does not abort a shared request when only one timeout subscriber expires', async () => {
const request: DocumentsRequest = {
status_filter: null,
page: 1,
page_size: 20,
sort_field: 'updated_at',
sort_direction: 'desc'
}
let callCount = 0
let resolveSharedRequest: ((value: any) => void) | undefined
let abortCount = 0
apiModule.__setPaginatedDocumentsPostForTests((_request, controller) => {
callCount += 1
return new Promise((resolve, reject) => {
resolveSharedRequest = resolve
controller.signal.addEventListener(
'abort',
() => {
abortCount += 1
reject(new DOMException('Aborted', 'AbortError'))
},
{ once: true }
)
})
})
const shortTimeoutRequest = apiModule.getDocumentsPaginatedWithTimeout(request, 1)
const longTimeoutRequest = apiModule.getDocumentsPaginatedWithTimeout(request, 100)
await expect(shortTimeoutRequest).rejects.toThrow('Document fetch timeout')
expect(callCount).toBe(1)
expect(abortCount).toBe(0)
resolveSharedRequest?.({
documents: [],
pagination: {
page: 1,
page_size: 20,
total_count: 0,
total_pages: 0,
has_next: false,
has_prev: false
},
status_counts: { all: 0 }
})
await expect(longTimeoutRequest).resolves.toEqual({
documents: [],
pagination: {
page: 1,
page_size: 20,
total_count: 0,
total_pages: 0,
has_next: false,
has_prev: false
},
status_counts: { all: 0 }
})
})
})
describe('isUserAbortError', () => {
// Regression: the Stop button must suppress query cancellation everywhere it
// surfaces — both the main stream catch and the guest-token retry catch (which
// otherwise redirects an aborting guest to the login page). Both sites share
// this predicate, so locking down its behavior guards both fixes.
test('treats an aborted signal as a user abort regardless of the error', () => {
const controller = new AbortController()
controller.abort()
expect(apiModule.isUserAbortError(controller.signal, new Error('boom'))).toBe(true)
})
test('treats an AbortError as a user abort even when the signal is absent', () => {
const abortError = new DOMException('Aborted', 'AbortError')
expect(apiModule.isUserAbortError(undefined, abortError)).toBe(true)
})
test('does not treat a real failure on a live signal as a user abort', () => {
const controller = new AbortController()
expect(apiModule.isUserAbortError(controller.signal, new Error('network down'))).toBe(false)
expect(apiModule.isUserAbortError(undefined, new Error('network down'))).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
import { useState, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/AlertDialog'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import { useSettingsStore } from '@/stores/settings'
import { useBackendState } from '@/stores/state'
import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'
interface ApiKeyAlertProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const ApiKeyAlert = ({ open: opened, onOpenChange: setOpened }: ApiKeyAlertProps) => {
const { t } = useTranslation()
const apiKey = useSettingsStore.use.apiKey()
const [tempApiKey, setTempApiKey] = useState<string>(apiKey || '')
const message = useBackendState.use.message()
// Sync draft input with latest store value whenever the dialog opens or apiKey changes
useEffect(() => {
const timer = setTimeout(() => setTempApiKey(apiKey || ''), 0)
return () => clearTimeout(timer)
}, [apiKey, opened])
useEffect(() => {
if (message) {
if (message.includes(InvalidApiKeyError) || message.includes(RequireApiKeError)) {
setOpened(true)
}
}
}, [message, setOpened])
const setApiKey = useCallback(() => {
useSettingsStore.setState({ apiKey: tempApiKey || null })
setOpened(false)
}, [tempApiKey, setOpened])
const handleTempApiKeyChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setTempApiKey(e.target.value)
},
[setTempApiKey]
)
return (
<AlertDialog open={opened} onOpenChange={setOpened}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('apiKeyAlert.title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('apiKeyAlert.description')}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-4">
<form className="flex gap-2" onSubmit={(e) => e.preventDefault()}>
<Input
type="password"
value={tempApiKey}
onChange={handleTempApiKeyChange}
placeholder={t('apiKeyAlert.placeholder')}
className="max-h-full w-full min-w-0"
autoComplete="off"
/>
<Button onClick={setApiKey} variant="outline" size="sm">
{t('apiKeyAlert.save')}
</Button>
</form>
{message && (
<div className="text-sm text-red-500">
{message}
</div>
)}
</div>
</AlertDialogContent>
</AlertDialog>
)
}
export default ApiKeyAlert
@@ -0,0 +1,80 @@
import { useState, useCallback } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import Button from '@/components/ui/Button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'
import { useSettingsStore } from '@/stores/settings'
import { PaletteIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
interface AppSettingsProps {
className?: string
}
export default function AppSettings({ className }: AppSettingsProps) {
const [opened, setOpened] = useState<boolean>(false)
const { t } = useTranslation()
const language = useSettingsStore.use.language()
const setLanguage = useSettingsStore.use.setLanguage()
const theme = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
const handleLanguageChange = useCallback((value: string) => {
setLanguage(value as 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW' | 'ru' | 'ja' | 'de' | 'uk' | 'ko' | 'vi')
}, [setLanguage])
const handleThemeChange = useCallback((value: string) => {
setTheme(value as 'light' | 'dark' | 'system')
}, [setTheme])
return (
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-9 w-9', className)}>
<PaletteIcon className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.language')}</label>
<Select value={language} onValueChange={handleLanguageChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh"></SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="ar">العربية</SelectItem>
<SelectItem value="zh_TW"></SelectItem>
<SelectItem value="ru">Русский</SelectItem>
<SelectItem value="ja"></SelectItem>
<SelectItem value="de">Deutsch</SelectItem>
<SelectItem value="uk">Українська</SelectItem>
<SelectItem value="ko"></SelectItem>
<SelectItem value="vi">Tiếng Việt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.theme')}</label>
<Select value={theme} onValueChange={handleThemeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">{t('settings.light')}</SelectItem>
<SelectItem value="dark">{t('settings.dark')}</SelectItem>
<SelectItem value="system">{t('settings.system')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,49 @@
import Button from '@/components/ui/Button'
import { useCallback } from 'react'
import { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
/**
* Component that toggles the language between English and Chinese.
*/
export default function LanguageToggle() {
const { i18n } = useTranslation()
const currentLanguage = i18n.language
const setLanguage = useSettingsStore.use.setLanguage()
const setEnglish = useCallback(() => {
i18n.changeLanguage('en')
setLanguage('en')
}, [i18n, setLanguage])
const setChinese = useCallback(() => {
i18n.changeLanguage('zh')
setLanguage('zh')
}, [i18n, setLanguage])
if (currentLanguage === 'zh') {
return (
<Button
onClick={setEnglish}
variant={controlButtonVariant}
tooltip="Switch to English"
size="icon"
side="bottom"
>
</Button>
)
}
return (
<Button
onClick={setChinese}
variant={controlButtonVariant}
tooltip="切换到中文"
size="icon"
side="bottom"
>
EN
</Button>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import App from '@/App'
import '@/i18n'
export const Root = () => (
<StrictMode>
<App />
</StrictMode>
)
@@ -0,0 +1,59 @@
import { createContext, useEffect } from 'react'
import { Theme, useSettingsStore } from '@/stores/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 = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
root.classList.remove('light', 'dark')
root.classList.add(e.matches ? 'dark' : 'light')
}
root.classList.add(mediaQuery.matches ? 'dark' : 'light')
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
} else {
root.classList.add(theme)
}
}, [theme])
const value = {
theme,
setTheme
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export { ThemeProviderContext }
@@ -0,0 +1,41 @@
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'
import { useTranslation } from 'react-i18next'
/**
* 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])
const { t } = useTranslation()
if (theme === 'dark') {
return (
<Button
onClick={setLight}
variant={controlButtonVariant}
tooltip={t('header.themeToggle.switchToLight')}
size="icon"
side="bottom"
>
<MoonIcon />
</Button>
)
}
return (
<Button
onClick={setDark}
variant={controlButtonVariant}
tooltip={t('header.themeToggle.switchToDark')}
size="icon"
side="bottom"
>
<SunIcon />
</Button>
)
}
@@ -0,0 +1,211 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import Button from '@/components/ui/Button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from '@/components/ui/Dialog'
import Input from '@/components/ui/Input'
import Checkbox from '@/components/ui/Checkbox'
import { toast } from 'sonner'
import { errorMessage } from '@/lib/utils'
import { clearDocuments, clearCache } from '@/api/lightrag'
import { EraserIcon, AlertTriangleIcon, Loader2Icon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
// Simple Label component
const Label = ({
htmlFor,
className,
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement>) => (
<label
htmlFor={htmlFor}
className={className}
{...props}
>
{children}
</label>
)
interface ClearDocumentsDialogProps {
onDocumentsCleared?: () => Promise<void>
}
export default function ClearDocumentsDialog({ onDocumentsCleared }: ClearDocumentsDialogProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [clearCacheOption, setClearCacheOption] = useState(false)
const [isClearing, setIsClearing] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isConfirmEnabled = confirmText.toLowerCase() === 'yes'
// Timeout constant (30 seconds)
const CLEAR_TIMEOUT = 30000
// Reset state when dialog closes - handled in onOpenChange to avoid setState in effect
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
if (!newOpen) {
setConfirmText('')
setClearCacheOption(false)
setIsClearing(false)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
// Cleanup when component unmounts
useEffect(() => {
return () => {
// Clear timeout timer when component unmounts
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const handleClear = useCallback(async () => {
if (!isConfirmEnabled || isClearing) return
setIsClearing(true)
// Set timeout protection
timeoutRef.current = setTimeout(() => {
if (isClearing) {
toast.error(t('documentPanel.clearDocuments.timeout'))
setIsClearing(false)
setConfirmText('') // Reset confirmation text after timeout
}
}, CLEAR_TIMEOUT)
try {
const result = await clearDocuments()
if (result.status !== 'success') {
toast.error(t('documentPanel.clearDocuments.failed', { message: result.message }))
setConfirmText('')
return
}
toast.success(t('documentPanel.clearDocuments.success'))
if (clearCacheOption) {
try {
await clearCache()
toast.success(t('documentPanel.clearDocuments.cacheCleared'))
} catch (cacheErr) {
toast.error(t('documentPanel.clearDocuments.cacheClearFailed', { error: errorMessage(cacheErr) }))
}
}
// Refresh document list if provided
if (onDocumentsCleared) {
onDocumentsCleared().catch(console.error)
}
// Close dialog after all operations succeed
handleOpenChange(false)
} catch (err) {
toast.error(t('documentPanel.clearDocuments.error', { error: errorMessage(err) }))
setConfirmText('')
} finally {
// Clear timeout timer
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setIsClearing(false)
}
}, [isConfirmEnabled, isClearing, clearCacheOption, handleOpenChange, t, onDocumentsCleared, CLEAR_TIMEOUT])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" side="bottom" tooltip={t('documentPanel.clearDocuments.tooltip')} size="sm">
<EraserIcon/> {t('documentPanel.clearDocuments.button')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-500 dark:text-red-400 font-bold">
<AlertTriangleIcon className="h-5 w-5" />
{t('documentPanel.clearDocuments.title')}
</DialogTitle>
<DialogDescription className="pt-2">
{t('documentPanel.clearDocuments.description')}
</DialogDescription>
</DialogHeader>
<div className="text-red-500 dark:text-red-400 font-semibold mb-4">
{t('documentPanel.clearDocuments.warning')}
</div>
<div className="mb-4">
{t('documentPanel.clearDocuments.confirm')}
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="confirm-text" className="text-sm font-medium">
{t('documentPanel.clearDocuments.confirmPrompt')}
</Label>
<Input
id="confirm-text"
value={confirmText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmText(e.target.value)}
placeholder={t('documentPanel.clearDocuments.confirmPlaceholder')}
className="w-full"
disabled={isClearing}
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="clear-cache"
checked={clearCacheOption}
onCheckedChange={(checked: boolean | 'indeterminate') => setClearCacheOption(checked === true)}
disabled={isClearing}
/>
<Label htmlFor="clear-cache" className="text-sm font-medium cursor-pointer">
{t('documentPanel.clearDocuments.clearCache')}
</Label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isClearing}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleClear}
disabled={!isConfirmEnabled || isClearing}
>
{isClearing ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
{t('documentPanel.clearDocuments.clearing')}
</>
) : (
t('documentPanel.clearDocuments.confirmButton')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,192 @@
import { useState, useCallback } from 'react'
import Button from '@/components/ui/Button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from '@/components/ui/Dialog'
import Input from '@/components/ui/Input'
import { toast } from 'sonner'
import { errorMessage } from '@/lib/utils'
import { deleteDocuments } from '@/api/lightrag'
import { TrashIcon, AlertTriangleIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
// Simple Label component
const Label = ({
htmlFor,
className,
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement>) => (
<label
htmlFor={htmlFor}
className={className}
{...props}
>
{children}
</label>
)
interface DeleteDocumentsDialogProps {
selectedDocIds: string[]
onDocumentsDeleted?: () => Promise<void>
}
export default function DeleteDocumentsDialog({ selectedDocIds, onDocumentsDeleted }: DeleteDocumentsDialogProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [deleteFile, setDeleteFile] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [deleteLLMCache, setDeleteLLMCache] = useState(false)
const isConfirmEnabled = confirmText.toLowerCase() === 'yes' && !isDeleting
// Reset state when dialog closes - handled in onOpenChange to avoid setState in effect
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
if (!newOpen) {
setConfirmText('')
setDeleteFile(false)
setDeleteLLMCache(false)
setIsDeleting(false)
}
}, [])
const handleDelete = useCallback(async () => {
if (!isConfirmEnabled || selectedDocIds.length === 0) return
setIsDeleting(true)
try {
const result = await deleteDocuments(selectedDocIds, deleteFile, deleteLLMCache)
if (result.status === 'deletion_started') {
toast.success(t('documentPanel.deleteDocuments.success', { count: selectedDocIds.length }))
} else if (result.status === 'busy') {
toast.error(t('documentPanel.deleteDocuments.busy'))
setConfirmText('')
setIsDeleting(false)
return
} else if (result.status === 'not_allowed') {
toast.error(t('documentPanel.deleteDocuments.notAllowed'))
setConfirmText('')
setIsDeleting(false)
return
} else {
toast.error(t('documentPanel.deleteDocuments.failed', { message: result.message }))
setConfirmText('')
setIsDeleting(false)
return
}
// Refresh document list if provided
if (onDocumentsDeleted) {
onDocumentsDeleted().catch(console.error)
}
// Close dialog after successful operation
handleOpenChange(false)
} catch (err) {
toast.error(t('documentPanel.deleteDocuments.error', { error: errorMessage(err) }))
setConfirmText('')
} finally {
setIsDeleting(false)
}
}, [isConfirmEnabled, selectedDocIds, deleteFile, deleteLLMCache, handleOpenChange, t, onDocumentsDeleted])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="destructive"
side="bottom"
tooltip={t('documentPanel.deleteDocuments.tooltip', { count: selectedDocIds.length })}
size="sm"
>
<TrashIcon/> {t('documentPanel.deleteDocuments.button')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-500 dark:text-red-400 font-bold">
<AlertTriangleIcon className="h-5 w-5" />
{t('documentPanel.deleteDocuments.title')}
</DialogTitle>
<DialogDescription className="pt-2">
{t('documentPanel.deleteDocuments.description', { count: selectedDocIds.length })}
</DialogDescription>
</DialogHeader>
<div className="text-red-500 dark:text-red-400 font-semibold mb-4">
{t('documentPanel.deleteDocuments.warning')}
</div>
<div className="mb-4">
{t('documentPanel.deleteDocuments.confirm', { count: selectedDocIds.length })}
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="confirm-text" className="text-sm font-medium">
{t('documentPanel.deleteDocuments.confirmPrompt')}
</Label>
<Input
id="confirm-text"
value={confirmText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmText(e.target.value)}
placeholder={t('documentPanel.deleteDocuments.confirmPlaceholder')}
className="w-full"
disabled={isDeleting}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="delete-file"
checked={deleteFile}
onChange={(e) => setDeleteFile(e.target.checked)}
disabled={isDeleting}
className="h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
/>
<Label htmlFor="delete-file" className="text-sm font-medium cursor-pointer">
{t('documentPanel.deleteDocuments.deleteFileOption')}
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="delete-llm-cache"
checked={deleteLLMCache}
onChange={(e) => setDeleteLLMCache(e.target.checked)}
disabled={isDeleting}
className="h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
/>
<Label htmlFor="delete-llm-cache" className="text-sm font-medium cursor-pointer">
{t('documentPanel.deleteDocuments.deleteLLMCacheOption')}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isDeleting}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={!isConfirmEnabled}
>
{isDeleting ? t('documentPanel.deleteDocuments.deleting') : t('documentPanel.deleteDocuments.confirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,271 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AlignLeft, AlignCenter, AlignRight } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
import { getPipelineStatus, cancelPipeline, PipelineStatusResponse } from '@/api/lightrag'
import { errorMessage } from '@/lib/utils'
import { cn } from '@/lib/utils'
type DialogPosition = 'left' | 'center' | 'right'
interface PipelineStatusDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export default function PipelineStatusDialog({
open,
onOpenChange
}: PipelineStatusDialogProps) {
const { t } = useTranslation()
const [status, setStatus] = useState<PipelineStatusResponse | null>(null)
const [position, setPosition] = useState<DialogPosition>('center')
const [isUserScrolled, setIsUserScrolled] = useState(false)
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
const historyRef = useRef<HTMLDivElement>(null)
// Reset UI state whenever the controlling open prop changes.
useEffect(() => {
if (open) {
// Resetting local dialog UI when the controlling prop changes is intentional.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPosition('center')
setIsUserScrolled(false)
return
}
setShowCancelConfirm(false)
}, [open])
// Handle scroll position
useEffect(() => {
const container = historyRef.current
if (!container || isUserScrolled) return
container.scrollTop = container.scrollHeight
}, [status?.history_messages, isUserScrolled])
const handleScroll = () => {
const container = historyRef.current
if (!container) return
const isAtBottom = Math.abs(
(container.scrollHeight - container.scrollTop) - container.clientHeight
) < 1
if (isAtBottom) {
setIsUserScrolled(false)
} else {
setIsUserScrolled(true)
}
}
// Refresh status every 2 seconds
useEffect(() => {
if (!open) return
const fetchStatus = async () => {
try {
const data = await getPipelineStatus()
setStatus(data)
} catch (err) {
toast.error(t('documentPanel.pipelineStatus.errors.fetchFailed', { error: errorMessage(err) }))
}
}
fetchStatus()
const interval = setInterval(fetchStatus, 2000)
return () => clearInterval(interval)
}, [open, t])
// Handle cancel pipeline confirmation
const handleConfirmCancel = async () => {
setShowCancelConfirm(false)
try {
const result = await cancelPipeline()
if (result.status === 'cancellation_requested') {
toast.success(t('documentPanel.pipelineStatus.cancelSuccess'))
} else if (result.status === 'not_busy') {
toast.info(t('documentPanel.pipelineStatus.cancelNotBusy'))
}
} catch (err) {
toast.error(t('documentPanel.pipelineStatus.cancelFailed', { error: errorMessage(err) }))
}
}
// Determine if cancel button should be enabled
const canCancel = status?.busy === true && !status?.cancellation_requested
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'sm:max-w-[800px] transition-all duration-200 fixed',
position === 'left' && '!left-[25%] !translate-x-[-50%] !mx-4',
position === 'center' && '!left-1/2 !-translate-x-1/2',
position === 'right' && '!left-[75%] !translate-x-[-50%] !mx-4'
)}
>
<DialogDescription className="sr-only">
{status?.job_name
? `${t('documentPanel.pipelineStatus.jobName')}: ${status.job_name}, ${t('documentPanel.pipelineStatus.progress')}: ${status.cur_batch}/${status.batchs}`
: t('documentPanel.pipelineStatus.noActiveJob')
}
</DialogDescription>
<DialogHeader className="flex flex-row items-center">
<DialogTitle className="flex-1">
{t('documentPanel.pipelineStatus.title')}
</DialogTitle>
{/* Position control buttons */}
<div className="flex items-center gap-2 mr-8">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6',
position === 'left' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'
)}
onClick={() => setPosition('left')}
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6',
position === 'center' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'
)}
onClick={() => setPosition('center')}
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6',
position === 'right' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'
)}
onClick={() => setPosition('right')}
>
<AlignRight className="h-4 w-4" />
</Button>
</div>
</DialogHeader>
{/* Status Content */}
<div className="space-y-4 pt-4">
{/* Pipeline Status - with cancel button */}
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Left side: Status indicators */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.busy')}:</div>
<div className={`h-2 w-2 rounded-full ${status?.busy ? 'bg-green-500' : 'bg-gray-300'}`} />
</div>
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.requestPending')}:</div>
<div className={`h-2 w-2 rounded-full ${status?.request_pending ? 'bg-green-500' : 'bg-gray-300'}`} />
</div>
{/* Only show cancellation status when it's requested */}
{status?.cancellation_requested && (
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.cancellationRequested')}:</div>
<div className="h-2 w-2 rounded-full bg-red-500" />
</div>
)}
</div>
{/* Right side: Cancel button - only show when pipeline is busy */}
{status?.busy && (
<Button
variant="destructive"
size="sm"
disabled={!canCancel}
onClick={() => setShowCancelConfirm(true)}
title={
status?.cancellation_requested
? t('documentPanel.pipelineStatus.cancelInProgress')
: t('documentPanel.pipelineStatus.cancelTooltip')
}
>
{t('documentPanel.pipelineStatus.cancelButton')}
</Button>
)}
</div>
{/* Job Information */}
<div className="rounded-md border p-3 space-y-2">
<div>{t('documentPanel.pipelineStatus.jobName')}: {status?.job_name || '-'}</div>
<div className="flex justify-between">
<span>{t('documentPanel.pipelineStatus.startTime')}: {status?.job_start
? new Date(status.job_start).toLocaleString(undefined, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
})
: '-'}</span>
<span>{t('documentPanel.pipelineStatus.progress')}: {status ? `${status.cur_batch}/${status.batchs} ${t('documentPanel.pipelineStatus.unit')}` : '-'}</span>
</div>
</div>
{/* History Messages */}
<div className="space-y-2">
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.pipelineMessages')}:</div>
<div
ref={historyRef}
onScroll={handleScroll}
className="font-mono text-xs rounded-md bg-zinc-800 text-zinc-100 p-3 overflow-y-auto overflow-x-hidden min-h-[7.5em] max-h-[40vh]"
>
{status?.history_messages?.length ? (
status.history_messages.map((msg, idx) => (
<div key={idx} className="whitespace-pre-wrap break-all">{msg}</div>
))
) : '-'}
</div>
</div>
</div>
</DialogContent>
{/* Cancel Confirmation Dialog */}
<Dialog open={showCancelConfirm} onOpenChange={setShowCancelConfirm}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t('documentPanel.pipelineStatus.cancelConfirmTitle')}</DialogTitle>
<DialogDescription>
{t('documentPanel.pipelineStatus.cancelConfirmDescription')}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-3 mt-4">
<Button
variant="outline"
onClick={() => setShowCancelConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleConfirmCancel}
>
{t('documentPanel.pipelineStatus.cancelConfirmButton')}
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
)
}
@@ -0,0 +1,245 @@
import { useState, useCallback } from 'react'
import { FileRejection } from 'react-dropzone'
import Button from '@/components/ui/Button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/Dialog'
import FileUploader from '@/components/ui/FileUploader'
import { toast } from 'sonner'
import { errorMessage } from '@/lib/utils'
import { uploadDocument } from '@/api/lightrag'
import { UploadIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface UploadDocumentsDialogProps {
onDocumentsUploaded?: () => Promise<void>
/**
* Fired once per batch as soon as the first file is accepted by the server.
* Lets the parent start its activity probe as early as possible (rather
* than waiting for the whole sequential batch to finish).
*/
onUploadBatchAccepted?: () => void
}
export default function UploadDocumentsDialog({
onDocumentsUploaded,
onUploadBatchAccepted
}: UploadDocumentsDialogProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [progresses, setProgresses] = useState<Record<string, number>>({})
const [fileErrors, setFileErrors] = useState<Record<string, string>>({})
const handleRejectedFiles = useCallback(
(rejectedFiles: FileRejection[]) => {
// Process rejected files and add them to fileErrors
rejectedFiles.forEach(({ file, errors }) => {
// Get the first error message
let errorMsg = errors[0]?.message || t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name })
// Simplify error message for unsupported file types
if (errorMsg.includes('file-invalid-type')) {
errorMsg = t('documentPanel.uploadDocuments.fileUploader.unsupportedType')
}
// Set progress to 100% to display error message
setProgresses((pre) => ({
...pre,
[file.name]: 100
}))
// Add error message to fileErrors
setFileErrors(prev => ({
...prev,
[file.name]: errorMsg
}))
})
},
[setProgresses, setFileErrors, t]
)
const handleDocumentsUpload = useCallback(
async (filesToUpload: File[]) => {
setIsUploading(true)
let hasSuccessfulUpload = false
// Only clear errors for files that are being uploaded, keep errors for rejected files
setFileErrors(prev => {
const newErrors = { ...prev };
filesToUpload.forEach(file => {
delete newErrors[file.name];
});
return newErrors;
});
// Show uploading toast
const toastId = toast.loading(t('documentPanel.uploadDocuments.batch.uploading'))
try {
// Track errors locally to ensure we have the final state
const uploadErrors: Record<string, string> = {}
let batchProbeTriggered = false
// Create a collator that supports Chinese sorting
const collator = new Intl.Collator(['zh-CN', 'en'], {
sensitivity: 'accent', // consider basic characters, accents, and case
numeric: true // enable numeric sorting, e.g., "File 10" will be after "File 2"
});
const sortedFiles = [...filesToUpload].sort((a, b) =>
collator.compare(a.name, b.name)
);
// Upload files in sequence, not parallel
for (const file of sortedFiles) {
try {
// Initialize upload progress
setProgresses((pre) => ({
...pre,
[file.name]: 0
}))
const result = await uploadDocument(file, (percentCompleted: number) => {
console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted }))
setProgresses((pre) => ({
...pre,
[file.name]: percentCompleted
}))
})
if (result.status !== 'success') {
uploadErrors[file.name] = result.message
setFileErrors(prev => ({
...prev,
[file.name]: result.message
}))
} else {
// Mark that we had at least one successful upload
hasSuccessfulUpload = true
if (!batchProbeTriggered) {
batchProbeTriggered = true
onUploadBatchAccepted?.()
}
}
} catch (err) {
console.error(`Upload failed for ${file.name}:`, err)
// Handle HTTP errors, including 400 errors
let errorMsg = errorMessage(err)
const duplicateFileMsg = t('documentPanel.uploadDocuments.fileUploader.duplicateFile')
// If it's an axios error with response data, try to extract more detailed error info
if (err && typeof err === 'object' && 'response' in err) {
const axiosError = err as { response?: { status: number, data?: { detail?: string } } }
const status = axiosError.response?.status
const detail = axiosError.response?.data?.detail
if (status === 409) {
// Server now rejects same-name uploads with HTTP 409 instead of
// returning a 200 ``status="duplicated"`` payload. Map the most
// common cases (existing record / file in INPUT dir) back to the
// dedicated "duplicate file" UI affordance, and surface other
// 409 reasons (pipeline busy / scanning) verbatim from the
// server detail so users can tell why they were rejected.
if (
typeof detail === 'string' &&
(/already contains/i.test(detail) || /Status:/i.test(detail))
) {
errorMsg = duplicateFileMsg
} else {
errorMsg = detail || errorMsg
}
} else if (status === 400) {
errorMsg = detail || errorMsg
}
// Set progress to 100% to display error message
setProgresses((pre) => ({
...pre,
[file.name]: 100
}))
}
// Record error message in both local tracking and state
uploadErrors[file.name] = errorMsg
setFileErrors(prev => ({
...prev,
[file.name]: errorMsg
}))
}
}
// Check if any files failed to upload using our local tracking
const hasErrors = Object.keys(uploadErrors).length > 0
// Update toast status
if (hasErrors) {
toast.error(t('documentPanel.uploadDocuments.batch.error'), { id: toastId })
} else {
toast.success(t('documentPanel.uploadDocuments.batch.success'), { id: toastId })
}
// Only update if at least one file was uploaded successfully
if (hasSuccessfulUpload) {
// Refresh document list
if (onDocumentsUploaded) {
onDocumentsUploaded().catch(err => {
console.error('Error refreshing documents:', err)
})
}
}
} catch (err) {
console.error('Unexpected error during upload:', err)
toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }), { id: toastId })
} finally {
setIsUploading(false)
}
},
[setIsUploading, setProgresses, setFileErrors, t, onDocumentsUploaded, onUploadBatchAccepted]
)
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (isUploading) {
return
}
if (!open) {
setProgresses({})
setFileErrors({})
}
setOpen(open)
}}
>
<DialogTrigger asChild>
<Button variant="default" side="bottom" tooltip={t('documentPanel.uploadDocuments.tooltip')} size="sm">
<UploadIcon /> {t('documentPanel.uploadDocuments.button')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>{t('documentPanel.uploadDocuments.title')}</DialogTitle>
<DialogDescription>
{t('documentPanel.uploadDocuments.description')}
</DialogDescription>
</DialogHeader>
<FileUploader
maxFileCount={Infinity}
maxSize={200 * 1024 * 1024}
description={t('documentPanel.uploadDocuments.fileTypes')}
onUpload={handleDocumentsUpload}
onReject={handleRejectedFiles}
progresses={progresses}
fileErrors={fileErrors}
disabled={isUploading}
/>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,316 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
import { useGraphStore } from '@/stores/graph'
import { useSettingsStore } from '@/stores/settings'
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
import PropertyEditDialog from './PropertyEditDialog'
import MergeDialog from './MergeDialog'
const createErrorWithCause = (message: string, cause: unknown): Error => {
const error = new Error(message) as Error & { cause?: unknown }
error.cause = cause
return error
}
/**
* Interface for the EditablePropertyRow component props
*/
interface EditablePropertyRowProps {
name: string // Property name to display and edit
value: any // Initial value of the property
onClick?: () => void // Optional click handler for the property value
nodeId?: string // ID of the node (for node type)
entityId?: string // ID of the entity (for node type)
edgeId?: string // ID of the edge (for edge type)
dynamicId?: string
entityType?: 'node' | 'edge' // Type of graph entity
sourceId?: string // Source node ID (for edge type)
targetId?: string // Target node ID (for edge type)
onValueChange?: (newValue: any) => void // Optional callback when value changes
isEditable?: boolean // Whether this property can be edited
tooltip?: string // Optional tooltip to display on hover
pipelineBusy?: boolean // When true, hide edit entry & disable save (pipeline writing)
}
/**
* EditablePropertyRow component that supports editing property values
* This component is used in the graph properties panel to display and edit entity properties
*/
const EditablePropertyRow = ({
name,
value: initialValue,
onClick,
nodeId,
edgeId,
entityId,
dynamicId,
entityType,
sourceId,
targetId,
onValueChange,
isEditable = false,
tooltip,
pipelineBusy = false
}: EditablePropertyRowProps) => {
const { t } = useTranslation()
const [isEditing, setIsEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [currentValue, setCurrentValue] = useState(initialValue)
const [draftValue, setDraftValue] = useState(String(initialValue))
const [draftAllowMerge, setDraftAllowMerge] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [mergeDialogOpen, setMergeDialogOpen] = useState(false)
const [mergeDialogInfo, setMergeDialogInfo] = useState<{
targetEntity: string
sourceEntity: string
} | null>(null)
// Sync currentValue when the incoming initialValue prop changes.
// Uses a render-time previous-value comparison instead of useEffect to avoid
// cascading renders flagged by react-hooks/set-state-in-effect.
const [previousInitialValue, setPreviousInitialValue] = useState(initialValue)
if (initialValue !== previousInitialValue) {
setPreviousInitialValue(initialValue)
setCurrentValue(initialValue)
}
const handleEditClick = () => {
if (pipelineBusy) return
if (isEditable && !isEditing) {
setDraftValue(String(currentValue))
setDraftAllowMerge(false)
setIsEditing(true)
setErrorMessage(null)
}
}
const handleCancel = () => {
setIsEditing(false)
setErrorMessage(null)
}
const handleSave = async () => {
const value = draftValue.trim()
const allowMerge = draftAllowMerge
if (value === '') {
return
}
if (isSubmitting || value === String(currentValue)) {
setIsEditing(false)
setErrorMessage(null)
return
}
setIsSubmitting(true)
setErrorMessage(null)
try {
if (entityType === 'node' && entityId && nodeId) {
let updatedData = { [name]: value }
if (name === 'entity_id') {
if (!allowMerge) {
const exists = await checkEntityNameExists(value)
if (exists) {
const errorMsg = t('graphPanel.propertiesView.errors.duplicateName')
setErrorMessage(errorMsg)
toast.error(errorMsg)
return
}
}
updatedData = { 'entity_name': value }
}
const response = await updateEntity(entityId, updatedData, true, allowMerge)
const operationSummary = response.operation_summary
const operationStatus = operationSummary?.operation_status || 'complete_success'
const finalValue = operationSummary?.final_entity ?? value
// Handle different operation statuses
if (operationStatus === 'success') {
if (operationSummary?.merged) {
// Node was successfully merged into an existing entity
setMergeDialogInfo({
targetEntity: finalValue,
sourceEntity: entityId,
})
setMergeDialogOpen(true)
// Remove old entity name from search history
SearchHistoryManager.removeLabel(entityId)
// Note: Search Label update is deferred until user clicks refresh button in merge dialog
toast.success(t('graphPanel.propertiesView.success.entityMerged'))
} else {
// Node was updated/renamed normally
try {
const graphValue = name === 'entity_id' ? finalValue : value
await useGraphStore
.getState()
.updateNodeAndSelect(nodeId, entityId, name, graphValue)
} catch (error) {
console.error('Error updating node in graph:', error)
throw createErrorWithCause('Failed to update node in graph', error)
}
// Update search history: remove old name, add new name
if (name === 'entity_id') {
const currentLabel = useSettingsStore.getState().queryLabel
SearchHistoryManager.removeLabel(entityId)
SearchHistoryManager.addToHistory(finalValue)
// Trigger dropdown refresh to show updated search history
useSettingsStore.getState().triggerSearchLabelDropdownRefresh()
// If current queryLabel is the old entity name, update to new name
if (currentLabel === entityId) {
useSettingsStore.getState().setQueryLabel(finalValue)
}
}
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
}
// Update local state and notify parent component
// For entity_id updates, use finalValue (which may be different due to merging)
// For other properties, use the original value the user entered
const valueToSet = name === 'entity_id' ? finalValue : value
setCurrentValue(valueToSet)
onValueChange?.(valueToSet)
} else if (operationStatus === 'partial_success') {
// Partial success: update succeeded but merge failed
// Do NOT update graph data to keep frontend in sync with backend
const mergeError = operationSummary?.merge_error || 'Unknown error'
const errorMsg = t('graphPanel.propertiesView.errors.updateSuccessButMergeFailed', {
error: mergeError
})
setErrorMessage(errorMsg)
toast.error(errorMsg)
// Do not update currentValue or call onValueChange
return
} else {
// Complete failure or unknown status
// Check if this was a merge attempt or just a regular update
if (operationSummary?.merge_status === 'failed') {
// Merge operation was attempted but failed
const mergeError = operationSummary?.merge_error || 'Unknown error'
const errorMsg = t('graphPanel.propertiesView.errors.mergeFailed', {
error: mergeError
})
setErrorMessage(errorMsg)
toast.error(errorMsg)
} else {
// Regular update failed (no merge involved)
const errorMsg = t('graphPanel.propertiesView.errors.updateFailed')
setErrorMessage(errorMsg)
toast.error(errorMsg)
}
// Do not update currentValue or call onValueChange
return
}
} else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) {
const updatedData = { [name]: value }
await updateRelation(sourceId, targetId, updatedData)
try {
await useGraphStore.getState().updateEdgeAndSelect(edgeId, dynamicId, sourceId, targetId, name, value)
} catch (error) {
console.error(`Error updating edge ${sourceId}->${targetId} in graph:`, error)
throw createErrorWithCause('Failed to update edge in graph', error)
}
toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
setCurrentValue(value)
onValueChange?.(value)
}
setIsEditing(false)
} catch (error) {
console.error('Error updating property:', error)
const errorMsg = error instanceof Error ? error.message : t('graphPanel.propertiesView.errors.updateFailed')
setErrorMessage(errorMsg)
toast.error(errorMsg)
return
} finally {
setIsSubmitting(false)
}
}
const handleMergeRefresh = (useMergedStart: boolean) => {
const info = mergeDialogInfo
const graphState = useGraphStore.getState()
const settingsState = useSettingsStore.getState()
const currentLabel = settingsState.queryLabel
// Clear graph state
graphState.clearSelection()
graphState.setGraphDataFetchAttempted(false)
graphState.setLastSuccessfulQueryLabel('')
if (useMergedStart && info?.targetEntity) {
// Use merged entity as new start point (might already be set in handleSave)
settingsState.setQueryLabel(info.targetEntity)
} else {
// Keep current start point - refresh by resetting and restoring label
// This handles the case where user wants to stay with current label
settingsState.setQueryLabel('')
setTimeout(() => {
settingsState.setQueryLabel(currentLabel)
}, 50)
}
// Force graph re-render and reset zoom/scale (same as refresh button behavior)
graphState.incrementGraphDataVersion()
setMergeDialogOpen(false)
setMergeDialogInfo(null)
toast.info(t('graphPanel.propertiesView.mergeDialog.refreshing'))
}
return (
<div className="flex items-center gap-1 overflow-hidden">
<PropertyName name={name} />
{!pipelineBusy && <EditIcon onClick={handleEditClick} />}:
<PropertyValue
value={currentValue}
onClick={onClick}
tooltip={tooltip || (typeof currentValue === 'string' ? currentValue : JSON.stringify(currentValue, null, 2))}
/>
<PropertyEditDialog
isOpen={isEditing}
onClose={handleCancel}
onSave={handleSave}
propertyName={name}
value={draftValue}
allowMerge={draftAllowMerge}
onValueChange={setDraftValue}
onAllowMergeChange={setDraftAllowMerge}
isSubmitting={isSubmitting}
errorMessage={errorMessage}
disableSave={pipelineBusy}
/>
<MergeDialog
mergeDialogOpen={mergeDialogOpen}
mergeDialogInfo={mergeDialogInfo}
onOpenChange={(open) => {
setMergeDialogOpen(open)
if (!open) {
setMergeDialogInfo(null)
}
}}
onRefresh={handleMergeRefresh}
/>
</div>
)
}
export default EditablePropertyRow
@@ -0,0 +1,54 @@
import { useCamera, useSigma } from '@react-sigma/core'
import { useEffect } from 'react'
import { useGraphStore } from '@/stores/graph'
/**
* 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(() => {
const graph = sigma.getGraph();
if (move) {
if (node && graph.hasNode(node)) {
try {
graph.setNodeAttribute(node, 'highlighted', true);
gotoNode(node);
} catch (error) {
console.error('Error focusing on node:', error);
}
} else {
// If no node is selected but move is true, reset to default view
sigma.setCustomBBox(null);
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 });
}
useGraphStore.getState().setMoveToSelectedNode(false);
} else if (node && graph.hasNode(node)) {
try {
graph.setNodeAttribute(node, 'highlighted', true);
} catch (error) {
console.error('Error highlighting node:', error);
}
}
return () => {
if (node && graph.hasNode(node)) {
try {
graph.setNodeAttribute(node, 'highlighted', false);
} catch (error) {
console.error('Error cleaning up node highlight:', error);
}
}
}
}, [node, move, sigma, gotoNode])
return null
}
export default FocusOnNode
@@ -0,0 +1,29 @@
import { useFullScreen } from '@react-sigma/core'
import { MaximizeIcon, MinimizeIcon } from 'lucide-react'
import { controlButtonVariant } from '@/lib/constants'
import Button from '@/components/ui/Button'
import { useTranslation } from 'react-i18next'
/**
* Component that toggles full screen mode.
*/
const FullScreenControl = () => {
const { isFullScreen, toggle } = useFullScreen()
const { t } = useTranslation()
return (
<>
{isFullScreen ? (
<Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.windowed')} size="icon">
<MinimizeIcon />
</Button>
) : (
<Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.fullScreen')} size="icon">
<MaximizeIcon />
</Button>
)}
</>
)
}
export default FullScreenControl
@@ -0,0 +1,502 @@
import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
import { AbstractGraph } from 'graphology-types'
import forceAtlas2 from 'graphology-layout-forceatlas2'
import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker'
import { useEffect, useRef } from 'react'
import { EdgeType, NodeType } from '@/hooks/useLightragGraph'
import useIsDarkMode from '@/hooks/useIsDarkMode'
import * as Constants from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
if (ev.type.startsWith('mouse')) {
if ((ev as MouseEvent).buttons !== 0) {
return true
}
}
return false
}
const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {
const sigma = useSigma<NodeType, EdgeType>()
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
const setSettings = useSetSettings<NodeType, EdgeType>()
const isDarkTheme = useIsDarkMode()
const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
const renderLabels = useSettingsStore.use.showNodeLabel()
const minEdgeSize = useSettingsStore.use.minEdgeSize()
const maxEdgeSize = useSettingsStore.use.maxEdgeSize()
const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode()
const selectedEdge = useGraphStore.use.selectedEdge()
const focusedEdge = useGraphStore.use.focusedEdge()
const sigmaGraph = useGraphStore.use.sigmaGraph()
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
// Mirror GraphViewer's gating: above EDGE_PERF_LIMIT the sigma instance is
// (re)built without the edge picking buffer, so edge events cannot fire even
// though the user setting may be on. Use this — not the raw setting — for
// event registration and the reducers, so we never run the costly edge-focus
// path against a graph that can't actually pick edges.
const effectiveEdgeEvents = enableEdgeEvents && graphEdgeCount <= Constants.EDGE_PERF_LIMIT
// The graph the initial FA2 layout has already been run for. Used to run the
// layout once PER GRAPH, not once per sigma instance (a theme change rebuilds
// the instance and re-runs the bind effect with the SAME graph).
const laidOutGraphRef = useRef<unknown>(null)
// Last (sigma instance, curved decision) the edge-type effect applied. A
// rebuild (theme toggle / edge-events gating) creates a fresh sigma whose
// defaultEdgeType reverts to its construction default ('rect'), so we must
// re-apply when the INSTANCE changed too — not only when the decision flips.
const edgeTypeRef = useRef<{ sigma: unknown; curved: boolean } | null>(null)
/**
* When component mounts or the graph changes
* => bind graph to sigma and run the initial layout
*
* PERFORMANCE: the initial ForceAtlas2 layout runs in a WEB WORKER with
* settings inferred from the graph size (inferSettings enables Barnes-Hut
* above ~2k nodes). The previous implementation called the synchronous
* `assign()` on the main thread with DEFAULT settings — i.e. without
* Barnes-Hut, where every iteration is O(V²) pairwise repulsion. At 73k
* nodes that is billions of operations per iteration, times 15 iterations,
* on the UI thread: the tab froze for minutes after every load.
*/
useEffect(() => {
if (!(sigmaGraph && sigma)) return
try {
if (typeof sigma.setGraph === 'function') {
sigma.setGraph(sigmaGraph as unknown as AbstractGraph<NodeType, EdgeType>)
console.log('Binding graph to sigma instance')
} else {
console.error('Sigma missing setGraph function')
}
} catch (error) {
console.error('Error setting graph on sigma instance:', error)
}
if (sigmaGraph.order === 0) return
// Run the initial layout once PER GRAPH. A theme toggle recreates the sigma
// instance (settings change -> SigmaContainer rebuild) and re-runs this
// effect with the SAME graph, which already carries settled positions —
// re-running FA2 there would replay the relaxation animation on every theme
// switch. Only (re)bind in that case; skip the layout.
//
// The ref is marked "laid out" only when the budget timer fires (layout
// settled), NOT before start: if a rebuild interrupts the layout mid-run
// (e.g. edge-events gating crosses the threshold during the first load),
// the new instance re-runs FA2 from where it was instead of freezing on a
// half-relaxed layout. Rebuilds after settling still match and skip.
if (laidOutGraphRef.current === sigmaGraph) return
let layout: { start: () => void; stop: () => void; kill: () => void } | null = null
try {
layout = new FA2LayoutSupervisor(sigmaGraph as never, {
settings: forceAtlas2.inferSettings(sigmaGraph.order)
})
layout.start()
// Become the single layout owner. If a previous layout is somehow still
// registered, this kills it so only one supervisor mutates coordinates.
useGraphStore.getState().setActiveLayoutSupervisor(layout)
console.log(`FA2 worker layout started (${sigmaGraph.order} nodes)`)
} catch (error) {
console.error('Error starting FA2 worker layout:', error)
return
}
// Time budget instead of an iteration count: the UI stays interactive
// while the layout settles in the background, then we stop it.
const budgetMs = Constants.workerBudgetMs(sigmaGraph.order)
const timer = window.setTimeout(() => {
try {
layout?.stop()
// Mark this graph as laid out only now (settled), so a rebuild during
// the budget window re-runs the layout rather than skipping it.
laidOutGraphRef.current = sigmaGraph
console.log('FA2 worker layout stopped after budget')
// Release the shared slot so the store invariant "activeLayoutSupervisor
// != null => a layout is running" holds (the budget just stopped this
// one); no-op for the slot if a manually selected layout already took over.
useGraphStore.getState().releaseLayoutSupervisor(layout)
// Clear any stale custom bbox (set by node dragging) and refresh so
// the camera normalization fits the settled layout.
sigma.setCustomBBox(null)
sigma.refresh()
} catch (error) {
console.error('Error stopping FA2 worker layout:', error)
}
}, budgetMs)
return () => {
window.clearTimeout(timer)
// Release the slot if we still own it (a manually selected worker layout
// may have taken over and already killed `layout`); otherwise just kill
// our own supervisor without disturbing the newer layout.
useGraphStore.getState().releaseLayoutSupervisor(layout)
}
}, [sigma, sigmaGraph])
/**
* Ensure the sigma instance is set in the store
*/
useEffect(() => {
if (sigma) {
const currentInstance = useGraphStore.getState().sigmaInstance
// Update when the instance CHANGED, not only when it's unset. A theme
// toggle, an effectiveEdgeEvents flip, or crossing the edge threshold
// rebuilds the SigmaContainer (old instance killed, new one created); if
// we only wrote on an empty store the killed instance would linger there
// and consumers (e.g. expand reading sigmaInstance.getCamera()) would act
// on a dead Sigma.
if (currentInstance !== sigma) {
console.log('Setting sigma instance from GraphControl')
useGraphStore.getState().setSigmaInstance(sigma)
}
}
}, [sigma])
/**
* With hideEdgesOnMove, edges are skipped while the camera is moving. sigma's
* built-in post-drag refresh (mouse handleUp) fires on a setTimeout(0), but a
* drag with inertia is still animating then, so it renders WITHOUT edges and
* nothing refreshes once movement settles — edges stay hidden until the next
* interaction (the "edges vanish after a small pan and don't come back" bug;
* a stage click doesn't help because it isn't a drag, but a hover re-renders).
*
* Watch the camera's `updated` event AND the mouse captor's `mouseup`, and
* once things go quiet, refresh ONLY when sigma is truly idle. We mirror
* sigma's exact `moving` condition (camera.isAnimated() || mouse.isMoving ||
* draggedEvents || wheelDirection); if any is still set when the timer fires,
* we re-arm and wait, so the refresh always lands on a frame where edges are
* actually drawn. The mouseup trigger only fires when the release ends a drag
* (draggedEvents > 0): a plain click never hides edges, so refreshing on it
* would be a wasted full re-indexation. Between them, the camera path covers
* every camera-animation-driven move (pan inertia, zoom, moveToSelectedNode)
* and the mouseup path covers drags with no post-release animation, so any
* selection-change render that lands on a "moving" frame is recovered by
* whichever of the two is concurrently active.
*/
useEffect(() => {
if (!sigma) return
const camera = sigma.getCamera()
const mouse = sigma.getMouseCaptor()
let timer: number | null = null
const stillMoving = () =>
camera.isAnimated() ||
mouse.isMoving ||
mouse.draggedEvents > 0 ||
mouse.currentWheelDirection !== 0
const refreshWhenIdle = () => {
if (timer !== null) window.clearTimeout(timer)
timer = window.setTimeout(() => {
timer = null
if (stillMoving()) {
refreshWhenIdle() // not settled yet — keep waiting
return
}
try {
sigma.refresh()
} catch {
/* sigma instance already killed */
}
}, 80)
}
// Only let a *drag* release re-trigger the idle refresh. A plain click never
// hides edges (no movement), so refreshing on it is pure wasted full
// re-indexation. draggedEvents is still set at this point — sigma resets it
// in its own post-mouseup setTimeout(0), which runs after this handler.
const refreshOnDragEnd = () => {
if (mouse.draggedEvents > 0) refreshWhenIdle()
}
camera.on('updated', refreshWhenIdle)
mouse.on('mouseup', refreshOnDragEnd)
return () => {
if (timer !== null) window.clearTimeout(timer)
camera.removeListener('updated', refreshWhenIdle)
mouse.removeListener('mouseup', refreshOnDragEnd)
}
}, [sigma])
/**
* Adapt edge geometry to graph size: curves for small graphs (nicer to read),
* straight rectangles above EDGE_PERF_LIMIT (curve tessellation is costly).
*
* Edges carry no per-edge `type`, so switching `defaultEdgeType` + a full
* `refresh()` (which re-adds edges through applyEdgeDefaults) re-selects the
* program for the whole graph without touching attributes or rebuilding.
*
* The ref tracks BOTH the sigma instance and the decision: re-apply when the
* instance changed (a rebuild resets defaultEdgeType to 'rect') OR when the
* curved/straight decision flipped; skip otherwise so routine expand/prune
* within one band don't trigger a full refresh.
*/
useEffect(() => {
if (!sigma) return
const curved = graphEdgeCount > 0 && graphEdgeCount <= Constants.EDGE_PERF_LIMIT
const prev = edgeTypeRef.current
if (prev && prev.sigma === sigma && prev.curved === curved) return
edgeTypeRef.current = { sigma, curved }
setSettings({ defaultEdgeType: curved ? 'curvedNoArrow' : 'rect' })
try {
sigma.refresh()
} catch {
/* sigma instance already killed */
}
}, [sigma, graphEdgeCount, setSettings])
/**
* When edge events become gated off (count crossed above EDGE_PERF_LIMIT),
* drop any residual edge focus/selection so the UI (property panel, reducers)
* doesn't keep a now-unpickable edge highlighted. Node selection is untouched.
*/
useEffect(() => {
if (effectiveEdgeEvents) return
const { selectedEdge, focusedEdge, setSelectedEdge, setFocusedEdge } = useGraphStore.getState()
if (selectedEdge !== null) setSelectedEdge(null)
if (focusedEdge !== null) setFocusedEdge(null)
}, [effectiveEdgeEvents])
/**
* When component mounts
* => register events
*/
useEffect(() => {
const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =
useGraphStore.getState()
type NodeEvent = { node: string; event: { original: MouseEvent | TouchEvent } }
type EdgeEvent = { edge: string; event: { original: MouseEvent | TouchEvent } }
const events: Record<string, any> = {
enterNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) {
const graph = sigma.getGraph()
if (graph.hasNode(event.node)) {
setFocusedNode(event.node)
}
}
},
leaveNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedNode(null)
}
},
clickNode: (event: NodeEvent) => {
const graph = sigma.getGraph()
if (graph.hasNode(event.node)) {
setSelectedNode(event.node)
setSelectedEdge(null)
}
},
clickStage: () => clearSelection()
}
if (effectiveEdgeEvents) {
events.clickEdge = (event: EdgeEvent) => {
setSelectedEdge(event.edge)
setSelectedNode(null)
}
events.enterEdge = (event: EdgeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedEdge(event.edge)
}
}
events.leaveEdge = (event: EdgeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedEdge(null)
}
}
}
registerEvents(events)
}, [registerEvents, effectiveEdgeEvents, sigma])
/**
* When edge size settings change, recalculate edge sizes.
* Batched via updateEachEdgeAttributes (one pass, one graphology event)
* instead of one event-emitting setEdgeAttribute per edge.
*/
useEffect(() => {
if (sigma && sigmaGraph) {
const graph = sigma.getGraph()
let minWeight = Number.MAX_SAFE_INTEGER
let maxWeight = 0
graph.forEachEdge((edge) => {
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1
if (typeof weight === 'number') {
minWeight = Math.min(minWeight, weight)
maxWeight = Math.max(maxWeight, weight)
}
})
const weightRange = maxWeight - minWeight
const sizeScale = maxEdgeSize - minEdgeSize
graph.updateEachEdgeAttributes(
(_edge, attr) => {
if (weightRange > 0) {
const weight = typeof attr.originalWeight === 'number' ? attr.originalWeight : 1
attr.size = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5)
} else {
attr.size = minEdgeSize
}
return attr
},
{ attributes: ['size'] }
)
sigma.refresh()
}
}, [sigma, sigmaGraph, minEdgeSize, maxEdgeSize])
/**
* When focus/selection changes
* => set the sigma reducers
*
* PERFORMANCE:
* - When nothing is focused or selected, reducers are set to null: running
* a JS callback (with an object spread) for all 73k nodes and all edges
* on every refresh is pure overhead when there is nothing to highlight.
* - The focused node's neighborhood is precomputed ONCE into a Set. The
* previous code called graph.neighbors(focused).includes(node) INSIDE the
* node reducer — allocating the full neighbor array and scanning it
* linearly for every single node of the graph, per refresh.
*/
useEffect(() => {
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
const graph = sigma.getGraph()
const graphOrder = graph ? graph.order : 0
const effectiveRenderLabels = renderLabels && graphOrder <= Constants.LABEL_RENDER_LIMIT
const _focusedNode = focusedNode || selectedNode
// Ignore any residual edge focus/selection when edge events are gated off
// (e.g. an edge was selected on a small graph, then expand pushed it past
// EDGE_PERF_LIMIT): otherwise the edge-focused reducer branch below would
// still run the per-edge highlight pass on a large graph — exactly the cost
// we disabled edge events to avoid.
const _focusedEdge = effectiveEdgeEvents ? focusedEdge || selectedEdge : null
if (disableHoverEffect || (!_focusedNode && !_focusedEdge)) {
setSettings({
enableEdgeEvents: effectiveEdgeEvents,
renderEdgeLabels,
renderLabels: effectiveRenderLabels,
nodeReducer: null,
edgeReducer: null
})
return
}
// Precompute the highlight context once, outside the reducers.
const neighborSet = new Set<string>()
let focusedNodeValid = false
if (_focusedNode && graph.hasNode(_focusedNode)) {
focusedNodeValid = true
graph.forEachNeighbor(_focusedNode, (neighbor) => neighborSet.add(neighbor))
}
let focusedEdgeSource = ''
let focusedEdgeTarget = ''
let focusedEdgeValid = false
if (!focusedNodeValid && _focusedEdge && graph.hasEdge(_focusedEdge)) {
focusedEdgeValid = true
focusedEdgeSource = graph.source(_focusedEdge)
focusedEdgeTarget = graph.target(_focusedEdge)
}
const edgeHighlightColor = isDarkTheme
? Constants.edgeColorHighlightedDarkTheme
: Constants.edgeColorHighlightedLightTheme
setSettings({
enableEdgeEvents: effectiveEdgeEvents,
renderEdgeLabels,
renderLabels: effectiveRenderLabels,
nodeReducer: (node, data) => {
const newData: NodeType & { labelColor?: string; borderColor?: string } = {
...data,
highlighted: false,
labelColor
}
if (focusedNodeValid) {
if (node === _focusedNode || neighborSet.has(node)) {
newData.highlighted = true
if (node === selectedNode) {
newData.borderColor = Constants.nodeBorderColorSelected
}
if (isDarkTheme) {
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
}
} else {
newData.color = Constants.nodeColorDisabled
}
} else if (focusedEdgeValid) {
if (node === focusedEdgeSource || node === focusedEdgeTarget) {
newData.highlighted = true
newData.size = 3
if (isDarkTheme) {
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
}
} else {
newData.color = Constants.nodeColorDisabled
}
}
return newData
},
edgeReducer: (edge, data) => {
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
if (focusedNodeValid) {
// No graph.extremities() here: it allocates an array per edge.
const touchesFocused =
graph.source(edge) === _focusedNode || graph.target(edge) === _focusedNode
if (hideUnselectedEdges) {
if (!touchesFocused) newData.hidden = true
} else if (touchesFocused) {
newData.color = edgeHighlightColor
}
} else if (focusedEdgeValid) {
if (edge === selectedEdge) {
newData.color = Constants.edgeColorSelected
} else if (edge === _focusedEdge) {
newData.color = edgeHighlightColor
} else if (hideUnselectedEdges) {
newData.hidden = true
}
}
return newData
}
})
}, [
selectedNode,
focusedNode,
selectedEdge,
focusedEdge,
setSettings,
sigma,
sigmaGraph,
disableHoverEffect,
isDarkTheme,
hideUnselectedEdges,
effectiveEdgeEvents,
renderEdgeLabels,
renderLabels
])
return null
}
export default GraphControl
@@ -0,0 +1,320 @@
import { useCallback, useEffect, useState, useRef } from 'react'
import { AsyncSelect } from '@/components/ui/AsyncSelect'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import { useBackendState } from '@/stores/state'
import {
dropdownDisplayLimit,
controlButtonVariant,
popularLabelsDefaultLimit,
searchLabelsDefaultLimit
} from '@/lib/constants'
import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react'
import Button from '@/components/ui/Button'
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
import { getPopularLabels, searchLabels } from '@/api/lightrag'
const GraphLabels = () => {
const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel()
const dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger()
const [isRefreshing, setIsRefreshing] = useState(false)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [selectKey, setSelectKey] = useState(0)
// Pipeline state monitoring
const pipelineBusy = useBackendState.use.pipelineBusy()
const prevPipelineBusy = useRef<boolean | undefined>(undefined)
const shouldRefreshPopularLabelsRef = useRef(false)
// Dynamic tooltip based on current label state
const getRefreshTooltip = useCallback(() => {
if (isRefreshing) {
return t('graphPanel.graphLabels.refreshingTooltip')
}
if (!label || label === '*') {
return t('graphPanel.graphLabels.refreshGlobalTooltip')
} else {
return t('graphPanel.graphLabels.refreshCurrentLabelTooltip', { label })
}
}, [label, t, isRefreshing])
// Initialize search history on component mount
useEffect(() => {
const initializeHistory = async () => {
const history = SearchHistoryManager.getHistory()
if (history.length === 0) {
// If no history exists, fetch popular labels and initialize
try {
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
await SearchHistoryManager.initializeWithDefaults(popularLabels)
} catch (error) {
console.error('Failed to initialize search history:', error)
// No fallback needed, API is the source of truth
}
}
}
initializeHistory()
}, [])
// Force AsyncSelect to re-render when label or dropdownRefreshTrigger changes.
// Uses render-time previous-value comparison to avoid cascading renders from
// setState-in-useEffect, while still bumping the key on external changes.
const [previousLabel, setPreviousLabel] = useState(label)
const [previousDropdownTrigger, setPreviousDropdownTrigger] = useState(dropdownRefreshTrigger)
if (label !== previousLabel) {
setPreviousLabel(label)
setSelectKey(prev => prev + 1)
}
if (dropdownRefreshTrigger !== previousDropdownTrigger) {
setPreviousDropdownTrigger(dropdownRefreshTrigger)
if (dropdownRefreshTrigger > 0) {
setSelectKey(prev => prev + 1)
}
}
// Monitor pipeline state changes: busy -> idle
useEffect(() => {
if (prevPipelineBusy.current === true && pipelineBusy === false) {
console.log('Pipeline changed from busy to idle, marking for popular labels refresh')
shouldRefreshPopularLabelsRef.current = true
}
prevPipelineBusy.current = pipelineBusy
}, [pipelineBusy])
// Helper: Reload popular labels from backend
const reloadPopularLabels = useCallback(async () => {
if (!shouldRefreshPopularLabelsRef.current) return
console.log('Reloading popular labels (triggered by pipeline idle)')
try {
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
SearchHistoryManager.clearHistory()
if (popularLabels.length === 0) {
const fallbackLabels = ['entity', 'relationship', 'document', 'concept']
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} else {
await SearchHistoryManager.initializeWithDefaults(popularLabels)
}
} catch (error) {
console.error('Failed to reload popular labels:', error)
const fallbackLabels = ['entity', 'relationship', 'document']
SearchHistoryManager.clearHistory()
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} finally {
// Always clear the flag
shouldRefreshPopularLabelsRef.current = false
}
}, [])
// Helper: Bump dropdown data to trigger refresh
const bumpDropdownData = useCallback(({ forceSelectKey = false } = {}) => {
setRefreshTrigger(prev => prev + 1)
if (forceSelectKey) {
setSelectKey(prev => prev + 1)
}
}, [])
const fetchData = useCallback(
async (query?: string): Promise<string[]> => {
let results: string[];
if (!query || query.trim() === '' || query.trim() === '*') {
// Empty query: return search history
results = SearchHistoryManager.getHistoryLabels(dropdownDisplayLimit)
} else {
// Non-empty query: call backend search API
try {
const apiResults = await searchLabels(query.trim(), searchLabelsDefaultLimit)
results = apiResults.length <= dropdownDisplayLimit
? apiResults
: [...apiResults.slice(0, dropdownDisplayLimit), '...']
} catch (error) {
console.error('Search API failed, falling back to local history search:', error)
// Fallback to local history search
const history = SearchHistoryManager.getHistory()
const queryLower = query.toLowerCase().trim()
results = history
.filter(item => item.label.toLowerCase().includes(queryLower))
.map(item => item.label)
.slice(0, dropdownDisplayLimit)
}
}
// Always show '*' at the top, and remove duplicates
const finalResults = ['*', ...results.filter(label => label !== '*')];
return finalResults;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[refreshTrigger] // Intentionally added to trigger re-creation when data changes
)
const handleRefresh = useCallback(async () => {
setIsRefreshing(true)
// Clear legend cache to ensure legend is re-generated on refresh
useGraphStore.getState().setTypeColorMap(new Map<string, string>())
try {
let currentLabel = label
// If queryLabel is empty, set it to '*'
if (!currentLabel || currentLabel.trim() === '') {
useSettingsStore.getState().setQueryLabel('*')
currentLabel = '*'
}
// Scenario 1: Manual refresh - reload popular labels if flag is set (regardless of current label)
if (shouldRefreshPopularLabelsRef.current) {
await reloadPopularLabels()
bumpDropdownData({ forceSelectKey: true })
}
if (currentLabel && currentLabel !== '*') {
// Scenario 1: Has specific label, try to refresh current label
console.log(`Refreshing current label: ${currentLabel}`)
// Reset graph data fetch status to trigger refresh
useGraphStore.getState().setGraphDataFetchAttempted(false)
useGraphStore.getState().setLastSuccessfulQueryLabel('')
// Force data refresh for current label
useGraphStore.getState().incrementGraphDataVersion()
// Note: If the current label has no data after refresh,
// the fallback logic would be handled by the graph component itself
// For now, we keep the current label and let the user see the result
} else {
// Scenario 3: queryLabel is "*", refresh global data and popular labels
console.log('Refreshing global data and popular labels')
try {
// Re-fetch popular labels and update search history (if not already done)
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
SearchHistoryManager.clearHistory()
if (popularLabels.length === 0) {
// If no popular labels, provide fallback defaults
const fallbackLabels = ['entity', 'relationship', 'document', 'concept']
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} else {
await SearchHistoryManager.initializeWithDefaults(popularLabels)
}
} catch (error) {
console.error('Failed to reload popular labels:', error)
// Provide fallback even if API fails
const fallbackLabels = ['entity', 'relationship', 'document']
SearchHistoryManager.clearHistory()
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
}
// Reset graph data fetch status
useGraphStore.getState().setGraphDataFetchAttempted(false)
useGraphStore.getState().setLastSuccessfulQueryLabel('')
// Force global data refresh
useGraphStore.getState().incrementGraphDataVersion()
// Ensure data update completes before triggering UI refresh
await new Promise(resolve => setTimeout(resolve, 0))
// Trigger both refresh mechanisms to ensure dropdown updates
setRefreshTrigger(prev => prev + 1)
setSelectKey(prev => prev + 1)
}
} catch (error) {
console.error('Error during refresh:', error)
} finally {
setIsRefreshing(false)
}
}, [label, reloadPopularLabels, bumpDropdownData])
// Handle dropdown before open - reload popular labels if needed
const handleDropdownBeforeOpen = useCallback(async () => {
const currentLabel = useSettingsStore.getState().queryLabel
if (shouldRefreshPopularLabelsRef.current && (!currentLabel || currentLabel === '*')) {
await reloadPopularLabels()
bumpDropdownData()
}
}, [reloadPopularLabels, bumpDropdownData])
return (
<div className="flex items-center">
{/* Always show refresh button */}
<Button
size="icon"
variant={controlButtonVariant}
onClick={handleRefresh}
tooltip={getRefreshTooltip()}
className="mr-2"
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
<div className="w-full min-w-[280px] max-w-[500px]">
<AsyncSelect<string>
key={selectKey} // Force re-render when data changes
className="min-w-[300px]"
triggerClassName="max-h-8 w-full overflow-hidden"
searchInputClassName="max-h-8"
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
fetcher={fetchData}
onBeforeOpen={handleDropdownBeforeOpen}
renderOption={(item) => (
<div className="truncate" title={item}>
{item}
</div>
)}
getOptionValue={(item) => item}
getDisplayValue={(item) => (
<div className="min-w-0 flex-1 truncate text-left" title={item}>
{item}
</div>
)}
notFound={<div className="py-6 text-center text-sm">{t('graphPanel.graphLabels.noLabels')}</div>}
ariaLabel={t('graphPanel.graphLabels.label')}
placeholder={t('graphPanel.graphLabels.placeholder')}
searchPlaceholder={t('graphPanel.graphLabels.placeholder')}
noResultsMessage={t('graphPanel.graphLabels.noLabels')}
value={label !== null ? label : '*'}
onChange={(newLabel) => {
const currentLabel = useSettingsStore.getState().queryLabel;
// select the last item means query all
if (newLabel === '...') {
newLabel = '*';
}
// Handle reselecting the same label
if (newLabel === currentLabel && newLabel !== '*') {
newLabel = '*';
}
// Add selected label to search history (except for special cases)
if (newLabel && newLabel !== '*' && newLabel !== '...' && newLabel.trim() !== '') {
SearchHistoryManager.addToHistory(newLabel);
}
// Reset graphDataFetchAttempted flag to ensure data fetch is triggered
useGraphStore.getState().setGraphDataFetchAttempted(false);
// Update the label to trigger data loading
useSettingsStore.getState().setQueryLabel(newLabel);
// Force graph re-render and reset zoom/scale (must be AFTER setQueryLabel)
useGraphStore.getState().incrementGraphDataVersion();
}}
clearable={false} // Prevent clearing value on reselect
debounceTime={500}
/>
</div>
</div>
)
}
export default GraphLabels
@@ -0,0 +1,232 @@
import { FC, useCallback, useEffect } from 'react'
import {
EdgeById,
GraphSearchInputProps,
GraphSearchContextProviderProps
} from '@react-sigma/graph-search'
import { AsyncSearch } from '@/components/ui/AsyncSearch'
import { searchResultLimit } from '@/lib/constants'
import { useGraphStore } from '@/stores/graph'
import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next'
// Message item identifier for search results
export const messageId = '__message_item'
// Search result option item interface
export interface OptionItem {
id: string
type: 'nodes' | 'edges' | 'message'
message?: string
}
const NodeOption = ({ id }: { id: string }) => {
const graph = useGraphStore.use.sigmaGraph()
// Early return if no graph or node doesn't exist
if (!graph?.hasNode(id)) {
return null
}
// Safely get node attributes with fallbacks
const label = graph.getNodeAttribute(id, 'label') || id
const color = graph.getNodeAttribute(id, 'color') || '#666'
const size = graph.getNodeAttribute(id, 'size') || 4
// Custom node display component that doesn't rely on @react-sigma/graph-search
return (
<div className="flex items-center gap-2 p-2 text-sm">
<div
className="rounded-full flex-shrink-0"
style={{
width: Math.max(8, Math.min(size * 2, 16)),
height: Math.max(8, Math.min(size * 2, 16)),
backgroundColor: color
}}
/>
<span className="truncate">{label}</span>
</div>
)
}
function OptionComponent(item: OptionItem) {
return (
<div>
{item.type === 'nodes' && <NodeOption id={item.id} />}
{item.type === 'edges' && <EdgeById id={item.id} />}
{item.type === 'message' && <div>{item.message}</div>}
</div>
)
}
/**
* Component thats display the search input.
*/
export const GraphSearchInput = ({
onChange,
onFocus,
value
}: {
onChange: GraphSearchInputProps['onChange']
onFocus?: GraphSearchInputProps['onFocus']
value?: GraphSearchInputProps['value']
}) => {
const { t } = useTranslation()
const graph = useGraphStore.use.sigmaGraph()
const searchEngine = useGraphStore.use.searchEngine()
// Reset search engine when graph changes
useEffect(() => {
if (graph) {
useGraphStore.getState().resetSearchEngine()
}
}, [graph]);
// Create search engine when needed
useEffect(() => {
// Skip if no graph, empty graph, or search engine already exists
if (!graph || graph.nodes().length === 0 || searchEngine) {
return
}
// Create new search engine
const newSearchEngine = new MiniSearch({
idField: 'id',
fields: ['label'],
searchOptions: {
prefix: true,
fuzzy: 0.2,
boost: {
label: 2
}
}
})
// Add nodes to search engine with safety checks
const documents = graph.nodes()
.filter(id => graph.hasNode(id)) // Ensure node exists before accessing attributes
.map((id: string) => ({
id: id,
label: graph.getNodeAttribute(id, 'label')
}))
if (documents.length > 0) {
newSearchEngine.addAll(documents)
}
// Update search engine in store
useGraphStore.getState().setSearchEngine(newSearchEngine)
}, [graph, searchEngine])
/**
* Loading the options while the user is typing.
*/
const loadOptions = useCallback(
async (query?: string): Promise<OptionItem[]> => {
if (onFocus) onFocus(null)
// Safety checks to prevent crashes
if (!graph || !searchEngine) {
return []
}
// Verify graph has nodes before proceeding
if (graph.nodes().length === 0) {
return []
}
// If no query, return some nodes for user to select
if (!query) {
const nodeIds = graph.nodes()
.filter(id => graph.hasNode(id))
.slice(0, searchResultLimit)
return nodeIds.map(id => ({
id,
type: 'nodes'
}))
}
// If has query, search nodes and verify they still exist
let result: OptionItem[] = searchEngine.search(query)
.filter((r: { id: string }) => graph.hasNode(r.id))
.map((r: { id: string }) => ({
id: r.id,
type: 'nodes'
}))
// Add middle-content matching if results are few
// This enables matching content in the middle of text, not just from the beginning
if (result.length < 5) {
// Get already matched IDs to avoid duplicates
const matchedIds = new Set(result.map(item => item.id))
// Perform middle-content matching on all nodes with safety checks
const middleMatchResults = graph.nodes()
.filter(id => {
// Skip already matched nodes
if (matchedIds.has(id)) return false
// Ensure node exists before accessing attributes
if (!graph.hasNode(id)) return false
// Get node label safely
const label = graph.getNodeAttribute(id, 'label')
// Match if label contains query string but doesn't start with it
return label &&
typeof label === 'string' &&
!label.toLowerCase().startsWith(query.toLowerCase()) &&
label.toLowerCase().includes(query.toLowerCase())
})
.map(id => ({
id,
type: 'nodes' as const
}))
// Merge results
result = [...result, ...middleMatchResults]
}
// prettier-ignore
return result.length <= searchResultLimit
? result
: [
...result.slice(0, searchResultLimit),
{
type: 'message',
id: messageId,
message: t('graphPanel.search.message', { count: result.length - searchResultLimit })
}
]
},
[graph, searchEngine, onFocus, t]
)
return (
<AsyncSearch
className="bg-background/60 w-24 rounded-xl border-1 opacity-60 backdrop-blur-lg transition-all hover:w-fit hover:opacity-100 w-full"
fetcher={loadOptions}
renderOption={OptionComponent}
getOptionValue={(item) => item.id}
value={value && value.type !== 'message' ? value.id : null}
onChange={(id) => {
if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null)
}}
onFocus={(id) => {
if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null)
}}
ariaLabel={t('graphPanel.search.placeholder')}
placeholder={t('graphPanel.search.placeholder')}
noResultsMessage={t('graphPanel.search.placeholder')}
/>
)
}
/**
* Component that display the search.
*/
const GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({ ...props }) => {
return <GraphSearchInput {...props} />
}
export default GraphSearch
@@ -0,0 +1,416 @@
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 { useLayoutRandom } from '@react-sigma/layout-random'
import { LayoutHook } from '@react-sigma/layout-core'
import forceAtlas2 from 'graphology-layout-forceatlas2'
import FA2Supervisor from 'graphology-layout-forceatlas2/worker'
import NoverlapSupervisor from 'graphology-layout-noverlap/worker'
import ForceSupervisor from 'graphology-layout-force/worker'
import { useCallback, useMemo, useState, useEffect, useRef } 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, ANIMATE_NODE_LIMIT, workerBudgetMs } from '@/lib/constants'
import { useGraphStore } from '@/stores/graph'
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
type LayoutName =
| 'Circular'
| 'Circlepack'
| 'Random'
| 'Noverlaps'
| 'Force Directed'
| 'Force Atlas'
// The layouts that relax over time and run in a web worker.
type WorkerLayoutName = 'Noverlaps' | 'Force Directed' | 'Force Atlas'
const WORKER_LAYOUTS: ReadonlySet<string> = new Set<WorkerLayoutName>([
'Noverlaps',
'Force Directed',
'Force Atlas'
])
// All three graphology supervisors share this API.
interface LayoutSupervisor {
start: () => void
stop: () => void
kill: () => void
isRunning: () => boolean
}
/**
* Build a graphology layout supervisor bound to `graph`.
*
* IMPORTANT: this binds to the graph that is passed in (the live graph from
* the store). The @react-sigma worker hooks (useWorkerLayoutForceAtlas2 etc.)
* construct their supervisor with `sigma.getGraph()` captured in an effect
* whose dependency list does NOT include the graph, so they stay bound to the
* empty initial graph that exists at mount. The real graph is swapped in later
* via `sigma.setGraph()` (in GraphControl), which does not re-run that effect
* -- so starting those hooks ran the algorithm on a stale empty graph and the
* visible graph never moved. Building the supervisor here, against the current
* graph, is what actually makes these layouts work.
*/
const buildSupervisor = (name: WorkerLayoutName, graph: unknown): LayoutSupervisor | null => {
const order = (graph as { order: number }).order
switch (name) {
case 'Force Atlas':
return new FA2Supervisor(graph as never, {
settings: forceAtlas2.inferSettings(order)
}) as unknown as LayoutSupervisor
case 'Force Directed':
// Tuned for CONTINUOUS worker relaxation (not the old bounded-compute +
// animateNodes tween): higher inertia (more damping) and a much smaller
// maxMove keep the live simulation from overshooting/oscillating. The old
// maxMove: 100 (~100x the node coordinate scale) caused violent bouncing
// when raw simulation ticks were rendered directly.
return new ForceSupervisor(graph as never, {
settings: {
attraction: 0.0003,
repulsion: 0.02,
gravity: 0.02,
inertia: 0.8,
maxMove: 5
}
}) as unknown as LayoutSupervisor
case 'Noverlaps':
// margin bumped 5 -> 10: a larger collision threshold makes Noverlap push
// more "too close" nodes apart, so it visibly de-clutters instead of doing
// almost nothing after the size-aware initial FA2 layout already spread
// the graph.
return new NoverlapSupervisor(graph as never, {
settings: { margin: 10, expansion: 1.1, gridSize: 1, ratio: 1, speed: 3 }
}) as unknown as LayoutSupervisor
default:
return null
}
}
/**
* Play/pause control for the worker-backed layouts.
*
* Self-contained: it builds and owns the supervisor (bound to the live store
* graph), auto-runs when its layout is selected, runs for a size-scaled time
* budget, then stops and re-normalizes the settled coordinates WITHOUT
* touching the camera (the user's rotation/zoom/pan are preserved). The
* previous implementation ran
* the SYNCHRONOUS layout (mainLayout.positions()) every 200ms in a setInterval
* -- for ForceAtlas2 without Barnes-Hut that is O(V^2) on the main thread,
* five times a second.
*/
const WorkerLayoutControl = ({ layoutName }: { layoutName: WorkerLayoutName }) => {
const sigma = useSigma()
const { t } = useTranslation()
// Rebind when the underlying graph is replaced (refresh / new label), so a
// running layout never keeps a supervisor bound to the detached old graph.
const sigmaGraph = useGraphStore.use.sigmaGraph()
const [running, setRunning] = useState(false)
const supervisorRef = useRef<LayoutSupervisor | null>(null)
const stopTimerRef = useRef<number | null>(null)
// The deferred supervisor.start() timer (see start()); tracked so a pause
// within the defer window cancels the pending start instead of being undone.
const startTimerRef = useRef<number | null>(null)
const clearTimer = useCallback(() => {
if (stopTimerRef.current !== null) {
window.clearTimeout(stopTimerRef.current)
stopTimerRef.current = null
}
if (startTimerRef.current !== null) {
window.clearTimeout(startTimerRef.current)
startTimerRef.current = null
}
}, [])
const stop = useCallback(
// settleView=true is used when the layout finishes on its own (budget
// expiry): it releases the custom bbox frozen by node dragging so the
// settled coordinates re-normalize, then refreshes. It deliberately does
// NOT reset the camera — the user's rotation/zoom/pan are preserved (an
// animatedReset here threw the view back to the default every time a
// layout finished). Manual pause passes false and leaves the view alone.
(settleView: boolean) => {
clearTimer()
try {
supervisorRef.current?.stop()
} catch (error) {
console.error('Error stopping layout:', error)
}
setRunning(false)
// Release the shared slot (kills the stopped worker) so
// "activeLayoutSupervisor != null" reliably means a layout is running.
useGraphStore.getState().releaseLayoutSupervisor(supervisorRef.current)
if (settleView) {
try {
sigma.setCustomBBox(null)
sigma.refresh()
} catch (error) {
console.error('Error refreshing after layout:', error)
}
}
},
[clearTimer, sigma]
)
const start = useCallback(() => {
const graph = useGraphStore.getState().sigmaGraph
if (!graph || graph.order === 0) return
// Cancel any pending start/stop timers from a previous cycle before
// (re)building, so a stale budget timer can't stop this fresh run.
clearTimer()
// (Re)build the supervisor bound to the CURRENT graph.
try {
supervisorRef.current?.kill()
} catch {
/* no live supervisor yet */
}
const supervisor = buildSupervisor(layoutName, graph)
supervisorRef.current = supervisor
if (!supervisor) return
// Become the single layout owner. This kills whatever was running before
// (the initial FA2 from GraphControl, or a previously selected worker
// layout) so two supervisors never mutate the same coordinates at once.
useGraphStore.getState().setActiveLayoutSupervisor(supervisor)
// A custom bbox frozen by dragging breaks coordinate normalization and
// makes a relaxing layout look like it collapses; clear it before running.
try {
sigma.setCustomBBox(null)
} catch {
/* ignore */
}
// No blocking overlay here: worker layouts are meant to stay interactive
// while they settle. FA2/Noverlap run in real Web Workers; the play/pause
// button (driven by `running`) is the live indicator, and the user can
// pause at any time. The full-screen overlay is reserved for the
// synchronous switch path in runLayout, which actually blocks the main
// thread. This also matches the initial FA2 layout in GraphControl, which
// deliberately does not set isLayoutComputing.
//
// We still DEFER supervisor.start() so the browser paints the Pause state
// first. Force's runFrame() runs sync on the main thread; rAF won't help
// because rAF callbacks fire BEFORE the next paint, so a small setTimeout
// guarantees a paint lands first. FA2/Noverlap don't strictly need this
// (their start() just posts to a real worker and returns), but the small
// delay is irrelevant for them too.
setRunning(true)
startTimerRef.current = window.setTimeout(() => {
startTimerRef.current = null
// Bail if the user already switched layouts before we fired.
if (supervisorRef.current !== supervisor) return
try {
supervisor.start()
} catch (error) {
console.error('Error starting layout:', error)
setRunning(false)
return
}
stopTimerRef.current = window.setTimeout(() => stop(true), workerBudgetMs(graph.order))
}, 50)
}, [layoutName, sigma, clearTimer, stop])
// Auto-run when this worker layout becomes active; clean up on
// change/unmount. Keyed on layoutName + sigmaGraph: when the graph is
// replaced (refresh / new label) we must tear down the supervisor bound to
// the old graph and rebuild against the new one, otherwise `running` and the
// budget timer keep pointing at a dead supervisor on a detached graph.
// supervisorRef is updated INSIDE start() (which runs after this effect's
// cleanup), so cleanup correctly kills the PREVIOUS supervisor, not the
// incoming one.
useEffect(() => {
// Intentional: mounting this control means the layout was selected, so we
// auto-run it (start() spins up the worker supervisor and flips `running`).
// This is the "synchronize with an external system" case the rule exempts,
// not an accidental render cascade.
// eslint-disable-next-line react-hooks/set-state-in-effect
start()
return () => {
clearTimer()
// Release the slot if we still own it; otherwise just kill our own
// (previous) supervisor. start() for the incoming layout runs AFTER this
// cleanup, so the slot still points at our supervisor here.
useGraphStore.getState().releaseLayoutSupervisor(supervisorRef.current)
supervisorRef.current = null
setRunning(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutName, sigmaGraph])
return (
<Button
size="icon"
onClick={() => (running ? stop(false) : start())}
tooltip={
running
? t('graphPanel.sideBar.layoutsControl.stopAnimation')
: t('graphPanel.sideBar.layoutsControl.startAnimation')
}
variant={controlButtonVariant}
>
{running ? <PauseIcon /> : <PlayIcon />}
</Button>
)
}
/**
* Component that controls the layout of the graph.
*/
const LayoutsControl = () => {
const sigma = useSigma()
const { t } = useTranslation()
const [layout, setLayout] = useState<LayoutName>('Circular')
const [opened, setOpened] = useState<boolean>(false)
// Only the instant, deterministic layouts use the @react-sigma hooks (they
// compute positions synchronously and we assign them). The relaxing layouts
// are handled by WorkerLayoutControl via supervisors.
const layoutCircular = useLayoutCircular()
const layoutCirclepack = useLayoutCirclepack()
const layoutRandom = useLayoutRandom()
const syncLayouts = useMemo(() => {
return {
Circular: layoutCircular,
Circlepack: layoutCirclepack,
Random: layoutRandom
} as Record<string, LayoutHook>
}, [layoutCircular, layoutCirclepack, layoutRandom])
const allLayoutNames: LayoutName[] = useMemo(
() => ['Circular', 'Circlepack', 'Random', 'Noverlaps', 'Force Directed', 'Force Atlas'],
[]
)
const runLayout = useCallback(
(newLayout: LayoutName) => {
console.debug('Running layout:', newLayout)
// Worker layouts: selecting one (re)mounts WorkerLayoutControl, which
// auto-runs it against the live graph. Nothing is computed on the main
// thread here.
if (WORKER_LAYOUTS.has(newLayout)) {
setLayout(newLayout)
return
}
const graph = sigma.getGraph()
if (!graph || graph.order === 0) {
console.error('No graph available')
return
}
// BUGFIX "graph collapses into a single point": node dragging installs
// a custom bounding box that was never cleared, freezing sigma's
// coordinate normalization to the previous layout's extent.
const doLayout = () => {
try {
// Kill any running worker layout (notably the initial FA2, which has
// no WorkerLayoutControl to unmount) before assigning positions, or
// it keeps mutating coordinates and fights this synchronous layout.
useGraphStore.getState().setActiveLayoutSupervisor(null)
const pos = syncLayouts[newLayout].positions()
sigma.setCustomBBox(null)
if (graph.order > ANIMATE_NODE_LIMIT) {
graph.updateEachNodeAttributes(
(node, attr) => {
const p = pos[node]
if (p) {
attr.x = p.x
attr.y = p.y
}
return attr
},
{ attributes: ['x', 'y'] }
)
sigma.refresh()
} else {
animateNodes(graph, pos, { duration: 400 })
}
sigma.getCamera().animatedReset()
setLayout(newLayout)
} catch (error) {
console.error('Error running layout:', error)
}
}
// For large graphs, the assign + refresh blocks the main thread for
// hundreds of ms. Show the loading overlay and defer the heavy work
// one frame so React actually paints the overlay before we block.
// Small graphs animate (400ms) -- the animation itself is feedback.
if (graph.order > ANIMATE_NODE_LIMIT) {
useGraphStore.getState().setIsLayoutComputing(true)
window.requestAnimationFrame(() => {
try {
doLayout()
} finally {
useGraphStore.getState().setIsLayoutComputing(false)
}
})
} else {
doLayout()
}
},
[syncLayouts, sigma]
)
return (
<div>
<div>
{WORKER_LAYOUTS.has(layout) && (
<WorkerLayoutControl layoutName={layout as WorkerLayoutName} />
)}
</div>
<div>
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button
size="icon"
variant={controlButtonVariant}
onClick={() => setOpened((e: boolean) => !e)}
tooltip={t('graphPanel.sideBar.layoutsControl.layoutGraph')}
>
<GripIcon />
</Button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={8}
collisionPadding={5}
sticky="always"
className="min-w-auto p-1"
>
<Command>
<CommandList>
<CommandGroup>
{allLayoutNames.map((name) => (
<CommandItem
onSelect={() => {
runLayout(name)
}}
key={name}
className="cursor-pointer text-xs"
>
{t(`graphPanel.sideBar.layoutsControl.layouts.${name}`)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)
}
export default LayoutsControl
@@ -0,0 +1,41 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useGraphStore } from '@/stores/graph'
import { Card } from '@/components/ui/Card'
import { ScrollArea } from '@/components/ui/ScrollArea'
interface LegendProps {
className?: string
}
const Legend: React.FC<LegendProps> = ({ className }) => {
const { t } = useTranslation()
const typeColorMap = useGraphStore.use.typeColorMap()
if (!typeColorMap || typeColorMap.size === 0) {
return null
}
return (
<Card className={`p-2 max-w-xs ${className}`}>
<h3 className="text-sm font-medium mb-2">{t('graphPanel.legend')}</h3>
<ScrollArea className="max-h-80">
<div className="flex flex-col gap-1">
{Array.from(typeColorMap.entries()).map(([type, color]) => (
<div key={type} className="flex items-center gap-2">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-xs truncate" title={type}>
{t(`graphPanel.nodeTypes.${type.toLowerCase().replace(/\s+/g, '')}`, type)}
</span>
</div>
))}
</div>
</ScrollArea>
</Card>
)
}
export default Legend
@@ -0,0 +1,32 @@
import { useCallback } from 'react'
import { BookOpenIcon } from 'lucide-react'
import Button from '@/components/ui/Button'
import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
/**
* Component that toggles legend visibility.
*/
const LegendButton = () => {
const { t } = useTranslation()
const showLegend = useSettingsStore.use.showLegend()
const setShowLegend = useSettingsStore.use.setShowLegend()
const toggleLegend = useCallback(() => {
setShowLegend(!showLegend)
}, [showLegend, setShowLegend])
return (
<Button
variant={controlButtonVariant}
onClick={toggleLegend}
tooltip={t('graphPanel.sideBar.legendControl.toggleLegend')}
size="icon"
>
<BookOpenIcon />
</Button>
)
}
export default LegendButton
@@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
interface MergeDialogProps {
mergeDialogOpen: boolean
mergeDialogInfo: {
targetEntity: string
sourceEntity: string
} | null
onOpenChange: (open: boolean) => void
onRefresh: (useMergedStart: boolean) => void
}
/**
* MergeDialog component that appears after a successful entity merge
* Allows user to choose whether to use the merged entity or keep current start point
*/
const MergeDialog = ({
mergeDialogOpen,
mergeDialogInfo,
onOpenChange,
onRefresh
}: MergeDialogProps) => {
const { t } = useTranslation()
const currentQueryLabel = useSettingsStore.use.queryLabel()
return (
<Dialog open={mergeDialogOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('graphPanel.propertiesView.mergeDialog.title')}</DialogTitle>
<DialogDescription>
{t('graphPanel.propertiesView.mergeDialog.description', {
source: mergeDialogInfo?.sourceEntity ?? '',
target: mergeDialogInfo?.targetEntity ?? '',
})}
</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('graphPanel.propertiesView.mergeDialog.refreshHint')}
</p>
<DialogFooter className="mt-4 flex-col gap-2 sm:flex-row sm:justify-end">
{currentQueryLabel !== mergeDialogInfo?.sourceEntity && (
<Button
type="button"
variant="outline"
onClick={() => onRefresh(false)}
>
{t('graphPanel.propertiesView.mergeDialog.keepCurrentStart')}
</Button>
)}
<Button type="button" onClick={() => onRefresh(true)}>
{t('graphPanel.propertiesView.mergeDialog.useMergedStart')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default MergeDialog
@@ -0,0 +1,430 @@
import { useMemo } from 'react'
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
import { useBackendState } from '@/stores/state'
import Text from '@/components/ui/Text'
import Button from '@/components/ui/Button'
import useLightragGraph from '@/hooks/useLightragGraph'
import { useTranslation } from 'react-i18next'
import { GitBranchPlus, Scissors, Lock } from 'lucide-react'
import EditablePropertyRow from './EditablePropertyRow'
/**
* Component that view properties of elements in graph.
*/
const PropertiesView = () => {
const { getNode, getEdge } = useLightragGraph()
const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode()
const selectedEdge = useGraphStore.use.selectedEdge()
const focusedEdge = useGraphStore.use.focusedEdge()
const graphDataVersion = useGraphStore.use.graphDataVersion()
const pipelineBusy = useBackendState.use.pipelineBusy()
const { currentElement, currentType } = useMemo(() => {
let type: 'node' | 'edge' | null = null
let element: RawNodeType | RawEdgeType | null = null
if (focusedNode) {
type = 'node'
element = getNode(focusedNode)
} else if (selectedNode) {
type = 'node'
element = getNode(selectedNode)
} else if (focusedEdge) {
type = 'edge'
element = getEdge(focusedEdge, true)
} else if (selectedEdge) {
type = 'edge'
element = getEdge(selectedEdge, true)
}
if (element) {
return {
currentElement: type === 'node'
? refineNodeProperties(element as any)
: refineEdgeProperties(element as any),
currentType: type
}
}
return { currentElement: null, currentType: null }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusedNode, selectedNode, focusedEdge, selectedEdge, graphDataVersion, getNode, getEdge])
if (!currentElement) {
return <></>
}
return (
<div className="bg-background/80 max-w-xs rounded-lg border-2 p-2 text-xs backdrop-blur-lg">
{currentType == 'node' ? (
<NodePropertiesView node={currentElement as any} pipelineBusy={pipelineBusy} />
) : (
<EdgePropertiesView edge={currentElement as any} pipelineBusy={pipelineBusy} />
)}
</div>
)
}
type NodeType = RawNodeType & {
relationships: {
type: string
id: string
label: string
}[]
}
type EdgeType = RawEdgeType & {
sourceNode?: RawNodeType
targetNode?: RawNodeType
}
const refineNodeProperties = (node: RawNodeType): NodeType => {
const state = useGraphStore.getState()
const relationships = []
if (state.sigmaGraph && state.rawGraph) {
try {
if (!state.sigmaGraph.hasNode(node.id)) {
console.warn('Node not found in sigmaGraph:', node.id)
return {
...node,
relationships: []
}
}
const edges = state.sigmaGraph.edges(node.id)
for (const edgeId of edges) {
if (!state.sigmaGraph.hasEdge(edgeId)) continue;
const edge = state.rawGraph.getEdge(edgeId, true)
if (edge) {
const isTarget = node.id === edge.source
const neighbourId = isTarget ? edge.target : edge.source
if (!state.sigmaGraph.hasNode(neighbourId)) continue;
const neighbour = state.rawGraph.getNode(neighbourId)
if (neighbour) {
relationships.push({
type: 'Neighbour',
id: neighbourId,
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
})
}
}
}
} catch (error) {
console.error('Error refining node properties:', error)
}
}
return {
...node,
relationships
}
}
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
const state = useGraphStore.getState()
let sourceNode: RawNodeType | undefined = undefined
let targetNode: RawNodeType | undefined = undefined
if (state.sigmaGraph && state.rawGraph) {
try {
if (!state.sigmaGraph.hasEdge(edge.dynamicId)) {
console.warn('Edge not found in sigmaGraph:', edge.id, 'dynamicId:', edge.dynamicId)
return {
...edge,
sourceNode: undefined,
targetNode: undefined
}
}
if (state.sigmaGraph.hasNode(edge.source)) {
sourceNode = state.rawGraph.getNode(edge.source)
}
if (state.sigmaGraph.hasNode(edge.target)) {
targetNode = state.rawGraph.getNode(edge.target)
}
} catch (error) {
console.error('Error refining edge properties:', error)
}
}
return {
...edge,
sourceNode,
targetNode
}
}
const PropertyRow = ({
name,
value,
onClick,
tooltip,
nodeId,
edgeId,
dynamicId,
entityId,
entityType,
sourceId,
targetId,
isEditable = false,
truncate,
pipelineBusy = false
}: {
name: string
value: any
onClick?: () => void
tooltip?: string
nodeId?: string
entityId?: string
edgeId?: string
dynamicId?: string
entityType?: 'node' | 'edge'
sourceId?: string
targetId?: string
isEditable?: boolean
truncate?: string
pipelineBusy?: boolean
}) => {
const { t } = useTranslation()
const getPropertyNameTranslation = (name: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
const translation = t(translationKey)
return translation === translationKey ? name : translation
}
// Utility function to convert <SEP> to newlines
const formatValueWithSeparators = (value: any): string => {
if (typeof value === 'string') {
return value.replace(/<SEP>/g, ';\n')
}
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
// Format the value to convert <SEP> to newlines
const formattedValue = formatValueWithSeparators(value)
let formattedTooltip = tooltip || formatValueWithSeparators(value)
// If this is source_id field and truncate info exists, append it to the tooltip
if (name === 'source_id' && truncate) {
formattedTooltip += `\n(Truncated: ${truncate})`
}
// Use EditablePropertyRow for editable fields (description, entity_id and entity_type)
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'entity_type' || name === 'keywords')) {
return (
<EditablePropertyRow
name={name}
value={value}
onClick={onClick}
nodeId={nodeId}
entityId={entityId}
edgeId={edgeId}
dynamicId={dynamicId}
entityType={entityType}
sourceId={sourceId}
targetId={targetId}
isEditable={true}
pipelineBusy={pipelineBusy}
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
/>
)
}
// For non-editable fields, use the regular Text component
return (
<div className="flex items-center gap-2">
<span className="text-primary/60 tracking-wide whitespace-nowrap">
{getPropertyNameTranslation(name)}
{name === 'source_id' && truncate && <sup className="text-red-500"></sup>}
</span>:
<Text
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis"
tooltipClassName="max-w-96 -translate-x-13"
text={formattedValue}
tooltip={formattedTooltip}
side="left"
onClick={onClick}
/>
</div>
)
}
const NodePropertiesView = ({ node, pipelineBusy }: { node: NodeType; pipelineBusy: boolean }) => {
const { t } = useTranslation()
const handleExpandNode = () => {
useGraphStore.getState().triggerNodeExpand(node.id)
}
const handlePruneNode = () => {
useGraphStore.getState().triggerNodePrune(node.id)
}
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<h3 className="text-md pl-1 font-bold tracking-wide text-blue-700">{t('graphPanel.propertiesView.node.title')}</h3>
<div className="flex gap-3">
{pipelineBusy && (
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t('graphPanel.propertiesView.editLockedByPipeline')}
aria-disabled="true"
className="h-7 w-7 border border-amber-400 hover:bg-amber-50 dark:border-amber-600 dark:hover:bg-amber-900/40 !cursor-default"
tooltip={t('graphPanel.propertiesView.editLockedByPipeline')}
onClick={(e) => e.preventDefault()}
>
<Lock className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
onClick={handleExpandNode}
tooltip={t('graphPanel.propertiesView.node.expandNode')}
>
<GitBranchPlus className="h-4 w-4 text-gray-700 dark:text-gray-300" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
onClick={handlePruneNode}
tooltip={t('graphPanel.propertiesView.node.pruneNode')}
>
<Scissors className="h-4 w-4 text-gray-900 dark:text-gray-300" />
</Button>
</div>
</div>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={t('graphPanel.propertiesView.node.id')} value={String(node.id)} />
<PropertyRow
name={t('graphPanel.propertiesView.node.labels')}
value={node.labels.join(', ')}
onClick={() => {
useGraphStore.getState().setSelectedNode(node.id, true)
}}
/>
<PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
</div>
<h3 className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.node.properties')}</h3>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(node.properties)
.sort()
.map((name) => {
if (name === 'created_at' || name === 'truncate') return null; // Hide created_at and truncate properties
return (
<PropertyRow
key={name}
name={name}
value={node.properties[name]}
nodeId={String(node.id)}
entityId={node.properties['entity_id']}
entityType="node"
isEditable={name === 'description' || name === 'entity_id' || name === 'entity_type'}
truncate={node.properties['truncate']}
pipelineBusy={pipelineBusy}
/>
)
})}
</div>
{node.relationships.length > 0 && (
<>
<h3 className="text-md pl-1 font-bold tracking-wide text-emerald-700">
{t('graphPanel.propertiesView.node.relationships')}
</h3>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{node.relationships.map(({ type, id, label }) => {
return (
<PropertyRow
key={id}
name={type}
value={label}
onClick={() => {
useGraphStore.getState().setSelectedNode(id, true)
}}
/>
)
})}
</div>
</>
)}
</div>
)
}
const EdgePropertiesView = ({ edge, pipelineBusy }: { edge: EdgeType; pipelineBusy: boolean }) => {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<h3 className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</h3>
{pipelineBusy && (
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t('graphPanel.propertiesView.editLockedByPipeline')}
aria-disabled="true"
className="h-7 w-7 border border-amber-400 hover:bg-amber-50 dark:border-amber-600 dark:hover:bg-amber-900/40 !cursor-default"
tooltip={t('graphPanel.propertiesView.editLockedByPipeline')}
onClick={(e) => e.preventDefault()}
>
<Lock className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</Button>
)}
</div>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
<PropertyRow
name={t('graphPanel.propertiesView.edge.source')}
value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source}
onClick={() => {
useGraphStore.getState().setSelectedNode(edge.source, true)
}}
/>
<PropertyRow
name={t('graphPanel.propertiesView.edge.target')}
value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target}
onClick={() => {
useGraphStore.getState().setSelectedNode(edge.target, true)
}}
/>
</div>
<h3 className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.edge.properties')}</h3>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(edge.properties)
.sort()
.map((name) => {
if (name === 'created_at' || name === 'truncate') return null; // Hide created_at and truncate properties
return (
<PropertyRow
key={name}
name={name}
value={edge.properties[name]}
edgeId={String(edge.id)}
dynamicId={String(edge.dynamicId)}
entityType="edge"
sourceId={edge.sourceNode?.properties['entity_id'] || edge.source}
targetId={edge.targetNode?.properties['entity_id'] || edge.target}
isEditable={name === 'description' || name === 'keywords'}
truncate={edge.properties['truncate']}
pipelineBusy={pipelineBusy}
/>
)
})}
</div>
</div>
)
}
export default PropertiesView
@@ -0,0 +1,199 @@
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
interface PropertyEditDialogProps {
isOpen: boolean
onClose: () => void
onSave: () => void | Promise<void>
propertyName: string
value: string
allowMerge: boolean
onValueChange: (value: string) => void
onAllowMergeChange: (allowMerge: boolean) => void
isSubmitting?: boolean
errorMessage?: string | null
disableSave?: boolean
}
/**
* Dialog component for editing property values
* Provides a modal with a title, multi-line text input, and save/cancel buttons
*/
const PropertyEditDialog = ({
isOpen,
onClose,
onSave,
propertyName,
value,
allowMerge,
onValueChange,
onAllowMergeChange,
isSubmitting = false,
errorMessage = null,
disableSave = false
}: PropertyEditDialogProps) => {
const { t } = useTranslation()
// Get translated property name
const getPropertyNameTranslation = (name: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
const translation = t(translationKey)
return translation === translationKey ? name : translation
}
// Get textarea configuration based on property name
const getTextareaConfig = (propertyName: string) => {
switch (propertyName) {
case 'description':
return {
// No rows attribute for description to allow auto-sizing
className: 'max-h-[50vh] min-h-[10em] resize-y', // Maximum height 70% of viewport, minimum height ~20 lines, allow vertical resizing
style: {
height: '70vh', // Set initial height to 70% of viewport
minHeight: '20em', // Minimum height ~20 lines
resize: 'vertical' as const // Allow vertical resizing, using 'as const' to fix type
}
};
case 'entity_id':
return {
rows: 2,
className: '',
style: {}
};
case 'keywords':
return {
rows: 4,
className: '',
style: {}
};
default:
return {
rows: 5,
className: '',
style: {}
};
}
};
const handleSave = async () => {
const trimmedValue = value.trim()
if (trimmedValue !== '') {
await onSave()
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t('graphPanel.propertiesView.editProperty', {
property: getPropertyNameTranslation(propertyName)
})}
</DialogTitle>
<DialogDescription>
{t('graphPanel.propertiesView.editPropertyDescription')}
</DialogDescription>
</DialogHeader>
{/* Pipeline busy banner: editing kept open with draft preserved, but save disabled */}
{disableSave && (
<div className="bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 border border-amber-400/60 px-3 py-2 rounded-md text-sm">
{t('graphPanel.propertiesView.editLockedByPipeline')}
</div>
)}
{/* Display error message if save fails */}
{errorMessage && (
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm">
{errorMessage}
</div>
)}
{/* Multi-line text input using textarea */}
<div className="grid gap-4 py-4">
{(() => {
const config = getTextareaConfig(propertyName);
return propertyName === 'description' ? (
<textarea
value={value}
onChange={(e) => onValueChange(e.target.value)}
className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}
style={config.style}
disabled={isSubmitting}
/>
) : (
<textarea
value={value}
onChange={(e) => onValueChange(e.target.value)}
rows={config.rows}
className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}
disabled={isSubmitting}
/>
);
})()}
</div>
{propertyName === 'entity_id' && (
<div className="rounded-md border border-border bg-muted/20 p-3">
<label className="flex items-start gap-2 text-sm font-medium">
<Checkbox
id="allow-merge"
checked={allowMerge}
disabled={isSubmitting}
onCheckedChange={(checked) => onAllowMergeChange(checked === true)}
/>
<div>
<span>{t('graphPanel.propertiesView.mergeOptionLabel')}</span>
<p className="text-xs font-normal text-muted-foreground">
{t('graphPanel.propertiesView.mergeOptionDescription')}
</p>
</div>
</label>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
{t('common.cancel')}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isSubmitting || disableSave}
>
{isSubmitting ? (
<>
<span className="mr-2">
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
{t('common.saving')}
</>
) : (
t('common.save')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default PropertyEditDialog
@@ -0,0 +1,55 @@
import { PencilIcon } from 'lucide-react'
import Text from '@/components/ui/Text'
import { useTranslation } from 'react-i18next'
interface PropertyNameProps {
name: string
}
export const PropertyName = ({ name }: PropertyNameProps) => {
const { t } = useTranslation()
const getPropertyNameTranslation = (propName: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${propName}`
const translation = t(translationKey)
return translation === translationKey ? propName : translation
}
return (
<span className="text-primary/60 tracking-wide whitespace-nowrap">
{getPropertyNameTranslation(name)}
</span>
)
}
interface EditIconProps {
onClick: () => void
}
export const EditIcon = ({ onClick }: EditIconProps) => (
<div>
<PencilIcon
className="h-3 w-3 text-gray-500 hover:text-gray-700 cursor-pointer"
onClick={onClick}
/>
</div>
)
interface PropertyValueProps {
value: any
onClick?: () => void
tooltip?: string
}
export const PropertyValue = ({ value, onClick, tooltip }: PropertyValueProps) => (
<div className="flex items-center gap-1 overflow-hidden">
<Text
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis whitespace-nowrap"
tooltipClassName="max-w-80 -translate-x-15"
text={value}
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
side="left"
onClick={onClick}
/>
</div>
)
@@ -0,0 +1,448 @@
import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import Checkbox from '@/components/ui/Checkbox'
import Button from '@/components/ui/Button'
import Separator from '@/components/ui/Separator'
import Input from '@/components/ui/Input'
import { controlButtonVariant, EDGE_PERF_LIMIT } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import useRandomGraph from '@/hooks/useRandomGraph'
import { SettingsIcon, Undo2, Shuffle } from 'lucide-react'
import { useTranslation } from 'react-i18next';
/**
* Component that displays a checkbox with a label.
*/
const LabeledCheckBox = ({
checked,
onCheckedChange,
label,
disabled,
title
}: {
checked: boolean
onCheckedChange: () => void
label: string
disabled?: boolean
title?: string
}) => {
// Create unique ID using the label text converted to lowercase with spaces removed
const id = `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;
// The label's peer-disabled:* classes don't fire — Checkbox carries no `peer`
// class — so grey the WHOLE row explicitly when disabled, not just the box.
return (
<div
className={`flex items-center gap-2${disabled ? ' cursor-not-allowed opacity-50' : ''}`}
title={title}
>
<Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
<label
htmlFor={id}
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
</div>
)
}
/**
* Component that displays a number input with a label.
*/
const LabeledNumberInput = ({
value,
onEditFinished,
label,
min,
max,
defaultValue
}: {
value: number
onEditFinished: (value: number) => void
label: string
min: number
max?: number
defaultValue?: number
}) => {
const { t } = useTranslation();
const [currentValue, setCurrentValue] = useState<number | null>(value)
// Create unique ID using the label text converted to lowercase with spaces removed
const id = `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
// Keep refs in sync so the unmount effect can read the latest values
const currentValueRef = useRef(currentValue)
const valueRef = useRef(value)
const onEditFinishedRef = useRef(onEditFinished)
useLayoutEffect(() => {
currentValueRef.current = currentValue
valueRef.current = value
onEditFinishedRef.current = onEditFinished
})
// Sync local state when controlled value changes (render-time comparison
// avoids cascading renders flagged by react-hooks/set-state-in-effect).
const [previousValue, setPreviousValue] = useState(value)
if (value !== previousValue) {
setPreviousValue(value)
setCurrentValue(value)
}
// Commit any pending change when the component unmounts (e.g. popover closes)
useEffect(() => {
return () => {
const cur = currentValueRef.current
if (cur !== null && cur !== valueRef.current) {
onEditFinishedRef.current(cur)
}
}
}, [])
const onValueChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value.trim()
if (text.length === 0) {
setCurrentValue(null)
return
}
const newValue = Number.parseInt(text)
if (!isNaN(newValue) && newValue !== currentValue) {
if (min !== undefined && newValue < min) {
return
}
if (max !== undefined && newValue > max) {
return
}
setCurrentValue(newValue)
}
},
[currentValue, min, max]
)
const onBlur = useCallback(() => {
if (currentValue !== null && value !== currentValue) {
onEditFinished(currentValue)
}
}, [value, currentValue, onEditFinished])
const handleReset = useCallback(() => {
if (defaultValue !== undefined && value !== defaultValue) {
setCurrentValue(defaultValue)
onEditFinished(defaultValue)
}
}, [defaultValue, value, onEditFinished])
return (
<div className="flex flex-col gap-2">
<label
htmlFor={id}
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
<div className="flex items-center gap-1">
<Input
id={id}
type="number"
value={currentValue === null ? '' : currentValue}
onChange={onValueChange}
className="h-6 w-full min-w-0 pr-1"
min={min}
max={max}
onBlur={onBlur}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onBlur()
}
}}
/>
{defaultValue !== undefined && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleReset}
type="button"
title={t('graphPanel.sideBar.settings.resetToDefault')}
>
<Undo2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)
}
/**
* Component that displays a popover with settings options.
*/
export default function Settings() {
const [opened, setOpened] = useState<boolean>(false)
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
const showNodeLabel = useSettingsStore.use.showNodeLabel()
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
const showEdgeLabel = useSettingsStore.use.showEdgeLabel()
const minEdgeSize = useSettingsStore.use.minEdgeSize()
const maxEdgeSize = useSettingsStore.use.maxEdgeSize()
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
const graphMaxNodes = useSettingsStore.use.graphMaxNodes()
const backendMaxGraphNodes = useSettingsStore.use.backendMaxGraphNodes()
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
// Random graph functionality for development/testing
const { randomGraph } = useRandomGraph()
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
})),
[]
)
//
const setShowPropertyPanel = useCallback(
() => useSettingsStore.setState((pre) => ({ showPropertyPanel: !pre.showPropertyPanel })),
[]
)
const setShowNodeSearchBar = useCallback(
() => useSettingsStore.setState((pre) => ({ showNodeSearchBar: !pre.showNodeSearchBar })),
[]
)
const setShowNodeLabel = useCallback(
() => useSettingsStore.setState((pre) => ({ showNodeLabel: !pre.showNodeLabel })),
[]
)
const setEnableHealthCheck = useCallback(
() => useSettingsStore.setState((pre) => ({ enableHealthCheck: !pre.enableHealthCheck })),
[]
)
const setGraphQueryMaxDepth = useCallback((depth: number) => {
if (depth < 1) return
useSettingsStore.setState({ graphQueryMaxDepth: depth })
useGraphStore.getState().setGraphDataFetchAttempted(false)
}, [])
const setGraphMaxNodes = useCallback((nodes: number) => {
const maxLimit = backendMaxGraphNodes || 1000
if (nodes < 1 || nodes > maxLimit) return
useSettingsStore.getState().setGraphMaxNodes(nodes, true)
}, [backendMaxGraphNodes])
const handleGenerateRandomGraph = useCallback(() => {
const graph = randomGraph()
useGraphStore.getState().setSigmaGraph(graph)
}, [randomGraph])
const { t } = useTranslation();
const saveSettings = () => setOpened(false);
return (
<>
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button
variant={controlButtonVariant}
tooltip={t('graphPanel.sideBar.settings.settings')}
size="icon"
>
<SettingsIcon />
</Button>
</PopoverTrigger>
<PopoverContent
side="right"
align="end"
sideOffset={8}
collisionPadding={5}
className="p-2 max-w-[200px]"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2">
<LabeledCheckBox
checked={enableHealthCheck}
onCheckedChange={setEnableHealthCheck}
label={t('graphPanel.sideBar.settings.healthCheck')}
/>
<Separator />
<LabeledCheckBox
checked={showPropertyPanel}
onCheckedChange={setShowPropertyPanel}
label={t('graphPanel.sideBar.settings.showPropertyPanel')}
/>
<LabeledCheckBox
checked={showNodeSearchBar}
onCheckedChange={setShowNodeSearchBar}
label={t('graphPanel.sideBar.settings.showSearchBar')}
/>
<Separator />
<LabeledCheckBox
checked={showNodeLabel}
onCheckedChange={setShowNodeLabel}
label={t('graphPanel.sideBar.settings.showNodeLabel')}
/>
<LabeledCheckBox
checked={enableNodeDrag}
onCheckedChange={setEnableNodeDrag}
label={t('graphPanel.sideBar.settings.nodeDraggable')}
/>
<Separator />
<LabeledCheckBox
checked={showEdgeLabel}
onCheckedChange={setShowEdgeLabel}
label={t('graphPanel.sideBar.settings.showEdgeLabel')}
/>
<LabeledCheckBox
checked={enableHideUnselectedEdges}
onCheckedChange={setEnableHideUnselectedEdges}
label={t('graphPanel.sideBar.settings.hideUnselectedEdges')}
/>
<LabeledCheckBox
checked={enableEdgeEvents}
onCheckedChange={setEnableEdgeEvents}
label={t('graphPanel.sideBar.settings.edgeEvents')}
disabled={graphEdgeCount > EDGE_PERF_LIMIT}
title={
graphEdgeCount > EDGE_PERF_LIMIT
? t('graphPanel.sideBar.settings.edgeEventsDisabledHint', { count: EDGE_PERF_LIMIT })
: undefined
}
/>
<div className="flex flex-col gap-2">
<label htmlFor="edge-size-min" className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t('graphPanel.sideBar.settings.edgeSizeRange')}
</label>
<div className="flex items-center gap-2">
<Input
id="edge-size-min"
type="number"
value={minEdgeSize}
onChange={(e) => {
const newValue = Number(e.target.value);
if (!isNaN(newValue) && newValue >= 1 && newValue <= maxEdgeSize) {
useSettingsStore.setState({ minEdgeSize: newValue });
}
}}
className="h-6 w-16 min-w-0 pr-1"
min={1}
max={Math.min(maxEdgeSize, 10)}
/>
<span>-</span>
<div className="flex items-center gap-1">
<Input
id="edge-size-max"
type="number"
value={maxEdgeSize}
onChange={(e) => {
const newValue = Number(e.target.value);
if (!isNaN(newValue) && newValue >= minEdgeSize && newValue >= 1 && newValue <= 10) {
useSettingsStore.setState({ maxEdgeSize: newValue });
}
}}
className="h-6 w-16 min-w-0 pr-1"
min={minEdgeSize}
max={10}
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={() => useSettingsStore.setState({ minEdgeSize: 1, maxEdgeSize: 5 })}
type="button"
title={t('graphPanel.sideBar.settings.resetToDefault')}
>
<Undo2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<Separator />
<LabeledNumberInput
label={t('graphPanel.sideBar.settings.maxQueryDepth')}
min={1}
value={graphQueryMaxDepth}
defaultValue={3}
onEditFinished={setGraphQueryMaxDepth}
/>
<LabeledNumberInput
label={`${t('graphPanel.sideBar.settings.maxNodes')} (≤ ${backendMaxGraphNodes || 1000})`}
min={1}
max={backendMaxGraphNodes || 1000}
value={graphMaxNodes}
defaultValue={backendMaxGraphNodes || 1000}
onEditFinished={setGraphMaxNodes}
/>
{/* Development/Testing Section - Only visible in development mode */}
{import.meta.env.DEV && (
<>
<Separator />
<div className="flex flex-col gap-2">
<label className="text-sm leading-none font-medium text-muted-foreground">
Dev Options
</label>
<Button
onClick={handleGenerateRandomGraph}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Shuffle className="h-3.5 w-3.5" />
Gen Random Graph
</Button>
</div>
<Separator />
</>
)}
<Button
onClick={saveSettings}
variant="outline"
size="sm"
className="ml-auto px-4"
>
{t('graphPanel.sideBar.settings.save')}
</Button>
</div>
</PopoverContent>
</Popover>
</>
)
}
@@ -0,0 +1,27 @@
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import { useTranslation } from 'react-i18next'
/**
* Component that displays current values of important graph settings
* Positioned to the right of the toolbar at the bottom-left corner
*/
const SettingsDisplay = () => {
const { t } = useTranslation()
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
// Live counts of the rendered graph (reactive: updated on build/expand/prune).
// Reading sigmaGraph.order/.size directly would not re-render, since
// expand/prune mutate the graph in place.
const graphNodeCount = useGraphStore.use.graphNodeCount()
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
return (
<div className="absolute bottom-4 left-[calc(1rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
<div>{t('graphPanel.sideBar.settings.node')}: {graphNodeCount}</div>
<div>{t('graphPanel.sideBar.settings.edge')}: {graphEdgeCount}</div>
</div>
)
}
export default SettingsDisplay
@@ -0,0 +1,109 @@
import { useCamera, useSigma } from '@react-sigma/core'
import { useCallback } from 'react'
import Button from '@/components/ui/Button'
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon, RotateCwIcon, RotateCcwIcon } from 'lucide-react'
import { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from 'react-i18next';
/**
* Component that provides zoom controls for the graph viewer.
*/
const ZoomControl = () => {
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
const sigma = useSigma()
const { t } = useTranslation();
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
const handleResetZoom = useCallback(() => {
if (!sigma) return
try {
// First clear any custom bounding box and refresh
sigma.setCustomBBox(null)
sigma.refresh()
// Get graph after refresh
const graph = sigma.getGraph()
// Check if graph has nodes before accessing them
if (!graph?.order || graph.nodes().length === 0) {
// Use reset() for empty graph case
reset()
return
}
sigma.getCamera().animate(
{ x: 0.5, y: 0.5, ratio: 1.1 },
{ duration: 1000 }
)
} catch (error) {
console.error('Error resetting zoom:', error)
// Use reset() as fallback on error
reset()
}
}, [sigma, reset])
const handleRotate = useCallback(() => {
if (!sigma) return
const camera = sigma.getCamera()
const currentAngle = camera.angle
const newAngle = currentAngle + Math.PI / 8
camera.animate(
{ angle: newAngle },
{ duration: 200 }
)
}, [sigma])
const handleRotateCounterClockwise = useCallback(() => {
if (!sigma) return
const camera = sigma.getCamera()
const currentAngle = camera.angle
const newAngle = currentAngle - Math.PI / 8
camera.animate(
{ angle: newAngle },
{ duration: 200 }
)
}, [sigma])
return (
<>
<Button
variant={controlButtonVariant}
onClick={handleRotate}
tooltip={t('graphPanel.sideBar.zoomControl.rotateCamera')}
size="icon"
>
<RotateCwIcon />
</Button>
<Button
variant={controlButtonVariant}
onClick={handleRotateCounterClockwise}
tooltip={t('graphPanel.sideBar.zoomControl.rotateCameraCounterClockwise')}
size="icon"
>
<RotateCcwIcon />
</Button>
<Button
variant={controlButtonVariant}
onClick={handleResetZoom}
tooltip={t('graphPanel.sideBar.zoomControl.resetZoom')}
size="icon"
>
<FullscreenIcon />
</Button>
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t('graphPanel.sideBar.zoomControl.zoomIn')} size="icon">
<ZoomInIcon />
</Button>
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t('graphPanel.sideBar.zoomControl.zoomOut')} size="icon">
<ZoomOutIcon />
</Button>
</>
)
}
export default ZoomControl
@@ -0,0 +1,9 @@
import type { SVGProps } from 'react'
export default function GithubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.4 3-.405 1.02.005 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}
@@ -0,0 +1,524 @@
import { ReactNode, useEffect, useMemo, useRef, memo, useState } from 'react' // Import useMemo
import { Message } from '@/api/lightrag'
import useTheme from '@/hooks/useTheme'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeReact from 'rehype-react'
import rehypeRaw from 'rehype-raw'
import remarkMath from 'remark-math'
import mermaid from 'mermaid'
import { remarkFootnotes } from '@/utils/remarkFootnotes'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import { LoaderIcon, ChevronDownIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
// KaTeX configuration options interface
interface KaTeXOptions {
errorColor?: string;
throwOnError?: boolean;
displayMode?: boolean;
strict?: boolean;
trust?: boolean;
errorCallback?: (error: string, latex: string) => void;
}
export type MessageWithError = Message & {
id: string // Unique identifier for stable React keys
isError?: boolean
isThinking?: boolean // Flag to indicate if the message is in a "thinking" state
isAborted?: boolean // Flag to indicate the user terminated this query (response may be incomplete)
/**
* Indicates if the mermaid diagram in this message has been rendered.
* Used to persist the rendering state across updates and prevent flickering.
*/
mermaidRendered?: boolean
/**
* Indicates if the LaTeX formulas in this message are complete and ready for rendering.
* Used to prevent red error text during streaming of incomplete LaTeX formulas.
*/
latexRendered?: boolean
}
// Restore original component definition and export
export const ChatMessage = ({
message,
isTabActive = true
}: {
message: MessageWithError
isTabActive?: boolean
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
const [katexPlugin, setKatexPlugin] = useState<((options?: KaTeXOptions) => any) | null>(null)
const [isThinkingExpanded, setIsThinkingExpanded] = useState<boolean>(false)
// Directly use props passed from the parent.
const { thinkingContent, displayContent, thinkingTime, isThinking } = message
// Reset expansion state when new thinking starts.
// Render-time comparison avoids cascading renders from setState-in-useEffect.
const [previousThinkingState, setPreviousThinkingState] = useState({
isThinking,
messageId: message.id
})
if (
previousThinkingState.isThinking !== isThinking ||
previousThinkingState.messageId !== message.id
) {
setPreviousThinkingState({ isThinking, messageId: message.id })
if (isThinking) {
setIsThinkingExpanded(false)
}
}
// The content to display is now non-ambiguous.
const finalThinkingContent = thinkingContent
// For user messages, displayContent will be undefined, so we fall back to content.
// For assistant messages, we prefer displayContent but fallback to content for backward compatibility
const finalDisplayContent = message.role === 'user'
? message.content
: (displayContent !== undefined ? displayContent : (message.content || ''))
// Load KaTeX rehype plugin dynamically
// Note: KaTeX extensions (mhchem, copy-tex) are imported statically in main.tsx
useEffect(() => {
const loadKaTeX = async () => {
try {
const { default: rehypeKatex } = await import('rehype-katex');
setKatexPlugin(() => rehypeKatex);
} catch (error) {
console.error('Failed to load KaTeX plugin:', error);
setKatexPlugin(null);
}
};
loadKaTeX();
}, []);
const mainMarkdownComponents = useMemo(() => ({
code: (props: any) => {
const { inline, className, children, ...restProps } = props;
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : undefined;
// Handle math blocks ($$...$$) - provide better container and styling
if (language === 'math' && !inline) {
return (
<div className="katex-display-wrapper my-4 overflow-x-auto">
<div className="text-current">{children}</div>
</div>
);
}
// Handle inline math ($...$) - ensure proper inline display
if (language === 'math' && inline) {
return (
<span className="katex-inline-wrapper">
<span className="text-current">{children}</span>
</span>
);
}
// Handle all other code (inline and block)
return (
<CodeHighlight
inline={inline}
className={className}
{...restProps}
renderAsDiagram={message.mermaidRendered ?? false}
messageRole={message.role}
>
{children}
</CodeHighlight>
);
},
p: ({ children }: { children?: ReactNode }) => <div className="my-2">{children}</div>,
h1: ({ children }: { children?: ReactNode }) => <h1 className="text-xl font-bold mt-4 mb-2">{children}</h1>,
h2: ({ children }: { children?: ReactNode }) => <h2 className="text-lg font-bold mt-4 mb-2">{children}</h2>,
h3: ({ children }: { children?: ReactNode }) => <h3 className="text-base font-bold mt-3 mb-2">{children}</h3>,
h4: ({ children }: { children?: ReactNode }) => <h4 className="text-base font-semibold mt-3 mb-2">{children}</h4>,
ul: ({ children }: { children?: ReactNode }) => <ul className="list-disc pl-5 my-2">{children}</ul>,
ol: ({ children }: { children?: ReactNode }) => <ol className="list-decimal pl-5 my-2">{children}</ol>,
li: ({ children }: { children?: ReactNode }) => <li className="my-1">{children}</li>
}), [message.mermaidRendered, message.role]);
const thinkingMarkdownComponents = useMemo(() => ({
code: (props: any) => (<CodeHighlight {...props} renderAsDiagram={message.mermaidRendered ?? false} messageRole={message.role} />)
}), [message.mermaidRendered, message.role]);
return (
<div
className={`${
message.role === 'user'
? 'max-w-[80%] bg-primary text-primary-foreground'
: message.isError
? 'w-[95%] bg-red-100 text-red-600 dark:bg-red-950 dark:text-red-400'
: 'w-[95%] bg-muted'
} rounded-lg px-4 py-2`}
>
{/* Thinking process display - only for assistant messages */}
{/* Always render to prevent layout shift when switching tabs */}
{message.role === 'assistant' && (isThinking || thinkingTime !== null) && (
<div className={cn(
'mb-2',
// Reduce visual priority in inactive tabs while maintaining layout
!isTabActive && 'opacity-50'
)}>
<div
className="flex items-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-200 text-sm cursor-pointer select-none"
onClick={() => {
// Allow expansion when there's thinking content, even during thinking process
if (finalThinkingContent && finalThinkingContent.trim() !== '') {
setIsThinkingExpanded(!isThinkingExpanded)
}
}}
>
{isThinking ? (
<>
{/* Only show spinner animation in active tab to save resources */}
{isTabActive && <LoaderIcon className="mr-2 size-4 animate-spin" />}
<span>{t('retrievePanel.chatMessage.thinking')}</span>
</>
) : (
typeof thinkingTime === 'number' && <span>{t('retrievePanel.chatMessage.thinkingTime', { time: thinkingTime })}</span>
)}
{/* Show chevron when there's thinking content, even during thinking process */}
{finalThinkingContent && finalThinkingContent.trim() !== '' && <ChevronDownIcon className={`ml-2 size-4 shrink-0 transition-transform ${isThinkingExpanded ? 'rotate-180' : ''}`} />}
</div>
{/* Show thinking content when expanded and content exists, even during thinking process */}
{isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && (
<div className="mt-2 pl-4 border-l-2 border-primary/20 dark:border-primary/40 text-sm prose dark:prose-invert max-w-none break-words prose-p:my-1 prose-headings:my-2 [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-6 [&_.footnotes]:pt-3 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes_ol]:text-xs [&_.footnotes_li]:my-0.5 [&_a[href^='#fn']]:text-primary [&_a[href^='#fn']]:no-underline [&_a[href^='#fn']]:hover:underline [&_a[href^='#fnref']]:text-primary [&_a[href^='#fnref']]:no-underline [&_a[href^='#fnref']]:hover:underline text-foreground">
{isThinking && (
<div className="mb-2 text-xs text-gray-400 dark:text-gray-300 italic">
{t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}
</div>
)}
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}
rehypePlugins={[
rehypeRaw,
...((katexPlugin && (message.latexRendered ?? true)) ? [[katexPlugin, {
errorColor: theme === 'dark' ? '#ef4444' : '#dc2626',
throwOnError: false,
displayMode: false,
strict: false,
trust: true,
// Add silent error handling to avoid console noise
errorCallback: (error: string, latex: string) => {
// Only show detailed errors in development environment
if (process.env.NODE_ENV === 'development') {
console.warn('KaTeX rendering error in thinking content:', error, 'for LaTeX:', latex);
}
}
}] as any] : []),
rehypeReact
]}
skipHtml={false}
components={thinkingMarkdownComponents}
>
{finalThinkingContent}
</ReactMarkdown>
</div>
)}
</div>
)}
{/* Main content display */}
{finalDisplayContent && (
<div className="relative">
<div className={`prose dark:prose-invert max-w-none text-sm break-words prose-headings:mt-4 prose-headings:mb-2 prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 [&_.katex]:text-current [&_.katex-display]:my-4 [&_.katex-display]:max-w-full [&_.katex-display_>.base]:overflow-x-auto [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-8 [&_.footnotes]:pt-4 [&_.footnotes]:border-t [&_.footnotes_ol]:text-sm [&_.footnotes_li]:my-1 ${
message.role === 'user' ? 'text-primary-foreground' : 'text-foreground'
} ${
message.role === 'user'
? '[&_.footnotes]:border-primary-foreground/30 [&_a[href^="#fn"]]:text-primary-foreground [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary-foreground [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline'
: '[&_.footnotes]:border-border [&_a[href^="#fn"]]:text-primary [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline'
}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}
rehypePlugins={[
rehypeRaw,
...((katexPlugin && (message.latexRendered ?? true)) ? [[
katexPlugin,
{
errorColor: theme === 'dark' ? '#ef4444' : '#dc2626',
throwOnError: false,
displayMode: false,
strict: false,
trust: true,
// Add silent error handling to avoid console noise
errorCallback: (error: string, latex: string) => {
// Only show detailed errors in development environment
if (process.env.NODE_ENV === 'development') {
console.warn('KaTeX rendering error in main content:', error, 'for LaTeX:', latex);
}
}
}
] as any] : []),
rehypeReact
]}
skipHtml={false}
components={mainMarkdownComponents}
>
{finalDisplayContent}
</ReactMarkdown>
</div>
</div>
)}
{/* User-terminated hint - response may be incomplete */}
{message.isAborted && (
<div className="mt-1 text-xs italic text-muted-foreground">
{t('retrievePanel.retrieval.userTerminated')}
</div>
)}
{/* Loading indicator - only show in active tab */}
{isTabActive && !message.isAborted && (() => {
// More comprehensive loading state check
const hasVisibleContent = finalDisplayContent && finalDisplayContent.trim() !== '';
const isLoadingState = !hasVisibleContent && !isThinking && !thinkingTime;
return isLoadingState && <LoaderIcon className="animate-spin duration-2000" />
})()}
</div>
)
}
// Remove the incorrect memo export line
interface CodeHighlightProps {
inline?: boolean
className?: string
children?: ReactNode
renderAsDiagram?: boolean // Flag to indicate if rendering as diagram should be attempted
messageRole?: 'user' | 'assistant' // Message role for context-aware styling
}
// Check if it is a large JSON
const isLargeJson = (language: string | undefined, content: string | undefined): boolean => {
if (!content || language !== 'json') return false;
return content.length > 5000; // JSON larger than 5KB is considered large JSON
};
// Memoize the CodeHighlight component
const CodeHighlight = memo(({ inline, className, children, renderAsDiagram = false, messageRole, ...props }: CodeHighlightProps) => {
const { theme } = useTheme();
const [hasRendered, setHasRendered] = useState(false); // State to track successful render
const match = className?.match(/language-(\w+)/);
const language = match ? match[1] : undefined;
const mermaidRef = useRef<HTMLDivElement>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // Use ReturnType for better typing
// Get the content string, check if it is a large JSON
const contentStr = String(children || '').replace(/\n$/, '');
const isLargeJsonBlock = isLargeJson(language, contentStr);
// Handle Mermaid rendering with debounce
useEffect(() => {
// Effect should run when renderAsDiagram becomes true or hasRendered changes.
// The actual rendering logic inside checks language and hasRendered state.
if (renderAsDiagram && !hasRendered && language === 'mermaid' && mermaidRef.current) {
const container = mermaidRef.current; // Capture ref value
// Clear previous timer if dependencies change before timeout (e.g., renderAsDiagram flips quickly)
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
if (!container) return; // Container might have unmounted
// Double check hasRendered state inside timeout, in case it changed rapidly
if (hasRendered) return;
try {
// Initialize mermaid config
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
suppressErrorRendering: true,
});
// Show loading indicator
container.innerHTML = '<div class="flex justify-center items-center p-4"><svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>';
// Preprocess mermaid content
const rawContent = String(children).replace(/\n$/, '').trim();
// Heuristic check for potentially complete graph definition
const looksPotentiallyComplete = rawContent.length > 10 && (
rawContent.startsWith('graph') ||
rawContent.startsWith('sequenceDiagram') ||
rawContent.startsWith('classDiagram') ||
rawContent.startsWith('stateDiagram') ||
rawContent.startsWith('gantt') ||
rawContent.startsWith('pie') ||
rawContent.startsWith('flowchart') ||
rawContent.startsWith('erDiagram')
);
if (!looksPotentiallyComplete) {
console.log('Mermaid content might be incomplete, skipping render attempt:', rawContent);
// Optionally keep loading indicator or show a message
// container.innerHTML = '<p class="text-sm text-muted-foreground">Waiting for complete diagram...</p>';
return;
}
const processedContent = rawContent
.split('\n')
.map(line => {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('subgraph')) {
const parts = trimmedLine.split(' ');
if (parts.length > 1) {
const title = parts.slice(1).join(' ').replace(/["']/g, '');
return `subgraph "${title}"`;
}
}
return trimmedLine;
})
.filter(line => !line.trim().startsWith('linkStyle'))
.join('\n');
const mermaidId = `mermaid-${Date.now()}`;
mermaid.render(mermaidId, processedContent)
.then(({ svg, bindFunctions }) => {
// Check ref and hasRendered state again inside async callback
if (mermaidRef.current === container && !hasRendered) {
container.innerHTML = svg;
setHasRendered(true); // Mark as rendered successfully
if (bindFunctions) {
try {
bindFunctions(container);
} catch (bindError) {
console.error('Mermaid bindFunctions error:', bindError);
container.innerHTML += '<p class="text-orange-500 text-xs">Diagram interactions might be limited.</p>';
}
}
} else if (mermaidRef.current !== container) {
console.log('Mermaid container changed before rendering completed.');
}
})
.catch(error => {
console.error('Mermaid rendering promise error (debounced):', error);
console.error('Failed content (debounced):', processedContent);
if (mermaidRef.current === container) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorPre = document.createElement('pre');
errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
errorPre.textContent = `Mermaid diagram error: ${errorMessage}\n\nContent:\n${processedContent}`;
container.innerHTML = '';
container.appendChild(errorPre);
}
});
} catch (error) {
console.error('Mermaid synchronous error (debounced):', error);
console.error('Failed content (debounced):', String(children));
if (mermaidRef.current === container) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorPre = document.createElement('pre');
errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
errorPre.textContent = `Mermaid diagram setup error: ${errorMessage}`;
container.innerHTML = '';
container.appendChild(errorPre);
}
}
}, 300); // Debounce delay
}
// Cleanup function to clear the timer on unmount or before re-running effect
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
// Dependencies: renderAsDiagram ensures effect runs when diagram should be shown.
// Dependencies include all values used inside the effect to satisfy exhaustive-deps.
// The !hasRendered check prevents re-execution of render logic after success.
}, [renderAsDiagram, hasRendered, language, children, theme]); // Add children and theme back
// For large JSON, skip syntax highlighting completely and use a simple pre tag
if (isLargeJsonBlock) {
return (
<pre className="whitespace-pre-wrap break-words bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
{contentStr}
</pre>
);
}
// Render based on language type
// If it's a mermaid language block and rendering as diagram is not requested (e.g., incomplete stream), display as plain text
if (language === 'mermaid' && !renderAsDiagram) {
return (
<SyntaxHighlighter
style={theme === 'dark' ? oneDark : oneLight}
PreTag="div"
language="text" // Use text as language to avoid syntax highlighting errors
{...props}
>
{contentStr}
</SyntaxHighlighter>
);
}
// If it's a mermaid language block and the message is complete, render as diagram
if (language === 'mermaid') {
// Container for Mermaid diagram
return <div className="mermaid-diagram-container my-4 overflow-x-auto" ref={mermaidRef}></div>;
}
// ReactMarkdown determines inline vs block based on markdown syntax
// Inline code: `code` (no className with language)
// Block code: ```language (has className like "language-js")
// If there's no language className and no explicit inline prop, it's likely inline code
const isInline = inline ?? !className?.startsWith('language-');
// Generate dynamic inline code styles based on message role and theme
const getInlineCodeStyles = () => {
if (messageRole === 'user') {
// User messages have dark background (bg-primary), need light inline code
return theme === 'dark'
? 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30'
: 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30';
} else {
// Assistant messages have light background (bg-muted), need contrasting inline code
return theme === 'dark'
? 'bg-muted-foreground/20 text-muted-foreground border border-muted-foreground/30'
: 'bg-slate-200 text-slate-800 border border-slate-300';
}
};
// Handle non-Mermaid code blocks
return !isInline ? (
<SyntaxHighlighter
style={theme === 'dark' ? oneDark : oneLight}
PreTag="div"
language={language}
{...props}
>
{contentStr}
</SyntaxHighlighter>
) : (
// Handle inline code with context-aware styling
<code
className={cn(
className,
'mx-1 rounded-sm px-1 py-0.5 font-mono text-sm',
getInlineCodeStyles()
)}
{...props}
>
{children}
</code>
);
});
// Assign display name for React DevTools
CodeHighlight.displayName = 'CodeHighlight';
@@ -0,0 +1,471 @@
import { useCallback, useMemo } from 'react'
import { QueryMode, QueryRequest } from '@/api/lightrag'
// Removed unused import for Text component
import Checkbox from '@/components/ui/Checkbox'
import Input from '@/components/ui/Input'
import UserPromptInputWithHistory from '@/components/ui/UserPromptInputWithHistory'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/Select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
import { RotateCcw } from 'lucide-react'
import { cn } from '@/lib/utils'
const ResetButton = ({ onClick, title }: { onClick: () => void; title: string }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className="mr-1 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={title}
>
<RotateCcw className="h-3 w-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
<p>{title}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
export default function QuerySettings() {
const { t } = useTranslation()
const querySettings = useSettingsStore((state) => state.querySettings)
const userPromptHistory = useSettingsStore((state) => state.userPromptHistory)
const handleChange = useCallback((key: keyof QueryRequest, value: any) => {
useSettingsStore.getState().updateQuerySettings({ [key]: value })
}, [])
const handleSelectFromHistory = useCallback((prompt: string) => {
handleChange('user_prompt', prompt)
}, [handleChange])
const handleDeleteFromHistory = useCallback((index: number) => {
const newHistory = [...userPromptHistory]
newHistory.splice(index, 1)
useSettingsStore.getState().setUserPromptHistory(newHistory)
}, [userPromptHistory])
// Default values for reset functionality
const defaultValues = useMemo(() => ({
mode: 'mix' as QueryMode,
top_k: 40,
chunk_top_k: 20,
max_entity_tokens: 6000,
max_relation_tokens: 8000,
max_total_tokens: 30000
}), [])
const handleReset = useCallback((key: keyof typeof defaultValues) => {
handleChange(key, defaultValues[key])
}, [handleChange, defaultValues])
// Mix offers the best retrieval coverage; Bypass intentionally skips retrieval.
// Warn only for the narrower-coverage modes (hybrid/naive/local/global).
const showQualityWarning =
querySettings.mode !== 'mix' && querySettings.mode !== 'bypass'
return (
<Card className="flex shrink-0 flex-col w-[280px]">
<CardHeader className="px-4 pt-4 pb-2">
<CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle>
<CardDescription className="sr-only">{t('retrievePanel.querySettings.parametersDescription')}</CardDescription>
</CardHeader>
<CardContent className="m-0 flex grow flex-col p-0 text-xs">
<div className="relative size-full">
<div className="absolute inset-0 flex flex-col gap-2 overflow-auto px-2 pr-2">
{/* User Prompt - Moved to top for better dropdown space */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="user_prompt" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.userPrompt')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.userPromptTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div>
<UserPromptInputWithHistory
id="user_prompt"
value={querySettings.user_prompt || ''}
onChange={(value) => handleChange('user_prompt', value)}
onSelectFromHistory={handleSelectFromHistory}
onDeleteFromHistory={handleDeleteFromHistory}
history={userPromptHistory}
placeholder={t('retrievePanel.querySettings.userPromptPlaceholder')}
className="h-9"
/>
</div>
</>
{/* Query Mode */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="query_mode_select" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.queryMode')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.queryModeTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Select
value={querySettings.mode}
onValueChange={(v) => handleChange('mode', v as QueryMode)}
>
<SelectTrigger
id="query_mode_select"
className={cn(
'hover:bg-primary/5 h-9 cursor-pointer focus:ring-0 focus:ring-offset-0 focus:outline-0 active:right-0 flex-1 text-left [&>span]:break-all [&>span]:line-clamp-1',
showQualityWarning &&
'border-red-400 bg-red-100 text-red-700 hover:bg-red-100 dark:border-red-600 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/30'
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="mix">{t('retrievePanel.querySettings.queryModeOptions.mix')}</SelectItem>
<SelectItem value="hybrid">{t('retrievePanel.querySettings.queryModeOptions.hybrid')}</SelectItem>
<SelectItem value="naive">{t('retrievePanel.querySettings.queryModeOptions.naive')}</SelectItem>
<SelectItem value="local">{t('retrievePanel.querySettings.queryModeOptions.local')}</SelectItem>
<SelectItem value="global">{t('retrievePanel.querySettings.queryModeOptions.global')}</SelectItem>
<SelectItem value="bypass">{t('retrievePanel.querySettings.queryModeOptions.bypass')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<ResetButton
onClick={() => handleReset('mode')}
title="Reset to default (Mix)"
/>
</div>
{showQualityWarning && (
<p className="ml-1 text-red-600 dark:text-red-400">
{t('retrievePanel.querySettings.queryModeWarning')}
</p>
)}
</>
{/* Top K */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="top_k" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.topK')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.topKTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="top_k"
type="number"
value={querySettings.top_k ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('top_k', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('top_k', 40)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.topKPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('top_k')}
title="Reset to default"
/>
</div>
</>
{/* Chunk Top K */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="chunk_top_k" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.chunkTopK')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.chunkTopKTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="chunk_top_k"
type="number"
value={querySettings.chunk_top_k ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('chunk_top_k', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('chunk_top_k', 20)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.chunkTopKPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('chunk_top_k')}
title="Reset to default"
/>
</div>
</>
{/* Max Entity Tokens */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="max_entity_tokens" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.maxEntityTokens')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.maxEntityTokensTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="max_entity_tokens"
type="number"
value={querySettings.max_entity_tokens ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('max_entity_tokens', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('max_entity_tokens', 6000)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.maxEntityTokensPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('max_entity_tokens')}
title="Reset to default"
/>
</div>
</>
{/* Max Relation Tokens */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="max_relation_tokens" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.maxRelationTokens')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.maxRelationTokensTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="max_relation_tokens"
type="number"
value={querySettings.max_relation_tokens ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('max_relation_tokens', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('max_relation_tokens', 8000)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.maxRelationTokensPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('max_relation_tokens')}
title="Reset to default"
/>
</div>
</>
{/* Max Total Tokens */}
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="max_total_tokens" className="ml-1 cursor-help">
{t('retrievePanel.querySettings.maxTotalTokens')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.maxTotalTokensTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center gap-1">
<Input
id="max_total_tokens"
type="number"
value={querySettings.max_total_tokens ?? ''}
onChange={(e) => {
const value = e.target.value
handleChange('max_total_tokens', value === '' ? '' : parseInt(value) || 0)
}}
onBlur={(e) => {
const value = e.target.value
if (value === '' || isNaN(parseInt(value))) {
handleChange('max_total_tokens', 30000)
}
}}
min={1}
placeholder={t('retrievePanel.querySettings.maxTotalTokensPlaceholder')}
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
/>
<ResetButton
onClick={() => handleReset('max_total_tokens')}
title="Reset to default"
/>
</div>
</>
{/* Toggle Options */}
<>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="enable_rerank" className="flex-1 ml-1 cursor-help">
{t('retrievePanel.querySettings.enableRerank')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.enableRerankTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
className="mr-10 cursor-pointer"
id="enable_rerank"
checked={querySettings.enable_rerank}
onCheckedChange={(checked) => handleChange('enable_rerank', checked)}
/>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="only_need_context" className="flex-1 ml-1 cursor-help">
{t('retrievePanel.querySettings.onlyNeedContext')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.onlyNeedContextTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
className="mr-10 cursor-pointer"
id="only_need_context"
checked={querySettings.only_need_context}
onCheckedChange={(checked) => {
handleChange('only_need_context', checked)
if (checked) {
handleChange('only_need_prompt', false)
}
}}
/>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="only_need_prompt" className="flex-1 ml-1 cursor-help">
{t('retrievePanel.querySettings.onlyNeedPrompt')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.onlyNeedPromptTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
className="mr-10 cursor-pointer"
id="only_need_prompt"
checked={querySettings.only_need_prompt}
onCheckedChange={(checked) => {
handleChange('only_need_prompt', checked)
if (checked) {
handleChange('only_need_context', false)
}
}}
/>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label htmlFor="stream" className="flex-1 ml-1 cursor-help">
{t('retrievePanel.querySettings.streamResponse')}
</label>
</TooltipTrigger>
<TooltipContent side="left">
<p>{t('retrievePanel.querySettings.streamResponseTooltip')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
className="mr-10 cursor-pointer"
id="stream"
checked={querySettings.stream}
onCheckedChange={(checked) => handleChange('stream', checked)}
/>
</div>
</>
</div>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,353 @@
import type {
LightragQueueStatus,
LightragRoleLLMConfig,
LightragStatus
} from '@/api/lightrag'
import { useTranslation } from 'react-i18next'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/Table'
const ROLE_ORDER = ['extract', 'keyword', 'query', 'vlm']
type RoleLLMRow = {
role: string
config: LightragRoleLLMConfig
queue?: LightragQueueStatus
}
const textValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === '') return '-'
return String(value)
}
const formatKwargs = (value: Record<string, any> | null | undefined): string => {
if (!value || typeof value !== 'object') return '-'
const entries = Object.entries(value)
if (!entries.length) return '-'
return entries
.map(([k, v]) => {
const strVal = typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v)
return `${k}=${strVal}`
})
.join(', ')
}
const statValue = (value: number | undefined) => {
return typeof value === 'number' ? value.toString() : '-'
}
type MinerUStatus = NonNullable<LightragStatus['configuration']['mineru']>
type DoclingStatus = NonNullable<LightragStatus['configuration']['docling']>
// Compact param display: values printed verbatim; True bools printed as
// their flag name; False bools and empty values dropped entirely. Params
// after the endpoint are wrapped in parens so they don't read like URL
// path segments.
const joinParts = (endpoint: string, parts: string[]): string => {
if (!endpoint && !parts.length) return '-'
if (!parts.length) return endpoint
if (!endpoint) return parts.join(' / ')
return `${endpoint} (${parts.join(' / ')})`
}
const formatMinerU = (m: MinerUStatus | undefined): string => {
if (!m || (!m.endpoint && !m.api_mode)) return '-'
const opts = m.options || {}
const parts: string[] = []
if (m.api_mode) parts.push(m.api_mode)
if (opts.language) parts.push(opts.language)
if (opts.enable_table) parts.push('table')
if (opts.enable_formula) parts.push('formula')
if (m.api_mode === 'official') {
if (opts.model_version) parts.push(opts.model_version)
if (opts.is_ocr) parts.push('ocr')
} else if (m.api_mode === 'local') {
if (opts.local_backend) parts.push(opts.local_backend)
if (opts.local_parse_method) parts.push(opts.local_parse_method)
if (opts.local_image_analysis) parts.push('image_analysis')
}
return joinParts(m.endpoint || '', parts)
}
const formatDocling = (d: DoclingStatus | undefined): string => {
if (!d || !d.endpoint) return '-'
const opts = d.options || {}
const parts: string[] = []
if (opts.ocr_engine) parts.push(opts.ocr_engine)
if (opts.do_ocr) parts.push('ocr')
if (opts.force_ocr) parts.push('force_ocr')
if (opts.do_formula_enrichment) parts.push('formula')
return joinParts(d.endpoint, parts)
}
const getModelRows = (status: LightragStatus): RoleLLMRow[] => {
const configs = status.configuration.role_llm_config || {}
const queues = status.llm_queue_status || {}
const discoveredRoles = new Set([...Object.keys(configs), ...Object.keys(queues)])
const orderedRoles = [
...ROLE_ORDER.filter((role) => discoveredRoles.has(role)),
...Array.from(discoveredRoles).filter((role) => !ROLE_ORDER.includes(role))
]
const rows: RoleLLMRow[] = orderedRoles.map((role) => ({
role,
config: configs[role] || {
binding: status.configuration.llm_binding,
model: status.configuration.llm_model,
host: status.configuration.llm_binding_host,
max_async: status.configuration.max_async
},
queue: queues[role]
}))
if (!rows.length) {
rows.push({
role: 'base',
config: {
binding: status.configuration.llm_binding,
model: status.configuration.llm_model,
host: status.configuration.llm_binding_host,
max_async: status.configuration.max_async
}
})
}
rows.push({
role: 'embed',
config: {
binding: status.configuration.embedding_binding,
model: status.configuration.embedding_model,
host: status.configuration.embedding_binding_host,
max_async: status.configuration.embedding_func_max_async
},
queue: status.embedding_queue_status
})
if (status.configuration.enable_rerank || status.rerank_queue_status?.available) {
rows.push({
role: 'rerank',
config: {
binding: status.configuration.rerank_binding,
model: status.configuration.rerank_model,
host: status.configuration.rerank_binding_host,
max_async: status.rerank_queue_status?.max_async
},
queue: status.rerank_queue_status
})
}
return rows
}
const StatusCard = ({ status }: { status: LightragStatus | null }) => {
const { t } = useTranslation()
if (!status) {
return <div className="text-foreground text-xs">{t('graphPanel.statusCard.unavailable')}</div>
}
const roleRows = getModelRows(status)
const storageWorkspaces = status.configuration.storage_workspaces
const defaultWorkspace = status.configuration.workspace
const storageColumns = [
{
key: 'kv',
label: t('graphPanel.statusCard.kvStorage'),
storageClass: status.configuration.kv_storage,
workspace: storageWorkspaces?.kv_storage ?? defaultWorkspace
},
{
key: 'doc-status',
label: t('graphPanel.statusCard.docStatusStorage'),
storageClass: status.configuration.doc_status_storage,
workspace: storageWorkspaces?.doc_status_storage ?? defaultWorkspace
},
{
key: 'vector',
label: t('graphPanel.statusCard.vectorStorage'),
storageClass: status.configuration.vector_storage,
workspace: storageWorkspaces?.vector_storage ?? defaultWorkspace
},
{
key: 'graph',
label: t('graphPanel.statusCard.graphStorage'),
storageClass: status.configuration.graph_storage,
workspace: storageWorkspaces?.graph_storage ?? defaultWorkspace
}
]
return (
<div className="min-w-[300px] space-y-2 text-xs">
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.serverInfo')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.inputDirectory')}:</span>
<span className="truncate">{status.input_directory}</span>
<span>{t('graphPanel.statusCard.parser')}:</span>
<span
className="truncate"
title={status.configuration.parser_routing || undefined}
>
{textValue(status.configuration.parser_routing)}
{' (VLM_PROCESS_ENABLE='}
{String(status.configuration.vlm_process_enable ?? false)}
{')'}
</span>
<span>{t('graphPanel.statusCard.mineru')}:</span>
{(() => {
const minerUText = formatMinerU(status.configuration.mineru)
return (
<span className="truncate" title={minerUText !== '-' ? minerUText : undefined}>
{minerUText}
</span>
)
})()}
<span>{t('graphPanel.statusCard.docling')}:</span>
{(() => {
const doclingText = formatDocling(status.configuration.docling)
return (
<span className="truncate" title={doclingText !== '-' ? doclingText : undefined}>
{doclingText}
</span>
)
})()}
<span>{t('graphPanel.statusCard.otherSettings')}:</span>
<span>
{status.configuration.summary_language}
{' / Sum_on_f '}{status.configuration.force_llm_summary_on_merge.toString()}
{' / max_p_i '}{status.configuration.max_parallel_insert}
{' / cosine '}{status.configuration.cosine_threshold}
{' / rerank '}{status.configuration.min_rerank_score}
{' / max_related '}{status.configuration.related_chunk_number}
{' / max_g_n '}{status.configuration.max_graph_nodes || '-'}
</span>
{status.keyed_locks && (
<>
<span>{t('graphPanel.statusCard.lockStatus')}:</span>
<span>
{status.server_mode && (
<>
{status.server_mode}
{status.server_mode === 'gunicorn' && status.workers ? ` ${status.workers}` : ''}
{' | '}
</>
)}
mp {status.keyed_locks.current_status.pending_mp_cleanup}/{status.keyed_locks.current_status.total_mp_locks} |
async {status.keyed_locks.current_status.pending_async_cleanup}/{status.keyed_locks.current_status.total_async_locks}
(pid: {status.keyed_locks.process_id})
</span>
</>
)}
</div>
</div>
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.llmConfig')}</h4>
<div className="rounded-md border">
<Table className="text-xs">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-7 px-2 py-1">role</TableHead>
<TableHead className="h-7 px-2 py-1">
binding/model
</TableHead>
<TableHead className="h-7 px-2 py-1">base_url/kwargs</TableHead>
<TableHead className="h-7 px-2 py-1 text-right">
queued
</TableHead>
<TableHead className="h-7 px-2 py-1 text-right">
run/max
</TableHead>
<TableHead className="h-7 px-2 py-1 text-right">
req
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roleRows.map(({ role, config, queue }) => {
const maxAsync = queue?.max_async ?? config.max_async
return (
<TableRow key={role} className="hover:bg-muted/30">
<TableCell className="px-2 py-1 font-medium capitalize">
{role}
</TableCell>
<TableCell className="max-w-[170px] px-2 py-1">
<div className="truncate">{textValue(config.binding)}</div>
<div className="text-muted-foreground truncate">
{textValue(config.model)}
</div>
</TableCell>
<TableCell className="max-w-[220px] px-2 py-1">
<div className="truncate">{textValue(config.host)}</div>
{(() => {
const providerOptions = config.metadata?.provider_options as Record<string, any> | undefined
const kwargsStr = formatKwargs(providerOptions)
return (
<div
className="text-muted-foreground truncate"
title={kwargsStr !== '-' ? JSON.stringify(providerOptions, null, 2) : undefined}
>
{kwargsStr}
</div>
)
})()}
</TableCell>
<TableCell className="px-2 py-1 text-right tabular-nums">
{statValue(queue?.queued)}
</TableCell>
<TableCell className="px-2 py-1 text-right tabular-nums">
{statValue(queue?.running)}/{statValue(maxAsync)}
</TableCell>
<TableCell className="px-2 py-1 text-right tabular-nums">
{statValue(queue?.submitted_total)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.storageConfig')}</h4>
<div className="rounded-md border">
<Table className="text-xs">
<TableHeader>
<TableRow className="hover:bg-transparent">
{storageColumns.map(({ key, label }) => (
<TableHead key={key} className="h-7 min-w-[130px] px-2 py-1">
{label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-muted/30">
{storageColumns.map(({ key, storageClass }) => (
<TableCell key={`${key}-class`} className="break-all px-2 py-1 align-top font-medium">
{textValue(storageClass)}
</TableCell>
))}
</TableRow>
<TableRow className="hover:bg-muted/30">
{storageColumns.map(({ key, workspace }) => (
<TableCell key={`${key}-workspace`} className="text-muted-foreground break-all px-2 py-1 align-top">
{textValue(workspace)}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
)
}
export default StatusCard
@@ -0,0 +1,36 @@
import { LightragStatus } from '@/api/lightrag'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/Dialog'
import StatusCard from './StatusCard'
interface StatusDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
status: LightragStatus | null
}
const StatusDialog = ({ open, onOpenChange, status }: StatusDialogProps) => {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[920px]">
<DialogHeader className="pr-6">
<DialogTitle>{t('graphPanel.statusDialog.title')}</DialogTitle>
<DialogDescription>
{t('graphPanel.statusDialog.description')}
</DialogDescription>
</DialogHeader>
<StatusCard status={status} />
</DialogContent>
</Dialog>
)
}
export default StatusDialog
@@ -0,0 +1,55 @@
import { cn } from '@/lib/utils'
import { useBackendState } from '@/stores/state'
import { useEffect, useState } from 'react'
import StatusDialog from './StatusDialog'
import { useTranslation } from 'react-i18next'
const StatusIndicator = () => {
const { t } = useTranslation()
const health = useBackendState.use.health()
const lastCheckTime = useBackendState.use.lastCheckTime()
const status = useBackendState.use.status()
const [animate, setAnimate] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
// listen to health change
useEffect(() => {
const animTimer = setTimeout(() => setAnimate(true), 0)
const timer = setTimeout(() => setAnimate(false), 300)
return () => {
clearTimeout(animTimer)
clearTimeout(timer)
}
}, [lastCheckTime])
return (
<div className="fixed right-4 bottom-4 flex items-center gap-2 opacity-80 select-none">
<div
className="flex cursor-pointer items-center gap-2"
onClick={() => setDialogOpen(true)}
>
<div
className={cn(
'h-3 w-3 rounded-full transition-all duration-300',
'shadow-[0_0_8px_rgba(0,0,0,0.2)]',
health ? 'bg-green-500' : 'bg-red-500',
animate && 'scale-125',
animate && health && 'shadow-[0_0_12px_rgba(34,197,94,0.4)]',
animate && !health && 'shadow-[0_0_12px_rgba(239,68,68,0.4)]'
)}
/>
<span className="text-muted-foreground text-xs">
{health ? t('graphPanel.statusIndicator.connected') : t('graphPanel.statusIndicator.disconnected')}
</span>
</div>
<StatusDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
status={status}
/>
</div>
)
}
export default StatusIndicator
@@ -0,0 +1,49 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive'
}
},
defaultVariants: {
variant: 'default'
}
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
{...props}
/>
)
)
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,115 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/Button'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
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/50',
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.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-top-[48%] 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}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel
}
@@ -0,0 +1,240 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } 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 AsyncSearchProps<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
/** Accessibility label for the search field */
ariaLabel?: 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 AsyncSearch<T>({
fetcher,
preload,
filterFn,
renderOption,
getOptionValue,
notFound,
loadingSkeleton,
ariaLabel,
placeholder = 'Select...',
value,
onChange,
onFocus,
disabled = false,
className,
noResultsMessage
}: AsyncSearchProps<T>) {
const [open, setOpen] = useState(false)
const [fetchedOptions, setFetchedOptions] = useState<T[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
const containerRef = useRef<HTMLDivElement>(null)
// Handle clicks outside of the component
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node) &&
open
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [open])
const fetchOptions = useCallback(async (query: string) => {
try {
setLoading(true)
setError(null)
const data = await fetcher(query)
setFetchedOptions(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch options')
} finally {
setLoading(false)
}
}, [fetcher])
// Fetch options from server when not in preload mode (search term changes).
// The fetch is a genuine external side effect; setState happens inside fetchOptions
// after the async call resolves, so the rule fires on the synchronous loading flag.
useEffect(() => {
if (preload) return
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchOptions(debouncedSearchTerm)
}, [preload, debouncedSearchTerm, fetchOptions])
// Load initial value
useEffect(() => {
if (!value) return
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchOptions(value)
}, [value, fetchOptions])
// In preload mode, derive filtered options without mutating state
const options = useMemo(() => {
if (preload && debouncedSearchTerm) {
return fetchedOptions.filter((option) =>
filterFn ? filterFn(option, debouncedSearchTerm) : true
)
}
return fetchedOptions
}, [preload, debouncedSearchTerm, filterFn, fetchedOptions])
const handleSelect = useCallback((currentValue: string) => {
onChange(currentValue)
requestAnimationFrame(() => {
// Blur the input to ensure focus event triggers on next click
const input = document.activeElement as HTMLElement
input?.blur()
// Close the dropdown
setOpen(false)
})
}, [onChange])
const handleFocus = useCallback(() => {
setOpen(true)
// Use current search term to fetch options
fetchOptions(searchTerm)
}, [searchTerm, fetchOptions])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (target.closest('.cmd-item')) {
e.preventDefault()
}
}, [])
return (
<div
ref={containerRef}
className={cn(disabled && 'cursor-not-allowed opacity-50', className)}
onMouseDown={handleMouseDown}
>
<Command shouldFilter={false} className="bg-transparent">
<div>
<CommandInput
placeholder={placeholder}
value={searchTerm}
className="max-h-8"
aria-label={ariaLabel}
onFocus={handleFocus}
onValueChange={(value) => {
setSearchTerm(value)
if (!open) setOpen(true)
}}
/>
{loading && (
<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 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 results found.'}</CommandEmpty>
))}
<CommandGroup>
{options.map((option, idx) => (
<React.Fragment key={getOptionValue(option) + `-fragment-${idx}`}>
<CommandItem
key={getOptionValue(option) + `${idx}`}
value={getOptionValue(option)}
onSelect={handleSelect}
onMouseMove={() => onFocus(getOptionValue(option))}
className="truncate cmd-item"
>
{renderOption(option)}
</CommandItem>
{idx !== options.length - 1 && (
<div key={`divider-${idx}`} className="bg-foreground/10 h-[1px]" />
)}
</React.Fragment>
))}
</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>
)
}
@@ -0,0 +1,273 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import { useDebounce } from '@/hooks/useDebounce'
import { cn } from '@/lib/utils'
import Button from '@/components/ui/Button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/Command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
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
/** Function to get the display value for the selected option */
getDisplayValue: (option: T) => React.ReactNode
/** Custom not found message */
notFound?: React.ReactNode
/** Custom loading skeleton */
loadingSkeleton?: React.ReactNode
/** Currently selected value */
value: string
/** Callback when selection changes */
onChange: (value: string) => void
/** Callback before opening the dropdown (async supported) */
onBeforeOpen?: () => void | Promise<void>
/** Accessibility label for the select field */
ariaLabel?: string
/** Placeholder text when no selection */
placeholder?: string
/** Display text for search placeholder */
searchPlaceholder?: 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 search input class names */
searchInputClassName?: string
/** Custom no results message */
noResultsMessage?: string
/** Custom trigger tooltip */
triggerTooltip?: string
/** Allow clearing the selection */
clearable?: boolean
/** Debounce time in milliseconds */
debounceTime?: number
}
export function AsyncSelect<T>({
fetcher,
preload,
filterFn,
renderOption,
getOptionValue,
getDisplayValue,
notFound,
loadingSkeleton,
ariaLabel,
placeholder = 'Select...',
searchPlaceholder,
value,
onChange,
onBeforeOpen,
disabled = false,
className,
triggerClassName,
searchInputClassName,
noResultsMessage,
triggerTooltip,
clearable = true,
debounceTime = 150
}: AsyncSelectProps<T>) {
const [open, setOpen] = useState(false)
const [originalOptions, setOriginalOptions] = useState<T[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : debounceTime)
// Derive the displayed options from the fetched list and search term, instead
// of mirroring them via setState in an effect.
const options = useMemo(() => {
if (preload && debouncedSearchTerm) {
return originalOptions.filter((option) =>
filterFn ? filterFn(option, debouncedSearchTerm) : true
)
}
return originalOptions
}, [preload, debouncedSearchTerm, filterFn, originalOptions])
// Derive selected option from value + currently-loaded options.
const selectedOption = useMemo(
() => (value ? options.find((opt) => getOptionValue(opt) === value) ?? null : null),
[value, options, getOptionValue]
)
// Show the raw value as a placeholder until the matching option is loaded.
const initialValueDisplay = useMemo(
() => (value && !selectedOption ? <div>{value}</div> : null),
[value, selectedOption]
)
// Fetch options whenever search term changes (skip filtering-only re-runs in preload mode)
useEffect(() => {
const fetchOptions = async () => {
try {
setLoading(true)
setError(null)
const data = await fetcher(debouncedSearchTerm)
setOriginalOptions(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch options')
} finally {
setLoading(false)
}
}
if (preload && originalOptions.length > 0) {
// Already fetched; rely on the memoised filter above.
return
}
fetchOptions()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher, debouncedSearchTerm, preload])
const handleSelect = useCallback(
(currentValue: string) => {
const newValue = clearable && currentValue === value ? '' : currentValue
onChange(newValue)
setOpen(false)
},
[value, onChange, clearable]
)
const handleOpenChange = useCallback(
async (newOpen: boolean) => {
if (newOpen && onBeforeOpen) {
await onBeforeOpen()
}
setOpen(newOpen)
},
[onBeforeOpen]
)
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-label={ariaLabel}
className={cn(
'justify-between',
disabled && 'cursor-not-allowed opacity-50',
triggerClassName
)}
disabled={disabled}
tooltip={triggerTooltip}
side="bottom"
>
{value === '*' ? <div>*</div> : (selectedOption ? getDisplayValue(selectedOption) : (initialValueDisplay || placeholder))}
<ChevronsUpDown className="opacity-50" size={10} />
</Button>
</PopoverTrigger>
<PopoverContent
className={cn('p-0', className)}
onCloseAutoFocus={(e) => e.preventDefault()}
align="start"
sideOffset={8}
collisionPadding={5}
>
<Command shouldFilter={false}>
<div className="relative w-full border-b">
<CommandInput
placeholder={searchPlaceholder || 'Search...'}
value={searchTerm}
onValueChange={(value) => {
setSearchTerm(value)
}}
className={searchInputClassName}
/>
{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>
{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 results found.'}
</CommandEmpty>
))}
<CommandGroup>
{options.map((option) => {
const optionValue = getOptionValue(option);
// Fix cmdk filtering issue: use empty string when search is empty
// This ensures all items are shown when searchTerm is empty
const itemValue = searchTerm.trim() === '' ? '' : optionValue;
return (
<CommandItem
key={optionValue}
value={itemValue}
onSelect={() => {
handleSelect(optionValue);
}}
className="truncate"
>
{renderOption(option)}
<Check
className={cn(
'ml-auto h-3 w-3',
value === optionValue ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
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>
)
}
@@ -0,0 +1,33 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export default Badge
@@ -0,0 +1,78 @@
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'
// eslint-disable-next-line react-refresh/only-export-components
export 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: 'size-8'
}
},
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, tooltip, size, 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
+55
View File
@@ -0,0 +1,55 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -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-muted data-[state=checked]:text-muted-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 default Checkbox
@@ -0,0 +1,142 @@
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(
'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
}
@@ -0,0 +1,67 @@
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/Table'
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
// TanStack Table returns unstable functions by design; keep this wrapper out of the
// incompatible-library warning rather than pretending it is compiler-safe.
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel()
})
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}
+102
View 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/30',
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 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.ComponentRef<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.ComponentRef<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,38 @@
import { cn } from '@/lib/utils'
import { Card, CardDescription, CardTitle } from '@/components/ui/Card'
import { FilesIcon } from 'lucide-react'
interface EmptyCardProps extends React.ComponentPropsWithoutRef<typeof Card> {
title: string
description?: string
action?: React.ReactNode
icon?: React.ComponentType<{ className?: string }>
}
export default function EmptyCard({
title,
description,
icon: Icon = FilesIcon,
action,
className,
...props
}: EmptyCardProps) {
return (
<Card
className={cn(
'flex h-full min-h-0 w-full flex-col items-center justify-center space-y-6 rounded-none bg-transparent p-16',
className
)}
{...props}
>
<div className="mr-4 shrink-0 rounded-full border border-dashed p-4">
<Icon className="text-muted-foreground size-8" aria-hidden="true" />
</div>
<div className="flex flex-col items-center gap-1.5 text-center">
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
{action ? action : null}
</Card>
)
}
@@ -0,0 +1,455 @@
/**
* @see https://github.com/sadmann7/file-uploader
*/
import * as React from 'react'
import { FileText, Upload, X } from 'lucide-react'
import Dropzone, { type DropzoneProps, type FileRejection } from 'react-dropzone'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import Button from '@/components/ui/Button'
import { ScrollArea } from '@/components/ui/ScrollArea'
import { supportedFileTypes } from '@/lib/constants'
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Value of the uploader.
* @type File[]
* @default undefined
* @example value={files}
*/
value?: File[]
/**
* Function to be called when the value changes.
* @type (files: File[]) => void
* @default undefined
* @example onValueChange={(files) => setFiles(files)}
*/
onValueChange?: (files: File[]) => void
/**
* Function to be called when files are uploaded.
* @type (files: File[]) => Promise<void>
* @default undefined
* @example onUpload={(files) => uploadFiles(files)}
*/
onUpload?: (files: File[]) => Promise<void>
/**
* Function to be called when files are rejected.
* @type (rejections: FileRejection[]) => void
* @default undefined
* @example onReject={(rejections) => handleRejectedFiles(rejections)}
*/
onReject?: (rejections: FileRejection[]) => void
/**
* Progress of the uploaded files.
* @type Record<string, number> | undefined
* @default undefined
* @example progresses={{ "file1.png": 50 }}
*/
progresses?: Record<string, number>
/**
* Error messages for failed uploads.
* @type Record<string, string> | undefined
* @default undefined
* @example fileErrors={{ "file1.png": "Upload failed" }}
*/
fileErrors?: Record<string, string>
/**
* Accepted file types for the uploader.
* @type { [key: string]: string[]}
* @default
* ```ts
* { "text/*": [] }
* ```
* @example accept={["text/plain", "application/pdf"]}
*/
accept?: DropzoneProps['accept']
/**
* Maximum file size for the uploader.
* @type number | undefined
* @default 1024 * 1024 * 200 // 200MB
* @example maxSize={1024 * 1024 * 2} // 2MB
*/
maxSize?: DropzoneProps['maxSize']
/**
* Maximum number of files for the uploader.
* @type number | undefined
* @default 1
* @example maxFileCount={4}
*/
maxFileCount?: DropzoneProps['maxFiles']
/**
* Whether the uploader should accept multiple files.
* @type boolean
* @default false
* @example multiple
*/
multiple?: boolean
/**
* Whether the uploader is disabled.
* @type boolean
* @default false
* @example disabled
*/
disabled?: boolean
description?: string
}
function formatBytes(
bytes: number,
opts: {
decimals?: number
sizeType?: 'accurate' | 'normal'
} = {}
) {
const { decimals = 0, sizeType = 'normal' } = opts
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']
if (bytes === 0) return '0 Byte'
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
sizeType === 'accurate' ? (accurateSizes[i] ?? 'Bytes') : (sizes[i] ?? 'Bytes')
}`
}
function FileUploader(props: FileUploaderProps) {
const { t } = useTranslation()
const {
value: valueProp,
onValueChange,
onUpload,
onReject,
progresses,
fileErrors,
accept = supportedFileTypes,
maxSize = 1024 * 1024 * 200,
maxFileCount = 1,
multiple = false,
disabled = false,
description,
className,
...dropzoneProps
} = props
const [files, setFiles] = useControllableState<File[]>({
prop: valueProp,
defaultProp: [],
onChange: onValueChange
})
const onDrop = React.useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
// Calculate total file count including both accepted and rejected files
const totalFileCount = (files?.length ?? 0) + acceptedFiles.length + rejectedFiles.length
// Check file count limits
if (!multiple && maxFileCount === 1 && (acceptedFiles.length + rejectedFiles.length) > 1) {
toast.error(t('documentPanel.uploadDocuments.fileUploader.singleFileLimit'))
return
}
if (totalFileCount > maxFileCount) {
toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount }))
return
}
// Handle rejected files first - this will set error states
if (rejectedFiles.length > 0) {
if (onReject) {
// Use the onReject callback if provided
onReject(rejectedFiles)
} else {
// Fall back to toast notifications if no callback is provided
rejectedFiles.forEach(({ file }) => {
toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }))
})
}
}
// Process accepted files
const newAcceptedFiles = acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file)
})
)
// Process rejected files for UI display
const newRejectedFiles = rejectedFiles.map(({ file }) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
rejected: true
})
)
// Combine all files for display
const allNewFiles = [...newAcceptedFiles, ...newRejectedFiles]
const updatedFiles = files ? [...files, ...allNewFiles] : allNewFiles
// Update the files state with all files
setFiles(updatedFiles)
// Only upload accepted files - make sure we're not uploading rejected files
if (onUpload && acceptedFiles.length > 0) {
// Filter out any files that might have been rejected by our custom validator
const validFiles = acceptedFiles.filter(file => {
// Skip files without a name
if (!file.name) {
return false;
}
// Check if file type is accepted
const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt));
});
// Check file size
const isSizeValid = file.size <= maxSize;
return isAccepted && isSizeValid;
});
if (validFiles.length > 0) {
onUpload(validFiles);
}
}
},
[files, maxFileCount, multiple, onUpload, onReject, setFiles, t, accept, maxSize]
)
function onRemove(index: number) {
if (!files) return
const newFiles = files.filter((_, i) => i !== index)
setFiles(newFiles)
onValueChange?.(newFiles)
}
// Revoke preview url when component unmounts
React.useEffect(() => {
return () => {
if (!files) return
files.forEach((file) => {
if (isFileWithPreview(file)) {
URL.revokeObjectURL(file.preview)
}
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount
return (
<div className="relative flex flex-col gap-6 overflow-hidden">
<Dropzone
onDrop={onDrop}
// remove acceptuse customizd validator
noClick={false}
noKeyboard={false}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple}
disabled={isDisabled}
validator={(file) => {
// Ensure file name exists
if (!file.name) {
return {
code: 'invalid-file-name',
message: t('documentPanel.uploadDocuments.fileUploader.invalidFileName',
{ fallback: 'Invalid file name' })
};
}
// Safely extract file extension
const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
// Ensure accept object exists and has correct format
const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
// Ensure extensions is an array before calling includes
return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt));
});
if (!isAccepted) {
return {
code: 'file-invalid-type',
message: t('documentPanel.uploadDocuments.fileUploader.unsupportedType')
};
}
// Check file size
if (file.size > maxSize) {
return {
code: 'file-too-large',
message: t('documentPanel.uploadDocuments.fileUploader.fileTooLarge', {
maxSize: formatBytes(maxSize)
})
};
}
return null;
}}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
'group border-muted-foreground/25 hover:bg-muted/25 relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed px-5 py-2.5 text-center transition',
'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
isDragActive && 'border-muted-foreground/50',
isDisabled && 'pointer-events-none opacity-60',
className
)}
{...dropzoneProps}
>
<input {...getInputProps()} />
{isDragActive ? (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<Upload className="text-muted-foreground size-7" aria-hidden="true" />
</div>
<p className="text-muted-foreground font-medium">{t('documentPanel.uploadDocuments.fileUploader.dropHere')}</p>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<Upload className="text-muted-foreground size-7" aria-hidden="true" />
</div>
<div className="flex flex-col gap-px">
<p className="text-muted-foreground font-medium">
{t('documentPanel.uploadDocuments.fileUploader.dragAndDrop')}
</p>
{description ? (
<p className="text-muted-foreground/70 text-sm">{description}</p>
) : (
<p className="text-muted-foreground/70 text-sm">
{t('documentPanel.uploadDocuments.fileUploader.uploadDescription', {
count: maxFileCount,
isMultiple: maxFileCount === Infinity,
maxSize: formatBytes(maxSize)
})}
{t('documentPanel.uploadDocuments.fileTypes')}
</p>
)}
</div>
</div>
)}
</div>
)}
</Dropzone>
{files?.length ? (
<ScrollArea className="h-fit w-full px-3">
<div className="flex max-h-48 flex-col gap-4">
{files?.map((file, index) => (
<FileCard
key={index}
file={file}
onRemove={() => onRemove(index)}
progress={progresses?.[file.name]}
error={fileErrors?.[file.name]}
/>
))}
</div>
</ScrollArea>
) : null}
</div>
)
}
interface ProgressProps {
value: number
error?: boolean
showIcon?: boolean // New property to control icon display
}
function Progress({ value, error }: ProgressProps) {
return (
<div className="relative h-2 w-full">
<div className="h-full w-full overflow-hidden rounded-full bg-secondary">
<div
className={cn(
'h-full transition-all',
error ? 'bg-red-400' : 'bg-primary'
)}
style={{ width: `${value}%` }}
/>
</div>
</div>
)
}
interface FileCardProps {
file: File
onRemove: () => void
progress?: number
error?: string
}
function FileCard({ file, progress, error, onRemove }: FileCardProps) {
const { t } = useTranslation()
return (
<div className="relative flex items-center gap-2.5">
<div className="flex flex-1 gap-2.5">
{error ? (
<FileText className="text-red-400 size-10" aria-hidden="true" />
) : (
isFileWithPreview(file) ? <FilePreview file={file} /> : null
)}
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-px">
<p className="text-foreground/80 line-clamp-1 text-sm font-medium">{file.name}</p>
<p className="text-muted-foreground text-xs">{formatBytes(file.size)}</p>
</div>
{error ? (
<div className="text-red-400 text-sm">
<div className="relative mb-2">
<Progress value={100} error={true} />
</div>
<p>{error}</p>
</div>
) : (
progress ? <Progress value={progress} /> : null
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="icon" className="size-7" onClick={onRemove}>
<X className="size-4" aria-hidden="true" />
<span className="sr-only">{t('documentPanel.uploadDocuments.fileUploader.removeFile')}</span>
</Button>
</div>
</div>
)
}
function isFileWithPreview(file: File): file is File & { preview: string } {
return 'preview' in file && typeof file.preview === 'string'
}
interface FilePreviewProps {
file: File & { preview: string }
}
function FilePreview({ file }: FilePreviewProps) {
if (file.type.startsWith('image/')) {
return <div className="aspect-square shrink-0 rounded-md object-cover" />
}
return <FileText className="text-muted-foreground size-10" aria-hidden="true" />
}
export default FileUploader
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm [&::-webkit-inner-spin-button]:opacity-50 [&::-webkit-outer-spin-button]:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export default Input
@@ -0,0 +1,133 @@
import { ChevronDown, ChevronUp } from 'lucide-react'
import { forwardRef, useCallback, useState } from 'react'
import { NumericFormat, NumericFormatProps } from 'react-number-format'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import { cn } from '@/lib/utils'
export interface NumberInputProps extends Omit<NumericFormatProps, 'value' | 'onValueChange'> {
stepper?: number
thousandSeparator?: string
placeholder?: string
defaultValue?: number
min?: number
max?: number
value?: number // Controlled value
suffix?: string
prefix?: string
onValueChange?: (value: number | undefined) => void
fixedDecimalScale?: boolean
decimalScale?: number
}
const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
(
{
stepper,
thousandSeparator,
placeholder,
defaultValue,
min = -Infinity,
max = Infinity,
onValueChange,
fixedDecimalScale = false,
decimalScale = 0,
className = undefined,
suffix,
prefix,
value: controlledValue,
...props
},
ref
) => {
const [value, setValue] = useState<number | undefined>(controlledValue ?? defaultValue)
// Sync local state when the controlled value changes (e.g. parent resets the field).
// Render-time comparison avoids cascading renders flagged by react-hooks/set-state-in-effect.
const [previousControlledValue, setPreviousControlledValue] = useState(controlledValue)
if (controlledValue !== previousControlledValue) {
setPreviousControlledValue(controlledValue)
if (controlledValue !== undefined) setValue(controlledValue)
}
const handleIncrement = useCallback(() => {
setValue((prev) =>
prev === undefined ? (stepper ?? 1) : Math.min(prev + (stepper ?? 1), max)
)
}, [stepper, max])
const handleDecrement = useCallback(() => {
setValue((prev) =>
prev === undefined ? -(stepper ?? 1) : Math.max(prev - (stepper ?? 1), min)
)
}, [stepper, min])
const handleChange = (values: { value: string; floatValue: number | undefined }) => {
const newValue = values.floatValue === undefined ? undefined : values.floatValue
setValue(newValue)
if (onValueChange) {
onValueChange(newValue)
}
}
const handleBlur = () => {
if (value !== undefined) {
if (value < min) {
setValue(min)
;(ref as React.RefObject<HTMLInputElement>).current!.value = String(min)
} else if (value > max) {
setValue(max)
;(ref as React.RefObject<HTMLInputElement>).current!.value = String(max)
}
}
}
return (
<div className="relative flex">
<NumericFormat
value={value}
onValueChange={handleChange}
thousandSeparator={thousandSeparator}
decimalScale={decimalScale}
fixedDecimalScale={fixedDecimalScale}
allowNegative={min < 0}
valueIsNumericString
onBlur={handleBlur}
max={max}
min={min}
suffix={suffix}
prefix={prefix}
customInput={(props) => <Input {...props} className={cn('w-full', className)} />}
placeholder={placeholder}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
getInputRef={ref}
{...props}
/>
<div className="absolute top-0 right-0 bottom-0 flex flex-col">
<Button
aria-label="Increase value"
className="border-input h-1/2 rounded-l-none rounded-br-none border-b border-l px-2 focus-visible:relative"
variant="outline"
onClick={handleIncrement}
disabled={value === max}
>
<ChevronUp size={15} />
</Button>
<Button
aria-label="Decrease value"
className="border-input h-1/2 rounded-l-none rounded-tr-none border-b border-l px-2 focus-visible:relative"
variant="outline"
onClick={handleDecrement}
disabled={value === min}
>
<ChevronDown size={15} />
</Button>
</div>
</div>
)
}
)
NumberInput.displayName = 'NumberInput'
export default NumberInput
@@ -0,0 +1,262 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from './Button'
import Input from './Input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './Select'
import { cn } from '@/lib/utils'
import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react'
export type PaginationControlsProps = {
currentPage: number
totalPages: number
pageSize: number
totalCount: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
isLoading?: boolean
compact?: boolean
className?: string
}
const PAGE_SIZE_OPTIONS = [
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 50, label: '50' },
{ value: 100, label: '100' },
{ value: 200, label: '200' }
]
export default function PaginationControls({
currentPage,
totalPages,
pageSize,
totalCount,
onPageChange,
onPageSizeChange,
isLoading = false,
compact = false,
className
}: PaginationControlsProps) {
const { t } = useTranslation()
const [inputPage, setInputPage] = useState(currentPage.toString())
// Sync input when currentPage changes externally (render-time comparison
// avoids cascading renders flagged by react-hooks/set-state-in-effect).
const [previousCurrentPage, setPreviousCurrentPage] = useState(currentPage)
if (currentPage !== previousCurrentPage) {
setPreviousCurrentPage(currentPage)
setInputPage(currentPage.toString())
}
// Handle page input change with debouncing
const handlePageInputChange = useCallback((value: string) => {
setInputPage(value)
}, [])
// Handle page input submit
const handlePageInputSubmit = useCallback(() => {
const pageNum = parseInt(inputPage, 10)
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
onPageChange(pageNum)
} else {
// Reset to current page if invalid
setInputPage(currentPage.toString())
}
}, [inputPage, totalPages, onPageChange, currentPage])
// Handle page input key press
const handlePageInputKeyPress = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handlePageInputSubmit()
}
}, [handlePageInputSubmit])
// Handle page size change
const handlePageSizeChange = useCallback((value: string) => {
const newPageSize = parseInt(value, 10)
if (!isNaN(newPageSize)) {
onPageSizeChange(newPageSize)
}
}, [onPageSizeChange])
// Navigation handlers
const goToFirstPage = useCallback(() => {
if (currentPage > 1 && !isLoading) {
onPageChange(1)
}
}, [currentPage, onPageChange, isLoading])
const goToPrevPage = useCallback(() => {
if (currentPage > 1 && !isLoading) {
onPageChange(currentPage - 1)
}
}, [currentPage, onPageChange, isLoading])
const goToNextPage = useCallback(() => {
if (currentPage < totalPages && !isLoading) {
onPageChange(currentPage + 1)
}
}, [currentPage, totalPages, onPageChange, isLoading])
const goToLastPage = useCallback(() => {
if (currentPage < totalPages && !isLoading) {
onPageChange(totalPages)
}
}, [currentPage, totalPages, onPageChange, isLoading])
if (totalPages <= 1) {
return null
}
if (compact) {
return (
<div className={cn('flex items-center gap-2', className)}>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={goToPrevPage}
disabled={currentPage <= 1 || isLoading}
className="h-8 w-8 p-0"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<Input
type="text"
value={inputPage}
onChange={(e) => handlePageInputChange(e.target.value)}
onBlur={handlePageInputSubmit}
onKeyPress={handlePageInputKeyPress}
disabled={isLoading}
className="h-8 w-12 text-center text-sm"
/>
<span className="text-sm text-gray-500">/ {totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= totalPages || isLoading}
className="h-8 w-8 p-0"
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
</div>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
disabled={isLoading}
>
<SelectTrigger className="h-8 w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
return (
<div className={cn('flex items-center justify-between gap-4', className)}>
<div className="text-sm text-gray-500">
{t('pagination.showing', {
start: Math.min((currentPage - 1) * pageSize + 1, totalCount),
end: Math.min(currentPage * pageSize, totalCount),
total: totalCount
})}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={goToFirstPage}
disabled={currentPage <= 1 || isLoading}
className="h-8 w-8 p-0"
tooltip={t('pagination.firstPage')}
>
<ChevronsLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={goToPrevPage}
disabled={currentPage <= 1 || isLoading}
className="h-8 w-8 p-0"
tooltip={t('pagination.prevPage')}
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<span className="text-sm">{t('pagination.page')}</span>
<Input
type="text"
value={inputPage}
onChange={(e) => handlePageInputChange(e.target.value)}
onBlur={handlePageInputSubmit}
onKeyPress={handlePageInputKeyPress}
disabled={isLoading}
className="h-8 w-16 text-center text-sm"
/>
<span className="text-sm">/ {totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= totalPages || isLoading}
className="h-8 w-8 p-0"
tooltip={t('pagination.nextPage')}
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={goToLastPage}
disabled={currentPage >= totalPages || isLoading}
className="h-8 w-8 p-0"
tooltip={t('pagination.lastPage')}
>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">{t('pagination.pageSize')}</span>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
disabled={isLoading}
>
<SelectTrigger className="h-8 w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
)
}
@@ -0,0 +1,39 @@
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
// Define the props type to include positioning props
type PopoverContentProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
sticky?: 'partial' | 'always';
avoidCollisions?: boolean;
};
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
PopoverContentProps
>(({ className, align = 'center', sideOffset = 4, collisionPadding, sticky, avoidCollisions = false, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
sticky={sticky}
avoidCollisions={avoidCollisions}
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,23 @@
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ComponentRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('bg-secondary relative h-4 w-full overflow-hidden rounded-full', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export default Progress
@@ -0,0 +1,44 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+151
View File
@@ -0,0 +1,151 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton
}
@@ -0,0 +1,24 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export default Separator
@@ -0,0 +1,37 @@
import React, { useEffect } from 'react';
import { useTabVisibility } from '@/contexts/useTabVisibility';
interface TabContentProps {
tabId: string;
children: React.ReactNode;
className?: string;
}
/**
* TabContent component that manages visibility based on tab selection
* Works with the TabVisibilityContext to show/hide content based on active tab
*/
const TabContent: React.FC<TabContentProps> = ({ tabId, children, className = '' }) => {
const { isTabVisible, setTabVisibility } = useTabVisibility();
const isVisible = isTabVisible(tabId);
// Register this tab with the context when mounted
useEffect(() => {
setTabVisibility(tabId, true);
// Cleanup when unmounted
return () => {
setTabVisibility(tabId, false);
};
}, [tabId, setTabVisibility]);
// Use CSS to hide content instead of not rendering it
// This prevents components from unmounting when tabs are switched
return (
<div className={`${className} ${isVisible ? '' : 'hidden'}`}>
{children}
</div>
);
};
export default TabContent;
@@ -0,0 +1,96 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
)
)
Table.displayName = 'Table'
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
))
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
))
TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className
)}
{...props}
/>
)
)
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
// eslint-disable-next-line react/prop-types
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
// eslint-disable-next-line react/prop-types
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn('text-muted-foreground mt-4 text-sm', className)} {...props} />
))
TableCaption.displayName = 'TableCaption'
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
+57
View File
@@ -0,0 +1,57 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'data-[state=inactive]:invisible data-[state=active]:visible',
'h-full w-full',
className
)}
// Force mounting of inactive tabs to preserve WebGL contexts
forceMount
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+49
View File
@@ -0,0 +1,49 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
import { cn } from '@/lib/utils'
const Text = ({
text,
className,
tooltipClassName,
tooltip,
side,
onClick
}: {
text: string
className?: string
tooltipClassName?: string
tooltip?: string
side?: 'top' | 'right' | 'bottom' | 'left'
onClick?: () => void
}) => {
if (!tooltip) {
return (
<label
className={cn(className, onClick !== undefined ? 'cursor-pointer' : undefined)}
onClick={onClick}
>
{text}
</label>
)
}
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<label
className={cn(className, onClick !== undefined ? 'cursor-pointer' : undefined)}
onClick={onClick}
>
{text}
</label>
</TooltipTrigger>
<TooltipContent side={side} className={tooltipClassName}>
{tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export default Text
@@ -0,0 +1,25 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
className?: string
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export default Textarea
@@ -0,0 +1,52 @@
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 processTooltipContent = (content: string) => {
if (typeof content !== 'string') return content
return (
<div className="relative top-0 pt-1 whitespace-pre-wrap break-words">
{content}
</div>
)
}
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
}
>(({ className, side = 'left', align = 'start', children, ...props }, ref) => {
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = 0;
}
}, [children]);
return (
<TooltipPrimitive.Content
ref={ref}
side={side}
align={align}
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 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md z-60',
className
)}
{...props}
>
{typeof children === 'string' ? processTooltipContent(children) : children}
</TooltipPrimitive.Content>
);
})
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,203 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { ChevronDown, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import Input from './Input'
interface UserPromptInputWithHistoryProps {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
id?: string
history: string[]
onSelectFromHistory: (prompt: string) => void
onDeleteFromHistory?: (index: number) => void
}
export default function UserPromptInputWithHistory({
value,
onChange,
placeholder,
className,
id,
history,
onSelectFromHistory,
onDeleteFromHistory
}: UserPromptInputWithHistoryProps) {
const [isOpen, setIsOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const [isHovered, setIsHovered] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
setSelectedIndex(-1)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
// Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === 'ArrowDown' && history.length > 0) {
e.preventDefault()
setIsOpen(true)
setSelectedIndex(0)
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev =>
prev < history.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)
if (selectedIndex === 0) {
setSelectedIndex(-1)
}
break
case 'Enter':
if (selectedIndex >= 0 && selectedIndex < history.length) {
e.preventDefault()
const selectedPrompt = history[selectedIndex]
onSelectFromHistory(selectedPrompt)
setIsOpen(false)
setSelectedIndex(-1)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
setSelectedIndex(-1)
break
}
}, [isOpen, selectedIndex, history, onSelectFromHistory])
const handleInputClick = () => {
if (history.length > 0) {
setIsOpen(!isOpen)
setSelectedIndex(-1)
}
}
const handleDropdownItemClick = (prompt: string) => {
onSelectFromHistory(prompt)
setIsOpen(false)
setSelectedIndex(-1)
inputRef.current?.focus()
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleMouseEnter = () => {
setIsHovered(true)
}
const handleMouseLeave = () => {
setIsHovered(false)
}
// Handle delete history item with boundary cases
const handleDeleteHistoryItem = useCallback((index: number, e: React.MouseEvent) => {
e.stopPropagation() // Prevent triggering item selection
onDeleteFromHistory?.(index)
// Handle boundary cases
if (history.length === 1) {
// Deleting the last item, close dropdown
setIsOpen(false)
setSelectedIndex(-1)
} else if (selectedIndex === index) {
// Deleting currently selected item, adjust selection
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)
} else if (selectedIndex > index) {
// Deleting item before selected item, adjust index
setSelectedIndex(prev => prev - 1)
}
}, [onDeleteFromHistory, history.length, selectedIndex])
return (
<div className="relative" ref={dropdownRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div className="relative">
<Input
ref={inputRef}
id={id}
value={value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onClick={handleInputClick}
placeholder={placeholder}
autoComplete="off"
className={cn(isHovered && history.length > 0 ? 'pr-5' : 'pr-2', 'w-full', className)}
/>
{isHovered && history.length > 0 && (
<button
type="button"
onClick={handleInputClick}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
tabIndex={-1}
>
<ChevronDown
className={cn(
'h-3 w-3 transition-transform duration-200 text-gray-500',
isOpen && 'rotate-180'
)}
/>
</button>
)}
</div>
{/* Dropdown */}
{isOpen && history.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-0.5 bg-gray-100 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-96 overflow-auto min-w-0">
{history.map((prompt, index) => (
<div
key={index}
className={cn(
'flex items-center justify-between pl-3 pr-1 py-2 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors',
'border-b border-gray-100 dark:border-gray-700 last:border-b-0',
'focus-within:bg-gray-100 dark:focus-within:bg-gray-700',
selectedIndex === index && 'bg-gray-100 dark:bg-gray-700'
)}
>
<button
type="button"
onClick={() => handleDropdownItemClick(prompt)}
className="flex-1 text-left truncate focus:outline-none mr-0"
title={prompt}
>
{prompt}
</button>
{onDeleteFromHistory && (
<button
type="button"
onClick={(e) => handleDeleteHistoryItem(index, e)}
className="flex-shrink-0 p-0 rounded hover:bg-red-100 dark:hover:bg-red-900 transition-colors focus:outline-none ml-auto"
title="Delete this history item"
>
<X className="h-3 w-3 text-gray-400 hover:text-red-500" />
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,62 @@
import React, { useState, useEffect, useMemo } from 'react';
import { TabVisibilityContext } from './context';
import { TabVisibilityContextType } from './types';
import { useSettingsStore } from '@/stores/settings';
interface TabVisibilityProviderProps {
children: React.ReactNode;
}
/**
* Provider component for the TabVisibility context
* Manages the visibility state of tabs throughout the application
*/
export const TabVisibilityProvider: React.FC<TabVisibilityProviderProps> = ({ children }) => {
// Get current tab from settings store
const currentTab = useSettingsStore.use.currentTab();
// Initialize visibility state with all tabs visible
const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({
'documents': true,
'knowledge-graph': true,
'retrieval': true,
'api': true
}));
// Keep all tabs visible because we use CSS to control TAB visibility instead of React.
// TabContent sets its tab to false on unmount (cleanup); this effect resets them to true
// whenever the current tab changes, acting as a safety net against stale false values.
useEffect(() => {
const timer = setTimeout(() => setVisibleTabs((prev) => ({
...prev,
'documents': true,
'knowledge-graph': true,
'retrieval': true,
'api': true
})), 0)
return () => clearTimeout(timer)
}, [currentTab]);
// Create the context value with memoization to prevent unnecessary re-renders
const contextValue = useMemo<TabVisibilityContextType>(
() => ({
visibleTabs,
setTabVisibility: (tabId: string, isVisible: boolean) => {
setVisibleTabs((prev) => ({
...prev,
[tabId]: isVisible,
}));
},
isTabVisible: (tabId: string) => !!visibleTabs[tabId],
}),
[visibleTabs]
);
return (
<TabVisibilityContext.Provider value={contextValue}>
{children}
</TabVisibilityContext.Provider>
);
};
export default TabVisibilityProvider;
+12
View File
@@ -0,0 +1,12 @@
import { createContext } from 'react';
import { TabVisibilityContextType } from './types';
// Default context value
const defaultContext: TabVisibilityContextType = {
visibleTabs: {},
setTabVisibility: () => {},
isTabVisible: () => false,
};
// Create the context
export const TabVisibilityContext = createContext<TabVisibilityContextType>(defaultContext);
+5
View File
@@ -0,0 +1,5 @@
export interface TabVisibilityContextType {
visibleTabs: Record<string, boolean>;
setTabVisibility: (tabId: string, isVisible: boolean) => void;
isTabVisible: (tabId: string) => boolean;
}
@@ -0,0 +1,17 @@
import { useContext } from 'react';
import { TabVisibilityContext } from './context';
import { TabVisibilityContextType } from './types';
/**
* Custom hook to access the tab visibility context
* @returns The tab visibility context
*/
export const useTabVisibility = (): TabVisibilityContextType => {
const context = useContext(TabVisibilityContext);
if (!context) {
throw new Error('useTabVisibility must be used within a TabVisibilityProvider');
}
return context;
};
+73
View File
@@ -0,0 +1,73 @@
import { useState, useEffect, useRef } from 'react'
import { useTabVisibility } from '@/contexts/useTabVisibility'
import { backendBaseUrl } from '@/lib/constants'
import { useTranslation } from 'react-i18next'
const getRootTheme = (): 'light' | 'dark' =>
window.document.documentElement.classList.contains('dark') ? 'dark' : 'light'
export default function ApiSite() {
const { t } = useTranslation()
const { isTabVisible } = useTabVisibility()
const isApiTabVisible = isTabVisible('api')
const [iframeLoaded, setIframeLoaded] = useState(false)
const [docsTheme, setDocsTheme] = useState<'light' | 'dark'>(getRootTheme)
// Freeze the initial theme so the iframe src never changes after mount;
// subsequent theme switches are pushed via postMessage to avoid reloading
// the entire Swagger UI on every toggle.
const [initialTheme] = useState(docsTheme)
const iframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => {
const timer = setTimeout(() => setIframeLoaded(true), 0)
return () => clearTimeout(timer)
}, [])
useEffect(() => {
const root = window.document.documentElement
const syncDocsTheme = () => setDocsTheme(getRootTheme())
syncDocsTheme()
const observer = new MutationObserver(syncDocsTheme)
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
useEffect(() => {
iframeRef.current?.contentWindow?.postMessage(
{ type: 'lightrag:set-docs-theme', theme: docsTheme },
'*'
)
}, [docsTheme])
const handleIframeLoad = () => {
iframeRef.current?.contentWindow?.postMessage(
{ type: 'lightrag:set-docs-theme', theme: docsTheme },
'*'
)
}
// Use CSS to hide content when tab is not visible
return (
<div className={`size-full ${isApiTabVisible ? '' : 'hidden'}`}>
{iframeLoaded ? (
<iframe
ref={iframeRef}
src={`${backendBaseUrl}/docs?theme=${initialTheme}`}
className="size-full w-full h-full"
style={{ width: '100%', height: '100%', border: 'none' }}
onLoad={handleIframeLoad}
key="api-docs-iframe"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="text-center">
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<p>{t('apiSite.loading')}</p>
</div>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
+325
View File
@@ -0,0 +1,325 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
// import { MiniMap } from '@react-sigma/minimap'
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
import { Settings as SigmaSettings } from 'sigma/settings'
import { GraphSearchOption, OptionItem } from '@react-sigma/graph-search'
import {
EdgeArrowProgram,
EdgeLineProgram,
EdgeRectangleProgram,
NodePointProgram,
NodeCircleProgram
} from 'sigma/rendering'
import { NodeBorderProgram } from '@sigma/node-border'
import { EdgeCurvedArrowProgram, createEdgeCurveProgram } from '@sigma/edge-curve'
import FocusOnNode from '@/components/graph/FocusOnNode'
import LayoutsControl from '@/components/graph/LayoutsControl'
import GraphControl from '@/components/graph/GraphControl'
// import ThemeToggle from '@/components/ThemeToggle'
import ZoomControl from '@/components/graph/ZoomControl'
import FullScreenControl from '@/components/graph/FullScreenControl'
import Settings from '@/components/graph/Settings'
import GraphSearch from '@/components/graph/GraphSearch'
import GraphLabels from '@/components/graph/GraphLabels'
import PropertiesView from '@/components/graph/PropertiesView'
import SettingsDisplay from '@/components/graph/SettingsDisplay'
import Legend from '@/components/graph/Legend'
import LegendButton from '@/components/graph/LegendButton'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import useIsDarkMode from '@/hooks/useIsDarkMode'
import { labelColorDarkTheme, labelColorLightTheme, edgeColorDarkTheme, EDGE_PERF_LIMIT } from '@/lib/constants'
import '@react-sigma/core/lib/style.css'
import '@react-sigma/graph-search/lib/style.css'
// Function to create sigma settings based on theme.
// `enableEdgeEvents` MUST be passed in (not toggled at runtime): sigma allocates
// the edge WebGL picking buffer once at construction based on this flag, so a
// later setSettings({ enableEdgeEvents: true }) cannot retroactively enable edge
// hover/click. We therefore key it off the user setting here and let the
// SigmaContainer rebuild the instance when the setting changes.
const createSigmaSettings = (
isDarkTheme: boolean,
enableEdgeEvents: boolean
): Partial<SigmaSettings> => ({
allowInvalidContainer: true,
// Nodes use the border program so every node gets a white ring (the
// @sigma/node-border default reads `borderColor` for the ring, `color` for
// the fill; ring thickness is a fixed 10% of the radius). Single GPU draw
// call -- cheap even at 70k+ nodes. (Earlier "border blanks the canvas"
// reports were actually the fetch effect never firing -- once that was
// fixed, the border program rendered fine.)
defaultNodeType: 'border',
// 'rect' (EdgeRectangleProgram) draws straight quads that RESPECT the edge
// size attribute; EdgeLineProgram renders fixed ~1px GL lines and ignores
// size entirely. Rectangles are still far cheaper than tessellated curves.
defaultEdgeType: 'rect',
renderEdgeLabels: false,
// Skip edge rendering while panning/zooming - big win on large graphs.
hideEdgesOnMove: true,
edgeProgramClasses: {
rect: EdgeRectangleProgram,
line: EdgeLineProgram,
arrow: EdgeArrowProgram,
curvedArrow: EdgeCurvedArrowProgram,
// kept registered for backward compatibility with edges that still
// carry type: 'curvedNoArrow'
curvedNoArrow: createEdgeCurveProgram()
},
nodeProgramClasses: {
point: NodePointProgram,
default: NodePointProgram,
circle: NodeCircleProgram,
border: NodeBorderProgram
},
labelGridCellSize: 60,
labelRenderedSizeThreshold: 12,
// Off by default (edge picking renders edges to an extra buffer every frame —
// costly on large graphs); turning on the "Edge Events" setting rebuilds the
// instance with the picking buffer so edges become hover/click-selectable.
enableEdgeEvents,
// Without per-edge reducers (disabled when nothing is focused), edges with
// no color attribute fall back to this.
defaultEdgeColor: isDarkTheme ? edgeColorDarkTheme : '#d3d3d3',
labelColor: {
color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,
attribute: 'labelColor'
},
edgeLabelColor: {
color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,
attribute: 'labelColor'
},
edgeLabelSize: 8,
labelSize: 12
})
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: (e) => {
// Only set custom BBox if it's a drag operation (mouse button is pressed)
const mouseEvent = e.original as MouseEvent
if (mouseEvent.buttons !== 0 && !sigma.getCustomBBox()) {
sigma.setCustomBBox(sigma.getBBox())
}
}
})
}, [registerEvents, sigma, draggedNode])
return null
}
const GraphViewer = () => {
const sigmaRef = useRef<any>(null)
const prevTheme = useRef<string>('')
const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode()
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
const isFetching = useGraphStore.use.isFetching()
const isLayoutComputing = useGraphStore.use.isLayoutComputing()
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
const showLegend = useSettingsStore.use.showLegend()
const theme = useSettingsStore.use.theme()
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
// Edge events are disabled above EDGE_PERF_LIMIT regardless of the user
// setting: the picking buffer renders edges to an extra frame buffer every
// frame, which is too costly on large graphs. The user setting is preserved
// (Settings greys the menu item but keeps its value), so dropping back below
// the limit auto-restores edge events.
const effectiveEdgeEvents = enableEdgeEvents && graphEdgeCount <= EDGE_PERF_LIMIT
const [isThemeSwitching, setIsThemeSwitching] = useState(false)
// Resolve effective dark mode (handles theme === 'system' against the OS
// preference, and stays reactive to OS color-scheme changes).
const isDarkMode = useIsDarkMode()
// Memoize sigma settings to prevent unnecessary re-creation. Keyed on the
// RESOLVED dark mode, not the raw theme: under theme === 'system' + OS dark
// the old `theme === 'dark'` check produced light-theme defaults, which the
// idle-state (null reducers) graph rendered verbatim.
// enableEdgeEvents is in the deps because it must be baked in at construction
// (the picking buffer can't be added later); toggling it rebuilds the instance.
const memoizedSigmaSettings = useMemo(
() => createSigmaSettings(isDarkMode, effectiveEdgeEvents),
[isDarkMode, effectiveEdgeEvents]
)
// Detect theme changes and briefly show a loading overlay to avoid flash of
// unstyled content. setState is inside setTimeout (async), not synchronously
// in the effect body, so react-hooks/set-state-in-effect is not triggered.
useEffect(() => {
const isThemeChange = prevTheme.current && prevTheme.current !== theme
if (isThemeChange) {
console.log('Theme switching detected:', prevTheme.current, '->', theme)
prevTheme.current = theme
const switchTimer = setTimeout(() => setIsThemeSwitching(true), 0)
const timer = setTimeout(() => {
setIsThemeSwitching(false)
console.log('Theme switching completed')
}, 150)
return () => {
clearTimeout(switchTimer)
clearTimeout(timer)
}
}
prevTheme.current = theme
console.log('Initialized sigma settings for theme:', theme)
}, [theme])
// Clean up sigma instance when component unmounts
useEffect(() => {
return () => {
// TAB is mount twice in vite dev mode, this is a workaround
const sigma = useGraphStore.getState().sigmaInstance
if (sigma) {
try {
// Destroy sigmaand clear WebGL context
sigma.kill()
useGraphStore.getState().setSigmaInstance(null)
console.log('Cleared sigma instance on Graphviewer unmount')
} catch (error) {
console.error('Error cleaning up sigma instance:', error)
}
}
}
}, [])
// Note: There was a useLayoutEffect hook here to set up the sigma instance and graph data,
// but testing showed it wasn't executing or having any effect, while the backup mechanism
// in GraphControl was sufficient. This code was removed to simplify implementation
const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
if (value === null) useGraphStore.getState().setFocusedNode(null)
else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id)
}, [])
const onSearchSelect = useCallback((value: GraphSearchOption | null) => {
if (value === null) {
useGraphStore.getState().setSelectedNode(null)
} else if (value.type === 'nodes') {
useGraphStore.getState().setSelectedNode(value.id, true)
}
}, [])
const autoFocusedNode = useMemo(() => focusedNode ?? selectedNode, [focusedNode, selectedNode])
const searchInitSelectedNode = useMemo(
(): OptionItem | null => (selectedNode ? { type: 'nodes', id: selectedNode } : null),
[selectedNode]
)
// Always render SigmaContainer but control its visibility with CSS
return (
<div className="relative h-full w-full overflow-hidden">
<SigmaContainer
settings={memoizedSigmaSettings}
className="!bg-background !size-full overflow-hidden"
ref={sigmaRef}
>
<GraphControl />
{enableNodeDrag && <GraphEvents />}
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
<div className="absolute top-2 left-2 flex items-start gap-2">
<GraphLabels />
{showNodeSearchBar && !isThemeSwitching && (
<GraphSearch
value={searchInitSelectedNode}
onFocus={onSearchFocus}
onChange={onSearchSelect}
/>
)}
</div>
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
<LayoutsControl />
<ZoomControl />
<FullScreenControl />
<LegendButton />
<Settings />
{/* <ThemeToggle /> */}
</div>
{showPropertyPanel && (
<div className="absolute top-2 right-2 z-10">
<PropertiesView />
</div>
)}
{showLegend && (
<div className="absolute right-2 bottom-10 z-0">
<Legend className="bg-background/60 backdrop-blur-lg" />
</div>
)}
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
<MiniMap width="100px" height="100px" />
</div> */}
<SettingsDisplay />
</SigmaContainer>
{/* Loading overlay - shown for data fetch, theme switch, or layout run. */}
{(isFetching || isThemeSwitching || isLayoutComputing) && (
<div className="bg-background/80 absolute inset-0 z-10 flex items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
<p>
{isThemeSwitching
? 'Switching Theme...'
: isFetching
? 'Loading Graph Data...'
: 'Computing Layout...'}
</p>
</div>
</div>
)}
</div>
)
}
export default GraphViewer
+210
View File
@@ -0,0 +1,210 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/stores/state'
import { useSettingsStore } from '@/stores/settings'
import { loginToServer, getAuthStatus } from '@/api/lightrag'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader } from '@/components/ui/Card'
import Input from '@/components/ui/Input'
import Button from '@/components/ui/Button'
import { ZapIcon } from 'lucide-react'
import AppSettings from '@/components/AppSettings'
const LoginPage = () => {
const navigate = useNavigate()
const { login, isAuthenticated } = useAuthStore()
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [checkingAuth, setCheckingAuth] = useState(true)
const authCheckRef = useRef(false); // Prevent duplicate calls in Vite dev mode
useEffect(() => {
console.log('LoginPage mounted')
}, []);
// Check if authentication is configured, skip login if not
useEffect(() => {
const checkAuthConfig = async () => {
// Prevent duplicate calls in Vite dev mode
if (authCheckRef.current) {
return;
}
authCheckRef.current = true;
try {
// If already authenticated, redirect to home
if (isAuthenticated) {
navigate('/')
return
}
// Check auth status
const status = await getAuthStatus()
// Set session flag for version check to avoid duplicate checks in App component
if (status.core_version || status.api_version) {
sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');
}
if (!status.auth_configured && status.access_token) {
// If auth is not configured, use the guest token and redirect
login(status.access_token, true, status.core_version, status.api_version, status.webui_title || null, status.webui_description || null)
if (status.message) {
toast.info(status.message)
}
navigate('/')
return
}
// Only set checkingAuth to false if we need to show the login page
setCheckingAuth(false);
} catch (error) {
console.error('Failed to check auth configuration:', error)
// Also set checkingAuth to false in case of error
setCheckingAuth(false);
}
// Removed finally block as we're setting checkingAuth earlier
}
// Execute immediately
checkAuthConfig()
// Cleanup function to prevent state updates after unmount
return () => {
}
}, [isAuthenticated, login, navigate])
// Don't render anything while checking auth
if (checkingAuth) {
return null
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!username || !password) {
toast.error(t('login.errorEmptyFields'))
return
}
try {
setLoading(true)
const response = await loginToServer(username, password)
// Get previous username from localStorage
const previousUsername = localStorage.getItem('LIGHTRAG-PREVIOUS-USER')
// Check if it's the same user logging in again
const isSameUser = previousUsername === username
// If it's not the same user, clear chat history
if (isSameUser) {
console.log('Same user logging in, preserving chat history')
} else {
console.log('Different user logging in, clearing chat history')
// Directly clear chat history instead of setting a flag
useSettingsStore.getState().setRetrievalHistory([])
}
// Update previous username
localStorage.setItem('LIGHTRAG-PREVIOUS-USER', username)
// Check authentication mode
const isGuestMode = response.auth_mode === 'disabled'
login(response.access_token, isGuestMode, response.core_version, response.api_version, response.webui_title || null, response.webui_description || null)
// Set session flag for version check
if (response.core_version || response.api_version) {
sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');
}
if (isGuestMode) {
// Show authentication disabled notification
toast.info(response.message || t('login.authDisabled', 'Authentication is disabled. Using guest access.'))
} else {
toast.success(t('login.successMessage'))
}
// Navigate to home page after successful login
navigate('/')
} catch (error) {
console.error('Login failed...', error)
toast.error(t('login.errorInvalidCredentials'))
// Clear any existing auth state
useAuthStore.getState().logout()
// Clear local storage
localStorage.removeItem('LIGHTRAG-API-TOKEN')
} finally {
setLoading(false)
}
}
return (
<div className="flex h-screen w-screen items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-gray-800">
<div className="absolute top-4 right-4 flex items-center gap-2">
<AppSettings className="bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md" />
</div>
<Card className="w-full max-w-[480px] shadow-lg mx-4">
<CardHeader className="flex items-center justify-center space-y-2 pb-8 pt-6">
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<img src="logo.svg" alt="LightRAG Logo" className="h-12 w-12" />
<ZapIcon className="size-10 text-emerald-400" aria-hidden="true" />
</div>
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold tracking-tight">LightRAG</h1>
<p className="text-muted-foreground text-sm">
{t('login.description')}
</p>
</div>
</div>
</CardHeader>
<CardContent className="px-8 pb-8">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex items-center gap-4">
<label htmlFor="username-input" className="text-sm font-medium w-16 shrink-0">
{t('login.username')}
</label>
<Input
id="username-input"
placeholder={t('login.usernamePlaceholder')}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="h-11 flex-1"
/>
</div>
<div className="flex items-center gap-4">
<label htmlFor="password-input" className="text-sm font-medium w-16 shrink-0">
{t('login.password')}
</label>
<Input
id="password-input"
type="password"
placeholder={t('login.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-11 flex-1"
/>
</div>
<Button
type="submit"
className="w-full h-11 text-base font-medium mt-2"
disabled={loading}
>
{loading ? t('login.loggingIn') : t('login.loginButton')}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
export default LoginPage
@@ -0,0 +1,920 @@
import Textarea from '@/components/ui/Textarea'
import Input from '@/components/ui/Input'
import Button from '@/components/ui/Button'
import { useCallback, useEffect, useRef, useState } from 'react'
import { throttle } from '@/lib/utils'
import { queryText, queryTextStream } from '@/api/lightrag'
import { errorMessage } from '@/lib/utils'
import { useSettingsStore } from '@/stores/settings'
import { useDebounce } from '@/hooks/useDebounce'
import QuerySettings from '@/components/retrieval/QuerySettings'
import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage'
import { EraserIcon, SendIcon, CopyIcon, SquareIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/utils/clipboard'
import type { QueryMode } from '@/api/lightrag'
// Helper function to generate unique IDs with browser compatibility
const generateUniqueId = () => {
// Use crypto.randomUUID() if available
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback to timestamp + random string for browsers without crypto.randomUUID
return `id-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
// LaTeX completeness detection function
const detectLatexCompleteness = (content: string): boolean => {
// Check for unclosed block-level LaTeX formulas ($$...$$)
const blockLatexMatches = content.match(/\$\$/g) || []
const hasUnclosedBlock = blockLatexMatches.length % 2 !== 0
// Check for unclosed inline LaTeX formulas ($...$, but not $$)
// Remove all block formulas first to avoid interference
const contentWithoutBlocks = content.replace(/\$\$[\s\S]*?\$\$/g, '')
const inlineLatexMatches = contentWithoutBlocks.match(/(?<!\$)\$(?!\$)/g) || []
const hasUnclosedInline = inlineLatexMatches.length % 2 !== 0
// LaTeX is complete if there are no unclosed formulas
return !hasUnclosedBlock && !hasUnclosedInline
}
// Robust COT parsing function to handle multiple think blocks and edge cases
const parseCOTContent = (content: string) => {
const thinkStartTag = '<think>'
const thinkEndTag = '</think>'
// Find all <think> and </think> tag positions
const startMatches: number[] = []
const endMatches: number[] = []
let startIndex = 0
while ((startIndex = content.indexOf(thinkStartTag, startIndex)) !== -1) {
startMatches.push(startIndex)
startIndex += thinkStartTag.length
}
let endIndex = 0
while ((endIndex = content.indexOf(thinkEndTag, endIndex)) !== -1) {
endMatches.push(endIndex)
endIndex += thinkEndTag.length
}
// Analyze COT state
const hasThinkStart = startMatches.length > 0
const hasThinkEnd = endMatches.length > 0
const isThinking = hasThinkStart && (startMatches.length > endMatches.length)
let thinkingContent = ''
let displayContent = content
if (hasThinkStart) {
if (hasThinkEnd && startMatches.length === endMatches.length) {
// Complete thinking blocks: extract the last complete thinking content
const lastStartIndex = startMatches[startMatches.length - 1]
const lastEndIndex = endMatches[endMatches.length - 1]
if (lastEndIndex > lastStartIndex) {
thinkingContent = content.substring(
lastStartIndex + thinkStartTag.length,
lastEndIndex
).trim()
// Remove all thinking blocks, keep only the final display content
displayContent = content.substring(lastEndIndex + thinkEndTag.length).trim()
}
} else if (isThinking) {
// Currently thinking: extract current thinking content
const lastStartIndex = startMatches[startMatches.length - 1]
thinkingContent = content.substring(lastStartIndex + thinkStartTag.length)
displayContent = ''
}
}
return {
isThinking,
thinkingContent,
displayContent,
hasValidThinkBlock: hasThinkStart && hasThinkEnd && startMatches.length === endMatches.length
}
}
export default function RetrievalView() {
const { t } = useTranslation()
// Get current tab to determine if this tab is active (for performance optimization)
const currentTab = useSettingsStore.use.currentTab()
const isRetrievalTabActive = currentTab === 'retrieval'
const [messages, setMessages] = useState<MessageWithError[]>(() => {
try {
const history = useSettingsStore.getState().retrievalHistory || []
// Ensure each message from history has a unique ID and mermaidRendered status
return history.map((msg, index) => {
try {
const msgWithError = msg as MessageWithError // Cast to access potential properties
return {
...msg,
id: msgWithError.id || `hist-${Date.now()}-${index}`, // Add ID if missing
mermaidRendered: msgWithError.mermaidRendered ?? true, // Assume historical mermaid is rendered
latexRendered: msgWithError.latexRendered ?? true // Assume historical LaTeX is rendered
}
} catch (error) {
console.error('Error processing message:', error)
// Return a default message if there's an error
return {
role: 'system',
content: 'Error loading message',
id: `error-${Date.now()}-${index}`,
isError: true,
mermaidRendered: true
}
}
})
} catch (error) {
console.error('Error loading history:', error)
return [] // Return an empty array if there's an error
}
})
const [inputValue, setInputValue] = useState('')
const [isLoading, setIsLoading] = useState(false)
// Briefly disable the Stop button right after a query starts so a fast
// double-click on Send (which morphs into Stop at the same position) can't
// accidentally abort the query it just launched.
const [stopDisabled, setStopDisabled] = useState(false)
const stopCooldownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [inputError, setInputError] = useState('') // Error message for input
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
// Smart switching logic: use Input for single line, Textarea for multi-line
const hasMultipleLines = inputValue.includes('\n')
// Enhanced event handlers for smart switching
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setInputValue(e.target.value)
if (inputError) setInputError('')
}, [inputError])
// Unified height adjustment function for textarea
const adjustTextareaHeight = useCallback((element: HTMLTextAreaElement) => {
requestAnimationFrame(() => {
element.style.height = 'auto'
element.style.height = Math.min(element.scrollHeight, 120) + 'px'
})
}, [])
// Scroll to bottom function - restored smooth scrolling with better handling
const scrollToBottom = useCallback(() => {
// Set flag to indicate this is a programmatic scroll
programmaticScrollRef.current = true
// Use requestAnimationFrame for better performance
requestAnimationFrame(() => {
if (messagesEndRef.current) {
// Use smooth scrolling for better user experience
messagesEndRef.current.scrollIntoView({ behavior: 'auto' })
}
})
}, [])
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
if (!inputValue.trim() || isLoading) return
// Parse query mode prefix
const allowedModes: QueryMode[] = ['naive', 'local', 'global', 'hybrid', 'mix', 'bypass']
const prefixMatch = inputValue.match(/^\/(\w+)\s+([\s\S]+)/)
let modeOverride: QueryMode | undefined = undefined
let actualQuery = inputValue
// If input starts with a slash, but does not match the valid prefix pattern, treat as error
if (/^\/\S+/.test(inputValue) && !prefixMatch) {
setInputError(t('retrievePanel.retrieval.queryModePrefixInvalid'))
return
}
if (prefixMatch) {
const mode = prefixMatch[1] as QueryMode
const query = prefixMatch[2]
if (!allowedModes.includes(mode)) {
setInputError(
t('retrievePanel.retrieval.queryModeError', {
modes: 'naive, local, global, hybrid, mix, bypass',
})
)
return
}
modeOverride = mode
actualQuery = query
}
// Clear error message
setInputError('')
// Reset thinking timer state for new query to prevent confusion
thinkingStartTime.current = null
thinkingProcessed.current = false
// Create messages
// Save the original input (with prefix if any) in userMessage.content for display
const userMessage: MessageWithError = {
id: generateUniqueId(), // Use browser-compatible ID generation
content: inputValue,
role: 'user'
}
const assistantMessage: MessageWithError = {
id: generateUniqueId(), // Use browser-compatible ID generation
content: '',
role: 'assistant',
mermaidRendered: false,
latexRendered: false, // Explicitly initialize to false
thinkingTime: null, // Explicitly initialize to null
thinkingContent: undefined, // Explicitly initialize to undefined
displayContent: undefined, // Explicitly initialize to undefined
isThinking: false // Explicitly initialize to false
}
const prevMessages = [...messages]
// Create an abort controller so the user can terminate this query via the
// Stop button. Track the active assistant message so handleStop can mark it.
const controller = new AbortController()
abortControllerRef.current = controller
activeAssistantIdRef.current = assistantMessage.id
// Add messages to chatbox
setMessages([...prevMessages, userMessage, assistantMessage])
// Reset scroll following state for new query
shouldFollowScrollRef.current = true
// Set flag to indicate we're receiving a response
isReceivingResponseRef.current = true
// Force scroll to bottom after messages are rendered
setTimeout(() => {
scrollToBottom()
}, 0)
// Clear input and set loading
setInputValue('')
setIsLoading(true)
// Disable the Stop button for a short cooldown so a fast double-click on
// Send doesn't immediately abort this query. Set synchronously (same batch
// as setIsLoading) so the Stop button is already disabled on its first
// paint, leaving no enabled gap for the second click to land in.
setStopDisabled(true)
if (stopCooldownTimerRef.current) clearTimeout(stopCooldownTimerRef.current)
stopCooldownTimerRef.current = setTimeout(() => setStopDisabled(false), 500)
// Reset input height to minimum after clearing input
if (inputRef.current) {
if ('style' in inputRef.current) {
inputRef.current.style.height = '40px'
}
}
// Create a function to update the assistant's message
const updateAssistantMessage = (chunk: string, isError?: boolean) => {
assistantMessage.content += chunk
// Start thinking timer on first sight of think tag
if (assistantMessage.content.includes('<think>') && !thinkingStartTime.current) {
thinkingStartTime.current = Date.now()
}
// Use the new robust COT parsing function
const cotResult = parseCOTContent(assistantMessage.content)
// Update thinking state
assistantMessage.isThinking = cotResult.isThinking
// Only calculate time and extract thinking content once when thinking is complete
if (cotResult.hasValidThinkBlock && !thinkingProcessed.current) {
if (thinkingStartTime.current && !assistantMessage.thinkingTime) {
const duration = (Date.now() - thinkingStartTime.current) / 1000
assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))
}
thinkingProcessed.current = true
}
// Update content based on parsing results
assistantMessage.thinkingContent = cotResult.thinkingContent
// Only fallback to full content if not in a thinking state.
if (cotResult.isThinking) {
assistantMessage.displayContent = ''
} else {
assistantMessage.displayContent = cotResult.displayContent || assistantMessage.content
}
// Detect if the assistant message contains a complete mermaid code block
// Simple heuristic: look for ```mermaid ... ```
const mermaidBlockRegex = /```mermaid\s+([\s\S]+?)```/g
let mermaidRendered = false
let match
while ((match = mermaidBlockRegex.exec(assistantMessage.content)) !== null) {
// If the block is not too short, consider it complete
if (match[1] && match[1].trim().length > 10) {
mermaidRendered = true
break
}
}
assistantMessage.mermaidRendered = mermaidRendered
// Detect if the assistant message contains complete LaTeX formulas
const latexRendered = detectLatexCompleteness(assistantMessage.content)
assistantMessage.latexRendered = latexRendered
// Single unified update to avoid race conditions
setMessages((prev) => {
const newMessages = [...prev]
const lastMessage = newMessages[newMessages.length - 1]
if (lastMessage && lastMessage.id === assistantMessage.id) {
// Update all properties at once to maintain consistency
Object.assign(lastMessage, {
content: assistantMessage.content,
thinkingContent: assistantMessage.thinkingContent,
displayContent: assistantMessage.displayContent,
isThinking: assistantMessage.isThinking,
isError: isError,
mermaidRendered: assistantMessage.mermaidRendered,
latexRendered: assistantMessage.latexRendered,
thinkingTime: assistantMessage.thinkingTime
})
}
return newMessages
})
// After updating content, scroll to bottom if auto-scroll is enabled
// Use a longer delay to ensure DOM has updated
if (shouldFollowScrollRef.current) {
setTimeout(() => {
scrollToBottom()
}, 30)
}
}
// Prepare query parameters
const state = useSettingsStore.getState()
// Add user prompt to history if it exists and is not empty
if (state.querySettings.user_prompt && state.querySettings.user_prompt.trim()) {
state.addUserPromptToHistory(state.querySettings.user_prompt.trim())
}
// Determine the effective mode
const effectiveMode = modeOverride || state.querySettings.mode
// Determine effective history turns with bypass override
const configuredHistoryTurns = state.querySettings.history_turns || 0
const effectiveHistoryTurns = (effectiveMode === 'bypass' && configuredHistoryTurns === 0)
? 3
: configuredHistoryTurns
const queryParams = {
...state.querySettings,
query: actualQuery,
response_type: 'Multiple Paragraphs',
conversation_history: effectiveHistoryTurns > 0
? prevMessages
.filter((m) => m.isError !== true)
.slice(-effectiveHistoryTurns * 2)
.map((m) => ({ role: m.role, content: m.content }))
: [],
...(modeOverride ? { mode: modeOverride } : {})
}
try {
// Run query
if (state.querySettings.stream) {
let errorMessage = ''
await queryTextStream(queryParams, updateAssistantMessage, (error) => {
errorMessage += error
}, controller.signal)
if (errorMessage) {
if (assistantMessage.content) {
errorMessage = assistantMessage.content + '\n' + errorMessage
}
updateAssistantMessage(errorMessage, true)
}
} else {
const response = await queryText(queryParams, controller.signal)
updateAssistantMessage(response.response)
}
} catch (err) {
// If the user terminated the query, handleStop already finalized the
// message state; don't render it as an error.
if (!controller.signal.aborted) {
updateAssistantMessage(`${t('retrievePanel.retrieval.error')}\n${errorMessage(err)}`, true)
}
} finally {
// Only the owning, non-terminated query runs global cleanup. A
// terminated query has its controller nulled by handleStop (which also
// persists the terminated history), and a superseded query no longer
// owns the ref — both skip here so we don't reset the shared thinking
// refs / isLoading / history out from under a freshly started query
// (which would break its COT animation) or undo a post-stop Clear.
if (abortControllerRef.current === controller) {
// Clear loading and add messages to state
setIsLoading(false)
isReceivingResponseRef.current = false
abortControllerRef.current = null
// Enhanced cleanup with error handling to prevent memory leaks
try {
// Final COT state validation and cleanup
const finalCotResult = parseCOTContent(assistantMessage.content)
// Force set final state - stream ended so thinking must be false
assistantMessage.isThinking = false
// If we have a complete thinking block but time wasn't calculated, do final calculation
if (finalCotResult.hasValidThinkBlock && thinkingStartTime.current && !assistantMessage.thinkingTime) {
const duration = (Date.now() - thinkingStartTime.current) / 1000
assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))
}
// Ensure display content is correctly set based on final parsing
if (finalCotResult.displayContent !== undefined) {
assistantMessage.displayContent = finalCotResult.displayContent
}
} catch (error) {
console.error('Error in final COT state validation:', error)
// Force reset state on error
assistantMessage.isThinking = false
} finally {
// Ensure cleanup happens regardless of errors
thinkingStartTime.current = null
}
// Save history with error handling
try {
useSettingsStore
.getState()
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
} catch (error) {
console.error('Error saving retrieval history:', error)
}
}
}
},
[inputValue, isLoading, messages, setMessages, t, scrollToBottom]
)
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (e.key === 'Enter' && e.shiftKey) {
// Shift+Enter: Insert newline
e.preventDefault()
const target = e.target as HTMLInputElement | HTMLTextAreaElement
const start = target.selectionStart || 0
const end = target.selectionEnd || 0
const newValue = inputValue.slice(0, start) + '\n' + inputValue.slice(end)
setInputValue(newValue)
// Set cursor position after the newline and adjust height if needed
setTimeout(() => {
if (target.setSelectionRange) {
target.setSelectionRange(start + 1, start + 1)
}
// Manually trigger height adjustment for textarea after component switch
if (inputRef.current && inputRef.current.tagName === 'TEXTAREA') {
adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)
}
}, 0)
} else if (e.key === 'Enter' && !e.shiftKey) {
// Enter: Submit form
e.preventDefault()
handleSubmit(e as any)
}
}, [inputValue, handleSubmit, adjustTextareaHeight])
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// Get pasted text content
const pastedText = e.clipboardData.getData('text')
// Check if it contains newlines
if (pastedText.includes('\n')) {
e.preventDefault() // Prevent default paste behavior
// Get current cursor position
const target = e.target as HTMLInputElement | HTMLTextAreaElement
const start = target.selectionStart || 0
const end = target.selectionEnd || 0
// Build new value
const newValue = inputValue.slice(0, start) + pastedText + inputValue.slice(end)
// Update state (this will trigger component switch to Textarea)
setInputValue(newValue)
// Set cursor position to end of pasted content
setTimeout(() => {
if (inputRef.current && inputRef.current.setSelectionRange) {
const newCursorPosition = start + pastedText.length
inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition)
}
}, 0)
}
// If no newlines, let default paste behavior continue
}, [inputValue])
// Effect to handle component switching and maintain focus
useEffect(() => {
if (inputRef.current) {
// When component type changes, restore focus and cursor position
const currentElement = inputRef.current
const cursorPosition = currentElement.selectionStart || inputValue.length
// Use requestAnimationFrame to ensure DOM update is complete
requestAnimationFrame(() => {
currentElement.focus()
if (currentElement.setSelectionRange) {
currentElement.setSelectionRange(cursorPosition, cursorPosition)
}
})
}
}, [hasMultipleLines, inputValue.length]) // Include inputValue.length dependency
// Effect to adjust textarea height when switching to multi-line mode
useEffect(() => {
if (hasMultipleLines && inputRef.current && inputRef.current.tagName === 'TEXTAREA') {
adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)
}
}, [hasMultipleLines, inputValue, adjustTextareaHeight])
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
const shouldFollowScrollRef = useRef(true)
const thinkingStartTime = useRef<number | null>(null)
const thinkingProcessed = useRef(false)
// Abort controller for the in-flight query (streaming or non-streaming)
const abortControllerRef = useRef<AbortController | null>(null)
// Id of the assistant message currently receiving a response
const activeAssistantIdRef = useRef<string | null>(null)
// Reference to track if user interaction is from the form area
const isFormInteractionRef = useRef(false)
// Reference to track if scroll was triggered programmatically
const programmaticScrollRef = useRef(false)
// Reference to track if we're currently receiving a streaming response
const isReceivingResponseRef = useRef(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
// Add cleanup effect for memory leak prevention
useEffect(() => {
// Component cleanup - reset timer state to prevent memory leaks
return () => {
if (thinkingStartTime.current) {
thinkingStartTime.current = null;
}
if (stopCooldownTimerRef.current) {
clearTimeout(stopCooldownTimerRef.current);
stopCooldownTimerRef.current = null;
}
};
}, []);
// Add event listeners to detect when user manually interacts with the container
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
// Handle significant mouse wheel events - only disable auto-scroll for deliberate scrolling
const handleWheel = (e: WheelEvent) => {
// Only consider significant wheel movements (more than 10px)
if (Math.abs(e.deltaY) > 10 && !isFormInteractionRef.current) {
shouldFollowScrollRef.current = false;
}
};
// Handle scroll events - only disable auto-scroll if not programmatically triggered
// and if it's a significant scroll
const handleScroll = throttle(() => {
// If this is a programmatic scroll, don't disable auto-scroll
if (programmaticScrollRef.current) {
programmaticScrollRef.current = false;
return;
}
// Check if scrolled to bottom or very close to bottom
const container = messagesContainerRef.current;
if (container) {
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 20;
// If at bottom, enable auto-scroll, otherwise disable it
if (isAtBottom) {
shouldFollowScrollRef.current = true;
} else if (!isFormInteractionRef.current && !isReceivingResponseRef.current) {
shouldFollowScrollRef.current = false;
}
}
}, 30);
// Add event listeners - only listen for wheel and scroll events
container.addEventListener('wheel', handleWheel as EventListener);
container.addEventListener('scroll', handleScroll as EventListener);
return () => {
container.removeEventListener('wheel', handleWheel as EventListener);
container.removeEventListener('scroll', handleScroll as EventListener);
};
}, []);
// Add event listeners to the form area to prevent disabling auto-scroll when interacting with form
useEffect(() => {
const form = document.querySelector('form');
if (!form) return;
const handleFormMouseDown = () => {
// Set flag to indicate form interaction
isFormInteractionRef.current = true;
// Reset the flag after a short delay
setTimeout(() => {
isFormInteractionRef.current = false;
}, 500); // Give enough time for the form interaction to complete
};
form.addEventListener('mousedown', handleFormMouseDown);
return () => {
form.removeEventListener('mousedown', handleFormMouseDown);
};
}, []);
// Use a longer debounce time for better performance with large message updates
const debouncedMessages = useDebounce(messages, 150)
useEffect(() => {
// Only auto-scroll if enabled
if (shouldFollowScrollRef.current) {
// Force scroll to bottom when messages change
scrollToBottom()
}
}, [debouncedMessages, scrollToBottom])
const clearMessages = useCallback(() => {
setMessages([])
useSettingsStore.getState().setRetrievalHistory([])
}, [setMessages])
// Stop the in-flight query. Frees the UI immediately so the user can start a
// new query without waiting for the aborted request to unwind. Marks the
// active assistant message as terminated and stops its COT spinner.
const handleStop = useCallback(() => {
const controller = abortControllerRef.current
if (!controller) return
controller.abort()
// Relinquish ownership so the aborted query's deferred `finally` skips its
// cleanup. Otherwise it still sees itself as the active query and would
// write the stale conversation back into history — undoing a Clear that the
// user performs after stopping (the cleared chat would reappear on reload).
// eslint-disable-next-line react-hooks/immutability
abortControllerRef.current = null
// Finalize the terminated assistant message (stop its COT spinner, mark it
// aborted) and persist immediately so the terminated state is the
// authoritative saved history.
const activeId = activeAssistantIdRef.current
const finalizedMessages = messages.map((m) => {
if (m.id !== activeId) return m
// Terminated mid-thinking: finalize the COT block so the partial reasoning
// stays visible (as a "Thinking time Xs" block, in whatever expand state
// the user left it) instead of vanishing once isThinking is cleared — the
// thinking block only renders while isThinking || thinkingTime !== null.
let thinkingTime = m.thinkingTime ?? null
if (m.isThinking && thinkingTime === null && thinkingStartTime.current) {
thinkingTime = parseFloat(((Date.now() - thinkingStartTime.current) / 1000).toFixed(2))
}
return { ...m, isThinking: false, isAborted: true, thinkingTime }
})
setMessages(finalizedMessages)
try {
useSettingsStore.getState().setRetrievalHistory(finalizedMessages)
} catch (error) {
console.error('Error saving retrieval history:', error)
}
// The skipped `finally` won't reset these shared thinking refs.
// eslint-disable-next-line react-hooks/immutability
thinkingStartTime.current = null
// eslint-disable-next-line react-hooks/immutability
thinkingProcessed.current = false
setIsLoading(false)
// Cancel any pending Stop-button cooldown and reset it for the next query.
if (stopCooldownTimerRef.current) {
clearTimeout(stopCooldownTimerRef.current)
stopCooldownTimerRef.current = null
}
setStopDisabled(false)
// eslint-disable-next-line react-hooks/immutability
isReceivingResponseRef.current = false
}, [messages, setMessages])
// Disable auto-scroll when the user clicks inside the messages container.
// The ref mutation pattern is intentional and matches how it's mutated elsewhere
// (wheel/scroll handlers in the effect above); the linter flags it here regardless.
const handleMessagesContainerClick = useCallback(() => {
if (shouldFollowScrollRef.current) {
// eslint-disable-next-line react-hooks/immutability
shouldFollowScrollRef.current = false;
}
}, [])
// Handle copying message content with robust clipboard support
const handleCopyMessage = useCallback(async (message: MessageWithError) => {
const contentToCopy = message.role === 'user'
? (message.content || '')
: (message.displayContent !== undefined ? message.displayContent : (message.content || ''));
if (!contentToCopy.trim()) {
toast.error(t('retrievePanel.chatMessage.copyEmpty', 'No content to copy'));
return;
}
try {
const result = await copyToClipboard(contentToCopy);
if (result.success) {
// Show success message with method used
const methodMessages: Record<string, string> = {
'clipboard-api': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'),
'execCommand': t('retrievePanel.chatMessage.copySuccessLegacy', 'Content copied (legacy method)'),
'manual-select': t('retrievePanel.chatMessage.copySuccessManual', 'Content copied (manual method)'),
'fallback': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard')
};
toast.success(methodMessages[result.method] || t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'));
} else {
// Show error with fallback instructions
if (result.method === 'fallback') {
toast.error(
result.error || t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),
{
description: t('retrievePanel.chatMessage.copyManualInstruction', 'Please select and copy the text manually')
}
);
} else {
toast.error(
t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),
{
description: result.error
}
);
}
}
} catch (err) {
console.error('Clipboard operation failed:', err);
toast.error(
t('retrievePanel.chatMessage.copyError', 'Copy operation failed'),
{
description: err instanceof Error ? err.message : 'Unknown error occurred'
}
);
}
}, [t])
return (
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
<div className="flex grow flex-col gap-4">
<div className="relative grow">
<div
ref={messagesContainerRef}
className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2"
onClick={handleMessagesContainerClick}
>
<div className="flex min-h-0 flex-1 flex-col gap-2">
{messages.length === 0 ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-lg">
{t('retrievePanel.retrieval.startPrompt')}
</div>
) : (
messages.map((message) => { // Remove unused idx
// isComplete logic is now handled internally based on message.mermaidRendered
return (
<div
key={message.id} // Use stable ID for key
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} items-end gap-2`}
>
{message.role === 'user' && (
<Button
onClick={() => handleCopyMessage(message)}
className="mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0"
tooltip={t('retrievePanel.chatMessage.copyTooltip')}
variant="ghost"
size="icon"
>
<CopyIcon className="size-4" />
</Button>
)}
<ChatMessage message={message} isTabActive={isRetrievalTabActive} />
{message.role === 'assistant' && (
<Button
onClick={() => handleCopyMessage(message)}
className="mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0"
tooltip={t('retrievePanel.chatMessage.copyTooltip')}
variant="ghost"
size="icon"
>
<CopyIcon className="size-4" />
</Button>
)}
</div>
);
})
)}
<div ref={messagesEndRef} className="pb-1" />
</div>
</div>
</div>
<form
onSubmit={handleSubmit}
className="flex shrink-0 items-center gap-2"
autoComplete="on"
method="post"
action="#"
role="search"
>
{/* Hidden submit button to ensure form meets HTML standards */}
<input type="submit" style={{ display: 'none' }} tabIndex={-1} />
<Button
type="button"
variant="outline"
onClick={clearMessages}
disabled={isLoading}
size="sm"
>
<EraserIcon />
{t('retrievePanel.retrieval.clear')}
</Button>
<div className="flex-1 relative">
<label htmlFor="query-input" className="sr-only">
{t('retrievePanel.retrieval.placeholder')}
</label>
{hasMultipleLines ? (
<Textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
id="query-input"
autoComplete="on"
className="w-full min-h-[40px] max-h-[120px] overflow-y-auto"
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={t('retrievePanel.retrieval.placeholder')}
disabled={isLoading}
rows={1}
style={{
resize: 'none',
height: 'auto',
minHeight: '40px',
maxHeight: '120px'
}}
onInput={(e: React.FormEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
requestAnimationFrame(() => {
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
})
}}
/>
) : (
<Input
ref={inputRef as React.RefObject<HTMLInputElement>}
id="query-input"
autoComplete="on"
className="w-full"
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={t('retrievePanel.retrieval.placeholder')}
disabled={isLoading}
/>
)}
{/* Error message below input */}
{inputError && (
<div className="absolute left-0 top-full mt-1 text-xs text-red-500">{inputError}</div>
)}
</div>
{isLoading ? (
<Button type="button" variant="destructive" onClick={handleStop} disabled={stopDisabled} size="sm">
<SquareIcon />
{t('retrievePanel.retrieval.stop')}
</Button>
) : (
<Button type="submit" variant="default" size="sm">
<SendIcon />
{t('retrievePanel.retrieval.send')}
</Button>
)}
</form>
</div>
<QuerySettings />
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
import Button from '@/components/ui/Button'
import { SiteInfo, webuiPrefix } from '@/lib/constants'
import AppSettings from '@/components/AppSettings'
import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
import { useSettingsStore } from '@/stores/settings'
import { useAuthStore } from '@/stores/state'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { navigationService } from '@/services/navigation'
import { ZapIcon, LogOutIcon } from 'lucide-react'
import GithubIcon from '@/components/icons/GithubIcon'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
interface NavigationTabProps {
value: string
currentTab: string
children: React.ReactNode
}
function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
return (
<TabsTrigger
value={value}
className={cn(
'cursor-pointer px-2 py-1 transition-all',
currentTab === value ? '!bg-emerald-400 !text-zinc-50' : 'hover:bg-background/60'
)}
>
{children}
</TabsTrigger>
)
}
function TabsNavigation() {
const currentTab = useSettingsStore.use.currentTab()
const { t } = useTranslation()
return (
<div className="flex h-8 self-center">
<TabsList className="h-full gap-2">
<NavigationTab value="documents" currentTab={currentTab}>
{t('header.documents')}
</NavigationTab>
<NavigationTab value="knowledge-graph" currentTab={currentTab}>
{t('header.knowledgeGraph')}
</NavigationTab>
<NavigationTab value="retrieval" currentTab={currentTab}>
{t('header.retrieval')}
</NavigationTab>
<NavigationTab value="api" currentTab={currentTab}>
{t('header.api')}
</NavigationTab>
</TabsList>
</div>
)
}
export default function SiteHeader() {
const { t } = useTranslation()
const { isGuestMode, coreVersion, apiVersion, username, webuiTitle, webuiDescription } = useAuthStore()
const versionDisplay = (coreVersion && apiVersion)
? `${coreVersion}/${apiVersion}`
: null;
// Check if frontend needs rebuild (apiVersion ends with warning symbol)
const hasWarning = apiVersion?.endsWith('⚠️');
const versionTooltip = hasWarning
? t('header.frontendNeedsRebuild')
: versionDisplay ? `v${versionDisplay}` : '';
const handleLogout = () => {
navigationService.navigateToLogin();
}
return (
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
<div className="min-w-[200px] w-auto flex items-center">
<a href={webuiPrefix} className="flex items-center gap-2">
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
</a>
{webuiTitle && (
<div className="flex items-center">
<span className="mx-1 text-xs text-gray-500 dark:text-gray-400">|</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="font-medium text-sm cursor-default">
{webuiTitle}
</span>
</TooltipTrigger>
{webuiDescription && (
<TooltipContent side="bottom">
{webuiDescription}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
<div className="flex h-10 flex-1 items-center justify-center">
<TabsNavigation />
{isGuestMode && (
<div className="ml-2 self-center px-2 py-1 text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 rounded-md">
{t('login.guestMode', 'Guest Mode')}
</div>
)}
</div>
<nav className="w-[200px] flex items-center justify-end">
<div className="flex items-center gap-2">
{versionDisplay && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1 cursor-default">
v{versionDisplay}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{versionTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}>
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
<GithubIcon className="size-4" />
</a>
</Button>
<AppSettings />
{!isGuestMode && (
<Button
variant="ghost"
size="icon"
side="bottom"
tooltip={`${t('header.logout')} (${username})`}
onClick={handleLogout}
>
<LogOutIcon className="size-4" aria-hidden="true" />
</Button>
)}
</div>
</nav>
</header>
)
}
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import { getStatusRequestFilters, matchesStatusFilter } from '@/features/documentStatusFilters'
describe('documentStatusFilters', () => {
test('builds exact single-status request filters for each tab', () => {
expect(getStatusRequestFilters('completed')).toEqual({
status_filter: 'processed',
status_filters: null
})
expect(getStatusRequestFilters('parse')).toEqual({
status_filter: 'parsing',
status_filters: null
})
expect(getStatusRequestFilters('analyze')).toEqual({
status_filter: 'analyzing',
status_filters: null
})
expect(getStatusRequestFilters('process')).toEqual({
status_filter: 'processing',
status_filters: null
})
expect(getStatusRequestFilters('failed')).toEqual({
status_filter: 'failed',
status_filters: null
})
})
test('builds empty request filters for the all tab', () => {
expect(getStatusRequestFilters('all')).toEqual({
status_filter: null,
status_filters: null
})
})
test('matches a single status per non-all tab', () => {
expect(matchesStatusFilter('parsing', 'parse')).toBe(true)
expect(matchesStatusFilter('analyzing', 'analyze')).toBe(true)
expect(matchesStatusFilter('processing', 'process')).toBe(true)
expect(matchesStatusFilter('analyzing', 'parse')).toBe(false)
expect(matchesStatusFilter('preprocessed', 'analyze')).toBe(false)
})
test('the all tab matches every status, including deprecated and hidden ones', () => {
expect(matchesStatusFilter('pending', 'all')).toBe(true)
expect(matchesStatusFilter('preprocessed', 'all')).toBe(true)
expect(matchesStatusFilter('processed', 'all')).toBe(true)
})
})
@@ -0,0 +1,45 @@
import type { DocStatus, DocumentsRequest } from '@/api/lightrag'
export type StatusBucket = 'completed' | 'parse' | 'analyze' | 'process' | 'failed'
export type StatusFilter = StatusBucket | 'all'
// Each filter bucket maps to exactly one DocStatus. `pending` and the deprecated
// `preprocessed` intentionally have no dedicated bucket — they only surface under
// the "all" tab.
const BUCKET_TO_STATUS: Record<StatusBucket, DocStatus> = {
completed: 'processed',
parse: 'parsing',
analyze: 'analyzing',
process: 'processing',
failed: 'failed'
}
const STATUS_TO_BUCKET: Partial<Record<DocStatus, StatusBucket>> = {
processed: 'completed',
parsing: 'parse',
analyzing: 'analyze',
processing: 'process',
failed: 'failed'
}
export const getStatusBucket = (status: DocStatus): StatusBucket | null =>
STATUS_TO_BUCKET[status] ?? null
export const getStatusRequestFilters = (
statusFilter: StatusFilter
): Pick<DocumentsRequest, 'status_filter' | 'status_filters'> => {
if (statusFilter === 'all') {
return {
status_filter: null,
status_filters: null
}
}
return {
status_filter: BUCKET_TO_STATUS[statusFilter],
status_filters: null
}
}
export const matchesStatusFilter = (status: DocStatus, statusFilter: StatusFilter): boolean =>
statusFilter === 'all' || BUCKET_TO_STATUS[statusFilter] === status
+17
View 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
}
@@ -0,0 +1,26 @@
import { useSyncExternalStore } from 'react'
import { useSettingsStore } from '@/stores/settings'
// Single source of truth for "is the UI effectively in dark mode right now",
// resolving theme === 'system' against the OS preference. Reactive: re-renders
// consumers both when the theme setting changes and (in system mode) when the
// OS color scheme is toggled. This mirrors how ThemeProvider writes the
// dark/light class, but reads matchMedia directly so it does not depend on the
// class write having happened first.
const darkMql = () => window.matchMedia('(prefers-color-scheme: dark)')
const subscribe = (onChange: () => void) => {
const mql = darkMql()
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}
const getSnapshot = () => darkMql().matches
const useIsDarkMode = (): boolean => {
const theme = useSettingsStore.use.theme()
const systemDark = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
return theme === 'dark' ? true : theme === 'light' ? false : systemDark
}
export default useIsDarkMode
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
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'
import { useGraphStore } from '@/stores/graph'
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)
// Seed global Math.random after commit to avoid polluting the RNG
// during render (StrictMode double-invoke, Concurrent Mode aborts)
useEffect(() => {
const params = new URLSearchParams(document.location.search)
const seed = params.get('seed')
if (!seed) return
// Global side effect — intentionally in effect, not render
seedrandom(seed, { global: true })
const f = new Faker({ locale: en })
f.seed(Math.random())
const timer = setTimeout(() => setFaker(f), 0)
return () => clearTimeout(timer)
}, [])
const randomGraph = useCallback(() => {
useGraphStore.getState().reset()
// 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()
})
})
// Add edge attributes
graph.edges().forEach((edge: string) => {
graph.mergeEdgeAttributes(edge, {
label: faker.lorem.words(faker.number.int({ min: 1, max: 3 })),
size: faker.number.float({ min: 1, max: 5 }),
color: randomColor()
})
})
return graph as Graph<NodeType, EdgeType>
}, [faker])
return { faker, randomColor, randomGraph }
}
export default useRandomGraph
+12
View 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
+64
View File
@@ -0,0 +1,64 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
import en from './locales/en.json'
import zh from './locales/zh.json'
import fr from './locales/fr.json'
import ar from './locales/ar.json'
import zh_TW from './locales/zh_TW.json'
import ru from './locales/ru.json'
import ja from './locales/ja.json'
import de from './locales/de.json'
import uk from './locales/uk.json'
import ko from './locales/ko.json'
import vi from './locales/vi.json'
const getStoredLanguage = () => {
try {
const settingsString = localStorage.getItem('settings-storage')
if (settingsString) {
const settings = JSON.parse(settingsString)
return settings.state?.language || 'en'
}
} catch (e) {
console.error('Failed to get stored language:', e)
}
return 'en'
}
i18n
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
zh: { translation: zh },
fr: { translation: fr },
ar: { translation: ar },
zh_TW: { translation: zh_TW },
ru: { translation: ru },
ja: { translation: ja },
de: { translation: de },
uk: { translation: uk },
ko: { translation: ko },
vi: { translation: vi }
},
lng: getStoredLanguage(), // Use stored language settings
fallbackLng: 'en',
interpolation: {
escapeValue: false
},
// Configuration to handle missing translations
returnEmptyString: false,
returnNull: false,
})
// Subscribe to language changes
useSettingsStore.subscribe((state) => {
const currentLanguage = state.language
if (i18n.language !== currentLanguage) {
i18n.changeLanguage(currentLanguage)
}
})
export default i18n
+228
View File
@@ -0,0 +1,228 @@
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@plugin 'tailwind-scrollbar';
@source '../index.html';
@source './**/*.{ts,tsx}';
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(240 10% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.6rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--ring: hsl(240 4.9% 83.9%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar-background);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background-color: hsl(0 0% 80%);
border-radius: 5px;
}
::-webkit-scrollbar-track {
background-color: hsl(0 0% 95%);
}
.dark {
::-webkit-scrollbar-thumb {
background-color: hsl(0 0% 90%);
}
::-webkit-scrollbar-track {
background-color: hsl(0 0% 0%);
}
}
/* KaTeX Math Formula Styles */
.katex-display-wrapper {
text-align: center;
position: relative;
}
.katex-display-wrapper .katex-display {
margin: 0.5em 0;
text-align: center;
}
.katex-inline-wrapper .katex {
font-size: inherit;
line-height: inherit;
}
/* Ensure KaTeX formulas inherit color properly */
.katex .base {
color: inherit;
}
/* Improve KaTeX display for different themes */
.katex .mord,
.katex .mop,
.katex .mbin,
.katex .mrel,
.katex .mpunct,
.katex .mopen,
.katex .mclose,
.katex .minner {
color: inherit;
}
/* Fix KaTeX display overflow issues */
.katex-display {
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
}
.katex-display > .katex {
white-space: nowrap;
}
/* Mermaid v11 attaches helper elements directly to <body> outside React's tree:
1) A transient <div id="dmermaid-…"> render scratchpad (~150px tall, lives
~500ms during render) used for layout measurement.
2) A persistent <div class="mermaidTooltip"> shared across renders.
Both default to position:absolute with no top/left, so they land in body's
normal flow below <main h-screen> and inflate documentElement.scrollHeight,
producing a page-level scrollbar that flashes during render and persists
afterwards across Tab switches. Force fixed so they don't contribute to
page scroll dimensions. */
.mermaidTooltip,
body > [id^="dmermaid-"] {
position: fixed !important;
}
/* Improve KaTeX error display */
.katex .katex-error {
background-color: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
border-radius: 4px;
padding: 2px 4px;
color: #dc2626;
}
.dark .katex .katex-error {
background-color: rgba(255, 0, 0, 0.2);
border-color: rgba(255, 0, 0, 0.4);
color: #ef4444;
}
+127
View File
@@ -0,0 +1,127 @@
import { ButtonVariantType } from '@/components/ui/Button'
import { normalizeApiPrefix, normalizeWebuiPrefix } from '@/lib/pathPrefix'
import { getRuntimeApiPrefix, getRuntimeWebuiPrefix } from '@/lib/runtimeConfig'
export const backendBaseUrl = normalizeApiPrefix(getRuntimeApiPrefix())
export const webuiPrefix = normalizeWebuiPrefix(getRuntimeWebuiPrefix())
export const controlButtonVariant: ButtonVariantType = 'ghost'
export const labelColorDarkTheme = '#FFFFFF'
export const LabelColorHighlightedDarkTheme = '#000000'
export const labelColorLightTheme = '#000'
export const nodeColorDisabled = '#E2E2E2'
export const nodeBorderColor = '#EEEEEE'
export const nodeBorderColorSelected = '#F57F17'
export const edgeColorDarkTheme = '#888888'
export const edgeColorSelected = '#F57F17'
export const edgeColorHighlightedDarkTheme = '#F57F17'
export const edgeColorHighlightedLightTheme = '#F57F17'
export const searchResultLimit = 50
export const labelListLimit = 100
// Search History Configuration
export const searchHistoryMaxItems = 500
export const searchHistoryVersion = '1.0'
// API Request Limits
export const popularLabelsDefaultLimit = 300
export const searchLabelsDefaultLimit = 50
// UI Display Limits
export const dropdownDisplayLimit = 300
export const minNodeSize = 4
export const maxNodeSize = 20
export const healthCheckInterval = 15 // seconds
export const defaultQueryLabel = '*'
// reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types/Common_types
export const supportedFileTypes = {
'text/plain': [
'.txt',
'.md',
'.textpack', // # Markdown Bundle(zip)
'.mdx', // # MDX (Markdown + JSX)
'.rtf', // # Rich Text Format
'.odt', // # OpenDocument Text
'.tex', // # LaTeX
'.epub', // # Electronic Publication
'.html', // # HyperText Markup Language
'.htm', // # HyperText Markup Language
'.csv', // # Comma-Separated Values
'.json', // # JavaScript Object Notation
'.xml', // # eXtensible Markup Language
'.yaml', // # YAML Ain't Markup Language
'.yml', // # YAML
'.log', // # Log files
'.conf', // # Configuration files
'.ini', // # Initialization files
'.properties', // # Java properties files
'.sql', // # SQL scripts
'.bat', // # Batch files
'.sh', // # Shell scripts
'.c', // # C source code
'.h', // # C header
'.cpp', // # C++ source code
'.hpp', // # C++ header
'.py', // # Python source code
'.java', // # Java source code
'.js', // # JavaScript source code
'.ts', // # TypeScript source code
'.swift', // # Swift source code
'.go', // # Go source code
'.rb', // # Ruby source code
'.php', // # PHP source code
'.css', // # Cascading Style Sheets
'.scss', // # Sassy CSS
'.less'
],
'application/pdf': ['.pdf'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx']
}
export const SiteInfo = {
name: 'LightRAG',
home: '/',
github: 'https://github.com/HKUDS/LightRAG'
}
// --- Graph layout performance thresholds ------------------------------------
// Shared by the initial FA2 layout (GraphControl) and the manual worker
// layouts (LayoutsControl) so the two cannot drift.
// Above this node count, node labels are forced off regardless of the
// showNodeLabel setting (the hovered node's label is still drawn by sigma's
// hover layer). Rendering thousands of labels is a major large-graph slowdown.
export const LABEL_RENDER_LIMIT = 2000
// Above this node count, layout switches assign positions directly instead of
// animating: animateNodes interpolates every node per frame on the main thread.
export const ANIMATE_NODE_LIMIT = 5000
// Edge-count threshold that switches the graph between "small-graph experience"
// and "large-graph performance". At or below it edges render as curves and edge
// events (hover/click picking) follow the user setting; above it edges render
// straight and edge events are fully disabled (no picking buffer allocated).
// Shared by GraphControl (defaultEdgeType), GraphViewer (enableEdgeEvents
// gating) and Settings (greying the Edge Events menu item) so they cannot drift.
export const EDGE_PERF_LIMIT = 5000
// Time budget (ms) a relaxing worker layout runs before it is stopped. Scales
// with graph size, capped so huge graphs don't run unbounded.
export const workerBudgetMs = (order: number): number => Math.min(1500 + order / 10, 10000)
// One-time system-suggested user prompts, injected once into userPromptHistory
// (for both fresh installs and upgrades). See settings store version 20 migration.
export const suggestedUserPrompts: string[] = [
'Ignore the `References Section Format` instruction in the system prompt, and do not include a `References` section in the response.',
'For inline citations, use the footnote marker syntax `[^1]`, where the `^` preceding the identifier indicates a footnote reference. When multiple citations are required at a single location, each ID should be enclosed in separate footnote markers (e.g., `[^1][^2][^3]`).'
]
+4
View File
@@ -0,0 +1,4 @@
// This file is for importing libraries that have global side effects.
// Load KaTeX mhchem extension globally
import 'katex/contrib/mhchem';
+67
View File
@@ -0,0 +1,67 @@
/// <reference types="bun" />
import { describe, expect, test } from 'bun:test'
import { normalizeApiPrefix, normalizeWebuiPrefix } from './pathPrefix'
describe('normalizeApiPrefix', () => {
test('empty / undefined / null collapse to ""', () => {
expect(normalizeApiPrefix(undefined)).toBe('')
expect(normalizeApiPrefix(null)).toBe('')
expect(normalizeApiPrefix('')).toBe('')
expect(normalizeApiPrefix(' ')).toBe('')
})
test('"/" collapses to "" — avoids `${"/"} + "/x"` producing protocol-relative `//x`', () => {
expect(normalizeApiPrefix('/')).toBe('')
})
test('strips trailing slashes (one or many)', () => {
expect(normalizeApiPrefix('/api/v1/')).toBe('/api/v1')
expect(normalizeApiPrefix('/api/v1//')).toBe('/api/v1')
})
test('adds leading slash if missing', () => {
expect(normalizeApiPrefix('api/v1')).toBe('/api/v1')
})
test('passes canonical form through unchanged', () => {
expect(normalizeApiPrefix('/api/v1')).toBe('/api/v1')
})
test('result is safe for fetch template concat: never starts with `//` and never ends with `/`', () => {
for (const input of ['', '/', undefined, '/api', '/api/', 'api', '/api/v1/']) {
const out = normalizeApiPrefix(input)
const fetchUrl = `${out}/query/stream`
expect(fetchUrl.startsWith('//')).toBe(false)
expect(fetchUrl).not.toContain('//')
}
})
})
describe('normalizeWebuiPrefix', () => {
test('empty / undefined / null fall back to default with trailing slash', () => {
expect(normalizeWebuiPrefix(undefined)).toBe('/webui/')
expect(normalizeWebuiPrefix(null)).toBe('/webui/')
expect(normalizeWebuiPrefix('')).toBe('/webui/')
expect(normalizeWebuiPrefix(' ')).toBe('/webui/')
})
test('"/" falls back to default — degenerate value rejected', () => {
expect(normalizeWebuiPrefix('/')).toBe('/webui/')
})
test('always ends with exactly one trailing slash (Vite `base` requirement)', () => {
expect(normalizeWebuiPrefix('/admin/ui')).toBe('/admin/ui/')
expect(normalizeWebuiPrefix('/admin/ui/')).toBe('/admin/ui/')
expect(normalizeWebuiPrefix('/admin/ui//')).toBe('/admin/ui/')
})
test('adds leading slash if missing', () => {
expect(normalizeWebuiPrefix('admin/ui')).toBe('/admin/ui/')
})
test('respects custom fallback', () => {
expect(normalizeWebuiPrefix(undefined, '/custom')).toBe('/custom/')
expect(normalizeWebuiPrefix('/', '/custom')).toBe('/custom/')
expect(normalizeWebuiPrefix('', '/custom/')).toBe('/custom/')
})
})

Some files were not shown because too many files have changed in this diff Show More