chore: init monorepo snapshot

This commit is contained in:
liaibo
2025-11-23 10:55:04 +08:00
commit c70ff52869
941 changed files with 246586 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
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 RetrievalTesting from '@/features/RetrievalTesting'
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),
[]
)
useEffect(() => {
if (message) {
if (message.includes(InvalidApiKeyError) || message.includes(RequireApiKeError)) {
setApiKeyAlertOpen(true)
}
}
}, [message])
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">
<RetrievalTesting />
</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
+817
View File
@@ -0,0 +1,817 @@
import axios, { AxiosError } from 'axios'
import { backendBaseUrl, popularLabelsDefaultLimit, searchLabelsDefaultLimit } from '@/lib/constants'
import { errorMessage } from '@/lib/utils'
import { useSettingsStore } from '@/stores/settings'
import { navigationService } from '@/services/navigation'
// Types
export type LightragNodeType = {
id: string
labels: string[]
properties: Record<string, any>
}
export type LightragEdgeType = {
id: string
source: string
target: string
type: string
properties: Record<string, any>
}
export type LightragGraphType = {
nodes: LightragNodeType[]
edges: LightragEdgeType[]
}
export type LightragStatus = {
status: 'healthy'
working_directory: string
input_directory: string
configuration: {
llm_binding: string
llm_binding_host: string
llm_model: string
embedding_binding: string
embedding_binding_host: string
embedding_model: string
kv_storage: string
doc_status_storage: string
graph_storage: string
vector_storage: string
workspace?: string
max_graph_nodes?: string
enable_rerank?: boolean
rerank_binding?: string | null
rerank_model?: string | null
rerank_binding_host?: string | null
summary_language: string
force_llm_summary_on_merge: boolean
max_parallel_insert: number
max_async: number
embedding_func_max_async: number
embedding_batch_num: number
cosine_threshold: number
min_rerank_score: number
related_chunk_number: number
}
update_status?: Record<string, any>
core_version?: string
api_version?: string
auth_mode?: 'enabled' | 'disabled'
pipeline_busy: boolean
keyed_locks?: {
process_id: number
cleanup_performed: {
mp_cleaned: number
async_cleaned: number
}
current_status: {
total_mp_locks: number
pending_mp_cleanup: number
total_async_locks: number
pending_async_cleanup: number
}
}
webui_title?: string
webui_description?: string
}
export type LightragDocumentsScanProgress = {
is_scanning: boolean
current_file: string
indexed_count: number
total_files: number
progress: number
}
/**
* Specifies the retrieval mode:
* - "naive": Performs a basic search without advanced techniques.
* - "local": Focuses on context-dependent information.
* - "global": Utilizes global knowledge.
* - "hybrid": Combines local and global retrieval methods.
* - "mix": Integrates knowledge graph and vector retrieval.
* - "bypass": Bypasses knowledge retrieval and directly uses the LLM.
*/
export type QueryMode = 'naive' | 'local' | 'global' | 'hybrid' | 'mix' | 'bypass'
export type Message = {
role: 'user' | 'assistant' | 'system'
content: string
thinkingContent?: string
displayContent?: string
thinkingTime?: number | null
}
export type QueryRequest = {
query: string
/** Specifies the retrieval mode. */
mode: QueryMode
/** If True, only returns the retrieved context without generating a response. */
only_need_context?: boolean
/** If True, only returns the generated prompt without producing a response. */
only_need_prompt?: boolean
/** Defines the response format. Examples: 'Multiple Paragraphs', 'Single Paragraph', 'Bullet Points'. */
response_type?: string
/** If True, enables streaming output for real-time responses. */
stream?: boolean
/** Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode. */
top_k?: number
/** Maximum number of text chunks to retrieve and keep after reranking. */
chunk_top_k?: number
/** Maximum number of tokens allocated for entity context in unified token control system. */
max_entity_tokens?: number
/** Maximum number of tokens allocated for relationship context in unified token control system. */
max_relation_tokens?: number
/** Maximum total tokens budget for the entire query context (entities + relations + chunks + system prompt). */
max_total_tokens?: number
/**
* Stores past conversation history to maintain context.
* Format: [{"role": "user/assistant", "content": "message"}].
*/
conversation_history?: Message[]
/** Number of complete conversation turns (user-assistant pairs) to consider in the response context. */
history_turns?: number
/** User-provided prompt for the query. If provided, this will be used instead of the default value from prompt template. */
user_prompt?: string
/** Enable reranking for retrieved text chunks. If True but no rerank model is configured, a warning will be issued. Default is True. */
enable_rerank?: boolean
}
export type QueryResponse = {
response: string
}
export type EntityUpdateResponse = {
status: string
message: string
data: Record<string, any>
operation_summary?: {
merged: boolean
merge_status: 'success' | 'failed' | 'not_attempted'
merge_error: string | null
operation_status: 'success' | 'partial_success' | 'failure'
target_entity: string | null
final_entity?: string | null
renamed?: boolean
}
}
export type DocActionResponse = {
status: 'success' | 'partial_success' | 'failure' | 'duplicated'
message: string
track_id?: string
}
export type ScanResponse = {
status: 'scanning_started'
message: string
track_id: string
}
export type ReprocessFailedResponse = {
status: 'reprocessing_started'
message: string
track_id: string
}
export type DeleteDocResponse = {
status: 'deletion_started' | 'busy' | 'not_allowed'
message: string
doc_id: string
}
export type DocStatus = 'pending' | 'processing' | 'preprocessed' | 'processed' | 'failed'
export type DocStatusResponse = {
id: string
content_summary: string
content_length: number
status: DocStatus
created_at: string
updated_at: string
track_id?: string
chunks_count?: number
error_msg?: string
metadata?: Record<string, any>
file_path: string
}
export type DocsStatusesResponse = {
statuses: Record<DocStatus, DocStatusResponse[]>
}
export type TrackStatusResponse = {
track_id: string
documents: DocStatusResponse[]
total_count: number
status_summary: Record<string, number>
}
export type DocumentsRequest = {
status_filter?: DocStatus | null
page: number
page_size: number
sort_field: 'created_at' | 'updated_at' | 'id' | 'file_path'
sort_direction: 'asc' | 'desc'
}
export type PaginationInfo = {
page: number
page_size: number
total_count: number
total_pages: number
has_next: boolean
has_prev: boolean
}
export type PaginatedDocsResponse = {
documents: DocStatusResponse[]
pagination: PaginationInfo
status_counts: Record<string, number>
}
export type StatusCountsResponse = {
status_counts: Record<string, number>
}
export type AuthStatusResponse = {
auth_configured: boolean
access_token?: string
token_type?: string
auth_mode?: 'enabled' | 'disabled'
message?: string
core_version?: string
api_version?: string
webui_title?: string
webui_description?: string
}
export type PipelineStatusResponse = {
autoscanned: boolean
busy: boolean
job_name: string
job_start?: string
docs: number
batchs: number
cur_batch: number
request_pending: boolean
cancellation_requested?: boolean
latest_message: string
history_messages?: string[]
update_status?: Record<string, any>
}
export type LoginResponse = {
access_token: string
token_type: string
auth_mode?: 'enabled' | 'disabled' // Authentication mode identifier
message?: string // Optional message
core_version?: string
api_version?: string
webui_title?: string
webui_description?: string
}
export const InvalidApiKeyError = 'Invalid API Key'
export const RequireApiKeError = 'API Key required'
// Axios instance
const axiosInstance = axios.create({
baseURL: backendBaseUrl,
headers: {
'Content-Type': 'application/json'
}
})
// Interceptor: add api key and check authentication
axiosInstance.interceptors.request.use((config) => {
const apiKey = useSettingsStore.getState().apiKey
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
// Always include token if it exists, regardless of path
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
if (apiKey) {
config.headers['X-API-Key'] = apiKey
}
return config
})
// Interceptorhanle error
axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response) {
if (error.response?.status === 401) {
// For login API, throw error directly
if (error.config?.url?.includes('/login')) {
throw error;
}
// For other APIs, navigate to login page
navigationService.navigateToLogin();
// return a reject Promise
return Promise.reject(new Error('Authentication required'));
}
throw new Error(
`${error.response.status} ${error.response.statusText}\n${JSON.stringify(
error.response.data
)}\n${error.config?.url}`
)
}
throw error
}
)
// API methods
export const queryGraphs = async (
label: string,
maxDepth: number,
maxNodes: number
): Promise<LightragGraphType> => {
const response = await axiosInstance.get(`/graphs?label=${encodeURIComponent(label)}&max_depth=${maxDepth}&max_nodes=${maxNodes}`)
return response.data
}
export const getGraphLabels = async (): Promise<string[]> => {
const response = await axiosInstance.get('/graph/label/list')
return response.data
}
export const getPopularLabels = async (limit: number = popularLabelsDefaultLimit): Promise<string[]> => {
const response = await axiosInstance.get(`/graph/label/popular?limit=${limit}`)
return response.data
}
export const searchLabels = async (query: string, limit: number = searchLabelsDefaultLimit): Promise<string[]> => {
const response = await axiosInstance.get(`/graph/label/search?q=${encodeURIComponent(query)}&limit=${limit}`)
return response.data
}
export const checkHealth = async (): Promise<
LightragStatus | { status: 'error'; message: string }
> => {
try {
const response = await axiosInstance.get('/health')
return response.data
} catch (error) {
return {
status: 'error',
message: errorMessage(error)
}
}
}
export const getDocuments = async (): Promise<DocsStatusesResponse> => {
const response = await axiosInstance.get('/documents')
return response.data
}
export const scanNewDocuments = async (): Promise<ScanResponse> => {
const response = await axiosInstance.post('/documents/scan')
return response.data
}
export const reprocessFailedDocuments = async (): Promise<ReprocessFailedResponse> => {
const response = await axiosInstance.post('/documents/reprocess_failed')
return response.data
}
export const getDocumentsScanProgress = async (): Promise<LightragDocumentsScanProgress> => {
const response = await axiosInstance.get('/documents/scan-progress')
return response.data
}
export const queryText = async (request: QueryRequest): Promise<QueryResponse> => {
const response = await axiosInstance.post('/query', request)
return response.data
}
export const queryTextStream = async (
request: QueryRequest,
onChunk: (chunk: string) => void,
onError?: (error: string) => void
) => {
const apiKey = useSettingsStore.getState().apiKey;
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
const headers: HeadersInit = {
'Content-Type': 'application/json',
'Accept': 'application/x-ndjson',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
try {
const response = await fetch(`${backendBaseUrl}/query/stream`, {
method: 'POST',
headers: headers,
body: JSON.stringify(request),
});
if (!response.ok) {
// Handle 401 Unauthorized error specifically
if (response.status === 401) {
// For consistency with axios interceptor, navigate to login page
navigationService.navigateToLogin();
// Create a specific authentication error
const authError = new Error('Authentication required');
throw authError;
}
// Handle other common HTTP errors with specific messages
let errorBody = 'Unknown error';
try {
errorBody = await response.text(); // Try to get error details from body
} catch { /* ignore */ }
// Format error message similar to axios interceptor for consistency
const url = `${backendBaseUrl}/query/stream`;
throw new Error(
`${response.status} ${response.statusText}\n${JSON.stringify(
{ error: errorBody }
)}\n${url}`
);
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break; // Stream finished
}
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true }); // stream: true handles multi-byte chars split across chunks
// Process complete lines (NDJSON)
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep potentially incomplete line in buffer
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line);
if (parsed.response) {
onChunk(parsed.response);
} else if (parsed.error && onError) {
onError(parsed.error);
}
} catch (error) {
console.error('Error parsing stream chunk:', line, error);
if (onError) onError(`Error parsing server response: ${line}`);
}
}
}
}
// Process any remaining data in the buffer after the stream ends
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer);
if (parsed.response) {
onChunk(parsed.response);
} else if (parsed.error && onError) {
onError(parsed.error);
}
} catch (error) {
console.error('Error parsing final chunk:', buffer, error);
if (onError) onError(`Error parsing final server response: ${buffer}`);
}
}
} catch (error) {
const message = errorMessage(error);
// Check if this is an authentication error
if (message === 'Authentication required') {
// Already navigated to login page in the response.status === 401 block
console.error('Authentication required for stream request');
if (onError) {
onError('Authentication required');
}
return; // Exit early, no need for further error handling
}
// Check for specific HTTP error status codes in the error message
const statusCodeMatch = message.match(/^(\d{3})\s/);
if (statusCodeMatch) {
const statusCode = parseInt(statusCodeMatch[1], 10);
// Handle specific status codes with user-friendly messages
let userMessage = message;
switch (statusCode) {
case 403:
userMessage = 'You do not have permission to access this resource (403 Forbidden)';
console.error('Permission denied for stream request:', message);
break;
case 404:
userMessage = 'The requested resource does not exist (404 Not Found)';
console.error('Resource not found for stream request:', message);
break;
case 429:
userMessage = 'Too many requests, please try again later (429 Too Many Requests)';
console.error('Rate limited for stream request:', message);
break;
case 500:
case 502:
case 503:
case 504:
userMessage = `Server error, please try again later (${statusCode})`;
console.error('Server error for stream request:', message);
break;
default:
console.error('Stream request failed with status code:', statusCode, message);
}
if (onError) {
onError(userMessage);
}
return;
}
// Handle network errors (like connection refused, timeout, etc.)
if (message.includes('NetworkError') ||
message.includes('Failed to fetch') ||
message.includes('Network request failed')) {
console.error('Network error for stream request:', message);
if (onError) {
onError('Network connection error, please check your internet connection');
}
return;
}
// Handle JSON parsing errors during stream processing
if (message.includes('Error parsing') || message.includes('SyntaxError')) {
console.error('JSON parsing error in stream:', message);
if (onError) {
onError('Error processing response data');
}
return;
}
// Handle other errors
console.error('Unhandled stream error:', message);
if (onError) {
onError(message);
} else {
console.error('No error handler provided for stream error:', message);
}
}
};
export const insertText = async (text: string): Promise<DocActionResponse> => {
const response = await axiosInstance.post('/documents/text', { text })
return response.data
}
export const insertTexts = async (texts: string[]): Promise<DocActionResponse> => {
const response = await axiosInstance.post('/documents/texts', { texts })
return response.data
}
export const uploadDocument = async (
file: File,
onUploadProgress?: (percentCompleted: number) => void
): Promise<DocActionResponse> => {
const formData = new FormData()
formData.append('file', file)
const response = await axiosInstance.post('/documents/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
// prettier-ignore
onUploadProgress:
onUploadProgress !== undefined
? (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total!)
onUploadProgress(percentCompleted)
}
: undefined
})
return response.data
}
export const batchUploadDocuments = async (
files: File[],
onUploadProgress?: (fileName: string, percentCompleted: number) => void
): Promise<DocActionResponse[]> => {
return await Promise.all(
files.map(async (file) => {
return await uploadDocument(file, (percentCompleted) => {
onUploadProgress?.(file.name, percentCompleted)
})
})
)
}
export const clearDocuments = async (): Promise<DocActionResponse> => {
const response = await axiosInstance.delete('/documents')
return response.data
}
export const clearCache = async (): Promise<{
status: 'success' | 'fail'
message: string
}> => {
const response = await axiosInstance.post('/documents/clear_cache', {})
return response.data
}
export const deleteDocuments = async (
docIds: string[],
deleteFile: boolean = false,
deleteLLMCache: boolean = false
): Promise<DeleteDocResponse> => {
const response = await axiosInstance.delete('/documents/delete_document', {
data: { doc_ids: docIds, delete_file: deleteFile, delete_llm_cache: deleteLLMCache }
})
return response.data
}
export const getAuthStatus = async (): Promise<AuthStatusResponse> => {
try {
// Add a timeout to the request to prevent hanging
const response = await axiosInstance.get('/auth-status', {
timeout: 5000, // 5 second timeout
headers: {
'Accept': 'application/json' // Explicitly request JSON
}
});
// Check if response is HTML (which indicates a redirect or wrong endpoint)
const contentType = response.headers['content-type'] || '';
if (contentType.includes('text/html')) {
console.warn('Received HTML response instead of JSON for auth-status endpoint');
return {
auth_configured: true,
auth_mode: 'enabled'
};
}
// Strict validation of the response data
if (response.data &&
typeof response.data === 'object' &&
'auth_configured' in response.data &&
typeof response.data.auth_configured === 'boolean') {
// For unconfigured auth, ensure we have an access token
if (!response.data.auth_configured) {
if (response.data.access_token && typeof response.data.access_token === 'string') {
return response.data;
} else {
console.warn('Auth not configured but no valid access token provided');
}
} else {
// For configured auth, just return the data
return response.data;
}
}
// If response data is invalid but we got a response, log it
console.warn('Received invalid auth status response:', response.data);
// Default to auth configured if response is invalid
return {
auth_configured: true,
auth_mode: 'enabled'
};
} catch (error) {
// If the request fails, assume authentication is configured
console.error('Failed to get auth status:', errorMessage(error));
return {
auth_configured: true,
auth_mode: 'enabled'
};
}
}
export const getPipelineStatus = async (): Promise<PipelineStatusResponse> => {
const response = await axiosInstance.get('/documents/pipeline_status')
return response.data
}
export const cancelPipeline = async (): Promise<{
status: 'cancellation_requested' | 'not_busy'
message: string
}> => {
const response = await axiosInstance.post('/documents/cancel_pipeline')
return response.data
}
export const loginToServer = async (username: string, password: string): Promise<LoginResponse> => {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const response = await axiosInstance.post('/login', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data;
}
/**
* Updates an entity's properties in the knowledge graph
* @param entityName The name of the entity to update
* @param updatedData Dictionary containing updated attributes
* @param allowRename Whether to allow renaming the entity (default: false)
* @param allowMerge Whether to merge into an existing entity when renaming to a duplicate name
* @returns Promise with the updated entity information
*/
export const updateEntity = async (
entityName: string,
updatedData: Record<string, any>,
allowRename: boolean = false,
allowMerge: boolean = false
): Promise<EntityUpdateResponse> => {
const response = await axiosInstance.post('/graph/entity/edit', {
entity_name: entityName,
updated_data: updatedData,
allow_rename: allowRename,
allow_merge: allowMerge
})
return response.data
}
/**
* Updates a relation's properties in the knowledge graph
* @param sourceEntity The source entity name
* @param targetEntity The target entity name
* @param updatedData Dictionary containing updated attributes
* @returns Promise with the updated relation information
*/
export const updateRelation = async (
sourceEntity: string,
targetEntity: string,
updatedData: Record<string, any>
): Promise<DocActionResponse> => {
const response = await axiosInstance.post('/graph/relation/edit', {
source_id: sourceEntity,
target_id: targetEntity,
updated_data: updatedData
})
return response.data
}
/**
* Checks if an entity name already exists in the knowledge graph
* @param entityName The entity name to check
* @returns Promise with boolean indicating if the entity exists
*/
export const checkEntityNameExists = async (entityName: string): Promise<boolean> => {
try {
const response = await axiosInstance.get(`/graph/entity/exists?name=${encodeURIComponent(entityName)}`)
return response.data.exists
} catch (error) {
console.error('Error checking entity name:', error)
return false
}
}
/**
* Get the processing status of documents by tracking ID
* @param trackId The tracking ID returned from upload, text, or texts endpoints
* @returns Promise with the track status response containing documents and summary
*/
export const getTrackStatus = async (trackId: string): Promise<TrackStatusResponse> => {
const response = await axiosInstance.get(`/documents/track_status/${encodeURIComponent(trackId)}`)
return response.data
}
/**
* Get documents with pagination support
* @param request The pagination request parameters
* @returns Promise with paginated documents response
*/
export const getDocumentsPaginated = async (request: DocumentsRequest): Promise<PaginatedDocsResponse> => {
const response = await axiosInstance.post('/documents/paginated', request)
return response.data
}
/**
* Get counts of documents by status
* @returns Promise with status counts response
*/
export const getDocumentStatusCounts = async (): Promise<StatusCountsResponse> => {
const response = await axiosInstance.get('/documents/status_counts')
return response.data
}
@@ -0,0 +1,86 @@
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>('')
const message = useBackendState.use.message()
useEffect(() => {
setTempApiKey(apiKey || '')
}, [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,74 @@
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')
}, [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>
</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>
)
}
@@ -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
useEffect(() => {
if (!open) {
setConfirmText('')
setClearCacheOption(false)
setIsClearing(false)
// Clear timeout timer
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [open])
// 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
setOpen(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, setOpen, t, onDocumentsCleared, CLEAR_TIMEOUT])
return (
<Dialog open={open} onOpenChange={setOpen}>
<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={() => setOpen(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,191 @@
import { useState, useCallback, useEffect } 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
useEffect(() => {
if (!open) {
setConfirmText('')
setDeleteFile(false)
setDeleteLLMCache(false)
setIsDeleting(false)
}
}, [open])
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
setOpen(false)
} catch (err) {
toast.error(t('documentPanel.deleteDocuments.error', { error: errorMessage(err) }))
setConfirmText('')
} finally {
setIsDeleting(false)
}
}, [isConfirmEnabled, selectedDocIds, deleteFile, deleteLLMCache, setOpen, t, onDocumentsDeleted])
return (
<Dialog open={open} onOpenChange={setOpen}>
<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={() => setOpen(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,269 @@
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 position when dialog opens
useEffect(() => {
if (open) {
setPosition('center')
setIsUserScrolled(false)
} else {
// Reset confirmation dialog state when main dialog closes
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,220 @@
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>
}
export default function UploadDocumentsDialog({ onDocumentsUploaded }: 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> = {}
// 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 === 'duplicated') {
uploadErrors[file.name] = t('documentPanel.uploadDocuments.fileUploader.duplicateFile')
setFileErrors(prev => ({
...prev,
[file.name]: t('documentPanel.uploadDocuments.fileUploader.duplicateFile')
}))
} else 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
}
} catch (err) {
console.error(`Upload failed for ${file.name}:`, err)
// Handle HTTP errors, including 400 errors
let errorMsg = errorMessage(err)
// 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 } } }
if (axiosError.response?.status === 400) {
// Extract specific error message from backend response
errorMsg = axiosError.response.data?.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]
)
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,288 @@
import { useState, useEffect } 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'
/**
* 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
}
/**
* 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
}: EditablePropertyRowProps) => {
const { t } = useTranslation()
const [isEditing, setIsEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [currentValue, setCurrentValue] = useState(initialValue)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [mergeDialogOpen, setMergeDialogOpen] = useState(false)
const [mergeDialogInfo, setMergeDialogInfo] = useState<{
targetEntity: string
sourceEntity: string
} | null>(null)
useEffect(() => {
setCurrentValue(initialValue)
}, [initialValue])
const handleEditClick = () => {
if (isEditable && !isEditing) {
setIsEditing(true)
setErrorMessage(null)
}
}
const handleCancel = () => {
setIsEditing(false)
setErrorMessage(null)
}
const handleSave = async (value: string, options?: { allowMerge?: boolean }) => {
if (isSubmitting || value === String(currentValue)) {
setIsEditing(false)
setErrorMessage(null)
return
}
setIsSubmitting(true)
setErrorMessage(null)
try {
if (entityType === 'node' && entityId && nodeId) {
let updatedData = { [name]: value }
const allowMerge = options?.allowMerge ?? false
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 new Error('Failed to update node in graph')
}
// 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 new Error('Failed to update edge in graph')
}
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} />
<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}
initialValue={String(currentValue)}
isSubmitting={isSubmitting}
errorMessage={errorMessage}
/>
<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,362 @@
import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
import { AbstractGraph } from 'graphology-types'
// import { useLayoutCircular } from '@react-sigma/layout-circular'
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useEffect, useState } from 'react'
// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'
import { EdgeType, NodeType } from '@/hooks/useLightragGraph'
import useTheme from '@/hooks/useTheme'
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 maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
const { assign: assignLayout } = useLayoutForceAtlas2({
iterations: maxIterations
})
const { theme } = useTheme()
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()
// Track system theme changes when theme is set to 'system'
const [systemThemeIsDark, setSystemThemeIsDark] = useState(() =>
window.matchMedia('(prefers-color-scheme: dark)').matches
)
useEffect(() => {
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handler = (e: MediaQueryListEvent) => setSystemThemeIsDark(e.matches)
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}
}, [theme])
/**
* When component mount or maxIterations changes
* => ensure graph reference and apply layout
*/
useEffect(() => {
if (sigmaGraph && sigma) {
// Ensure sigma binding to sigmaGraph
try {
if (typeof sigma.setGraph === 'function') {
sigma.setGraph(sigmaGraph as unknown as AbstractGraph<NodeType, EdgeType>);
console.log('Binding graph to sigma instance');
} else {
(sigma as any).graph = sigmaGraph;
console.warn('Simgma missing setGraph function, set graph property directly');
}
} catch (error) {
console.error('Error setting graph on sigma instance:', error);
}
assignLayout();
console.log('Initial layout applied to graph');
}
}, [sigma, sigmaGraph, assignLayout, maxIterations])
/**
* Ensure the sigma instance is set in the store
* This provides a backup in case the instance wasn't set in GraphViewer
*/
useEffect(() => {
if (sigma) {
// Double-check that the store has the sigma instance
const currentInstance = useGraphStore.getState().sigmaInstance;
if (!currentInstance) {
console.log('Setting sigma instance from GraphControl');
useGraphStore.getState().setSigmaInstance(sigma);
}
}
}, [sigma]);
/**
* When component mount
* => register events
*/
useEffect(() => {
const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =
useGraphStore.getState()
// Define event types
type NodeEvent = { node: string; event: { original: MouseEvent | TouchEvent } }
type EdgeEvent = { edge: string; event: { original: MouseEvent | TouchEvent } }
// Register all events, but edge events will only be processed if enableEdgeEvents is true
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()
}
// Only add edge event handlers if enableEdgeEvents is true
if (enableEdgeEvents) {
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)
}
}
}
// Register the events
registerEvents(events)
// Cleanup function - basic cleanup without relying on specific APIs
return () => {
try {
console.log('Cleaning up graph event listeners')
} catch (error) {
console.warn('Error cleaning up graph event listeners:', error)
}
}
}, [registerEvents, enableEdgeEvents, sigma])
/**
* When edge size settings change, recalculate edge sizes and refresh the sigma instance
* to ensure changes take effect immediately
*/
useEffect(() => {
if (sigma && sigmaGraph) {
// Get the graph from sigma
const graph = sigma.getGraph()
// Find min and max weight values
let minWeight = Number.MAX_SAFE_INTEGER
let maxWeight = 0
graph.forEachEdge(edge => {
// Get original weight (before scaling)
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1
if (typeof weight === 'number') {
minWeight = Math.min(minWeight, weight)
maxWeight = Math.max(maxWeight, weight)
}
})
// Scale edge sizes based on weight range and current min/max edge size settings
const weightRange = maxWeight - minWeight
if (weightRange > 0) {
const sizeScale = maxEdgeSize - minEdgeSize
graph.forEachEdge(edge => {
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1
if (typeof weight === 'number') {
const scaledSize = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5)
graph.setEdgeAttribute(edge, 'size', scaledSize)
}
})
} else {
// If all weights are the same, use default size
graph.forEachEdge(edge => {
graph.setEdgeAttribute(edge, 'size', minEdgeSize)
})
}
// Refresh the sigma instance to apply changes
sigma.refresh()
}
}, [sigma, sigmaGraph, minEdgeSize, maxEdgeSize])
/**
* When component mount or hovered node change
* => Setting the sigma reducers
*/
useEffect(() => {
// Check if dark mode is actually applied (handles both 'dark' theme and 'system' theme when OS is dark)
const isDarkTheme = theme === 'dark' ||
(theme === 'system' && window.document.documentElement.classList.contains('dark'))
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
// Update all dynamic settings directly without recreating the sigma container
setSettings({
// Update display settings
enableEdgeEvents,
renderEdgeLabels,
renderLabels,
// Node reducer for node appearance
nodeReducer: (node, data) => {
const graph = sigma.getGraph()
// Add defensive check for node existence during theme switching
if (!graph.hasNode(node)) {
console.warn(`Node ${node} not found in graph during theme switch, returning default data`)
return { ...data, highlighted: false, labelColor }
}
const newData: NodeType & {
labelColor?: string
borderColor?: string
} = { ...data, highlighted: data.highlighted || false, labelColor }
if (!disableHoverEffect) {
newData.highlighted = false
const _focusedNode = focusedNode || selectedNode
const _focusedEdge = focusedEdge || selectedEdge
if (_focusedNode && graph.hasNode(_focusedNode)) {
try {
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
newData.highlighted = true
if (node === selectedNode) {
newData.borderColor = Constants.nodeBorderColorSelected
}
}
} catch (error) {
console.error('Error in nodeReducer:', error);
return { ...data, highlighted: false, labelColor }
}
} else if (_focusedEdge && graph.hasEdge(_focusedEdge)) {
try {
if (graph.extremities(_focusedEdge).includes(node)) {
newData.highlighted = true
newData.size = 3
}
} catch (error) {
console.error('Error accessing edge extremities in nodeReducer:', error);
return { ...data, highlighted: false, labelColor }
}
} else {
return newData
}
if (newData.highlighted) {
if (isDarkTheme) {
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
}
} else {
newData.color = Constants.nodeColorDisabled
}
}
return newData
},
// Edge reducer for edge appearance
edgeReducer: (edge, data) => {
const graph = sigma.getGraph()
// Add defensive check for edge existence during theme switching
if (!graph.hasEdge(edge)) {
console.warn(`Edge ${edge} not found in graph during theme switch, returning default data`)
return { ...data, hidden: false, labelColor, color: edgeColor }
}
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
if (!disableHoverEffect) {
const _focusedNode = focusedNode || selectedNode
// Choose edge highlight color based on theme
const edgeHighlightColor = isDarkTheme
? Constants.edgeColorHighlightedDarkTheme
: Constants.edgeColorHighlightedLightTheme
if (_focusedNode && graph.hasNode(_focusedNode)) {
try {
if (hideUnselectedEdges) {
if (!graph.extremities(edge).includes(_focusedNode)) {
newData.hidden = true
}
} else {
if (graph.extremities(edge).includes(_focusedNode)) {
newData.color = edgeHighlightColor
}
}
} catch (error) {
console.error('Error in edgeReducer:', error);
return { ...data, hidden: false, labelColor, color: edgeColor }
}
} else {
const _selectedEdge = selectedEdge && graph.hasEdge(selectedEdge) ? selectedEdge : null;
const _focusedEdge = focusedEdge && graph.hasEdge(focusedEdge) ? focusedEdge : null;
if (_selectedEdge || _focusedEdge) {
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,
disableHoverEffect,
theme,
systemThemeIsDark,
hideUnselectedEdges,
enableEdgeEvents,
renderEdgeLabels,
renderLabels
])
return null
}
export default GraphControl
@@ -0,0 +1,316 @@
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 changes externally (e.g., from entity rename/merge)
useEffect(() => {
setSelectKey(prev => prev + 1)
}, [label])
// Force AsyncSelect to re-render when dropdown refresh is triggered (e.g., after entity rename)
useEffect(() => {
if (dropdownRefreshTrigger > 0) {
setSelectKey(prev => prev + 1)
}
}, [dropdownRefreshTrigger])
// Monitor pipeline state changes: busy -> idle
useEffect(() => {
if (prevPipelineBusy.current === true && pipelineBusy === false) {
console.log('Pipeline changed from busy to idle, marking for popular labels refresh')
shouldRefreshPopularLabelsRef.current = true
}
prevPipelineBusy.current = pipelineBusy
}, [pipelineBusy])
// Helper: Reload popular labels from backend
const reloadPopularLabels = useCallback(async () => {
if (!shouldRefreshPopularLabelsRef.current) return
console.log('Reloading popular labels (triggered by pipeline idle)')
try {
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
SearchHistoryManager.clearHistory()
if (popularLabels.length === 0) {
const fallbackLabels = ['entity', 'relationship', 'document', 'concept']
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} else {
await SearchHistoryManager.initializeWithDefaults(popularLabels)
}
} catch (error) {
console.error('Failed to reload popular labels:', error)
const fallbackLabels = ['entity', 'relationship', 'document']
SearchHistoryManager.clearHistory()
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} finally {
// Always clear the flag
shouldRefreshPopularLabelsRef.current = false
}
}, [])
// Helper: Bump dropdown data to trigger refresh
const bumpDropdownData = useCallback(({ forceSelectKey = false } = {}) => {
setRefreshTrigger(prev => prev + 1)
if (forceSelectKey) {
setSelectKey(prev => prev + 1)
}
}, [])
const fetchData = useCallback(
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,345 @@
import { useSigma } from '@react-sigma/core'
import { animateNodes } from 'sigma/utils'
import { useLayoutCirclepack } from '@react-sigma/layout-circlepack'
import { useLayoutCircular } from '@react-sigma/layout-circular'
import { LayoutHook, LayoutWorkerHook, WorkerLayoutControlProps } from '@react-sigma/layout-core'
import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
import { useLayoutRandom } from '@react-sigma/layout-random'
import { useCallback, useMemo, useState, useEffect, 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 } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
type LayoutName =
| 'Circular'
| 'Circlepack'
| 'Random'
| 'Noverlaps'
| 'Force Directed'
| 'Force Atlas'
// Extend WorkerLayoutControlProps to include mainLayout
interface ExtendedWorkerLayoutControlProps extends WorkerLayoutControlProps {
mainLayout: LayoutHook;
}
const WorkerLayoutControl = ({ layout, autoRunFor, mainLayout }: ExtendedWorkerLayoutControlProps) => {
const sigma = useSigma()
// Use local state to track animation running status
const [isRunning, setIsRunning] = useState(false)
// Timer reference for animation
const animationTimerRef = useRef<number | null>(null)
const { t } = useTranslation()
// Function to update node positions using the layout algorithm
const updatePositions = useCallback(() => {
if (!sigma) return
try {
const graph = sigma.getGraph()
if (!graph || graph.order === 0) return
// Use mainLayout to get positions, similar to refreshLayout function
// console.log('Getting positions from mainLayout')
const positions = mainLayout.positions()
// Animate nodes to new positions
// console.log('Updating node positions with layout algorithm')
animateNodes(graph, positions, { duration: 300 }) // Reduced duration for more frequent updates
} catch (error) {
console.error('Error updating positions:', error)
// Stop animation if there's an error
if (animationTimerRef.current) {
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
setIsRunning(false)
}
}
}, [sigma, mainLayout])
// Improved click handler that uses our own animation timer
const handleClick = useCallback(() => {
if (isRunning) {
// Stop the animation
console.log('Stopping layout animation')
if (animationTimerRef.current) {
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
}
// Try to kill the layout algorithm if it's running
try {
if (typeof layout.kill === 'function') {
layout.kill()
console.log('Layout algorithm killed')
} else if (typeof layout.stop === 'function') {
layout.stop()
console.log('Layout algorithm stopped')
}
} catch (error) {
console.error('Error stopping layout algorithm:', error)
}
setIsRunning(false)
} else {
// Start the animation
console.log('Starting layout animation')
// Initial position update
updatePositions()
// Set up interval for continuous updates
animationTimerRef.current = window.setInterval(() => {
updatePositions()
}, 200) // Reduced interval to create overlapping animations for smoother transitions
setIsRunning(true)
// Set a timeout to automatically stop the animation after 3 seconds
setTimeout(() => {
if (animationTimerRef.current) {
console.log('Auto-stopping layout animation after 3 seconds')
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
setIsRunning(false)
// Try to stop the layout algorithm
try {
if (typeof layout.kill === 'function') {
layout.kill()
} else if (typeof layout.stop === 'function') {
layout.stop()
}
} catch (error) {
console.error('Error stopping layout algorithm:', error)
}
}
}, 3000)
}
}, [isRunning, layout, updatePositions])
/**
* Init component when Sigma or component settings change.
*/
useEffect(() => {
if (!sigma) {
console.log('No sigma instance available')
return
}
// Auto-run if specified
let timeout: number | null = null
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
console.log('Auto-starting layout animation')
// Initial position update
updatePositions()
// Set up interval for continuous updates
animationTimerRef.current = window.setInterval(() => {
updatePositions()
}, 200) // Reduced interval to create overlapping animations for smoother transitions
setIsRunning(true)
// Set a timeout to stop it if autoRunFor > 0
if (autoRunFor > 0) {
timeout = window.setTimeout(() => {
console.log('Auto-stopping layout animation after timeout')
if (animationTimerRef.current) {
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
}
setIsRunning(false)
}, autoRunFor)
}
}
// Cleanup function
return () => {
// console.log('Cleaning up WorkerLayoutControl')
if (animationTimerRef.current) {
window.clearInterval(animationTimerRef.current)
animationTimerRef.current = null
}
if (timeout) {
window.clearTimeout(timeout)
}
setIsRunning(false)
}
}, [autoRunFor, sigma, updatePositions])
return (
<Button
size="icon"
onClick={handleClick}
tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}
variant={controlButtonVariant}
>
{isRunning ? <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)
const maxIterations = useSettingsStore.use.graphLayoutMaxIterations()
const layoutCircular = useLayoutCircular()
const layoutCirclepack = useLayoutCirclepack()
const layoutRandom = useLayoutRandom()
const layoutNoverlap = useLayoutNoverlap({
maxIterations: maxIterations,
settings: {
margin: 5,
expansion: 1.1,
gridSize: 1,
ratio: 1,
speed: 3,
}
})
// Add parameters for Force Directed layout to improve convergence
const layoutForce = useLayoutForce({
maxIterations: maxIterations,
settings: {
attraction: 0.0003, // Lower attraction force to reduce oscillation
repulsion: 0.02, // Lower repulsion force to reduce oscillation
gravity: 0.02, // Increase gravity to make nodes converge to center faster
inertia: 0.4, // Lower inertia to add damping effect
maxMove: 100 // Limit maximum movement per step to prevent large jumps
}
})
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: maxIterations })
const workerNoverlap = useWorkerLayoutNoverlap()
const workerForce = useWorkerLayoutForce()
const workerForceAtlas2 = useWorkerLayoutForceAtlas2()
const layouts = useMemo(() => {
return {
Circular: {
layout: layoutCircular
},
Circlepack: {
layout: layoutCirclepack
},
Random: {
layout: layoutRandom
},
Noverlaps: {
layout: layoutNoverlap,
worker: workerNoverlap
},
'Force Directed': {
layout: layoutForce,
worker: workerForce
},
'Force Atlas': {
layout: layoutForceAtlas2,
worker: workerForceAtlas2
}
} as { [key: string]: { layout: LayoutHook; worker?: LayoutWorkerHook } }
}, [
layoutCirclepack,
layoutCircular,
layoutForce,
layoutForceAtlas2,
layoutNoverlap,
layoutRandom,
workerForce,
workerNoverlap,
workerForceAtlas2
])
const runLayout = useCallback(
(newLayout: LayoutName) => {
console.debug('Running layout:', newLayout)
const { positions } = layouts[newLayout].layout
try {
const graph = sigma.getGraph()
if (!graph) {
console.error('No graph available')
return
}
const pos = positions()
console.log('Positions calculated, animating nodes')
animateNodes(graph, pos, { duration: 400 })
setLayout(newLayout)
} catch (error) {
console.error('Error running layout:', error)
}
},
[layouts, sigma]
)
return (
<div>
<div>
{layouts[layout] && 'worker' in layouts[layout] && (
<WorkerLayoutControl
layout={layouts[layout].worker!}
mainLayout={layouts[layout].layout}
/>
)}
</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="p-1 min-w-auto"
>
<Command>
<CommandList>
<CommandGroup>
{Object.keys(layouts).map((name) => (
<CommandItem
onSelect={() => {
runLayout(name as LayoutName)
}}
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,408 @@
import { useEffect, useState } from 'react'
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
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 } 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 [currentElement, setCurrentElement] = useState<NodeType | EdgeType | null>(null)
const [currentType, setCurrentType] = useState<'node' | 'edge' | null>(null)
// This effect will run when selection changes or when graph data is updated
useEffect(() => {
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) {
if (type == 'node') {
setCurrentElement(refineNodeProperties(element as any))
} else {
setCurrentElement(refineEdgeProperties(element as any))
}
setCurrentType(type)
} else {
setCurrentElement(null)
setCurrentType(null)
}
}, [
focusedNode,
selectedNode,
focusedEdge,
selectedEdge,
graphDataVersion, // Add dependency on graphDataVersion to refresh when data changes
setCurrentElement,
setCurrentType,
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} />
) : (
<EdgePropertiesView edge={currentElement as any} />
)}
</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
}: {
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
}) => {
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}
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 }: { node: NodeType }) => {
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">
<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']}
/>
)
})}
</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 }: { edge: EdgeType }) => {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2">
<h3 className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</h3>
<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']}
/>
)
})}
</div>
</div>
)
}
export default PropertiesView
@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react'
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: (value: string, options?: { allowMerge?: boolean }) => void
propertyName: string
initialValue: string
isSubmitting?: boolean
errorMessage?: string | null
}
/**
* 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,
initialValue,
isSubmitting = false,
errorMessage = null
}: PropertyEditDialogProps) => {
const { t } = useTranslation()
const [value, setValue] = useState('')
const [allowMerge, setAllowMerge] = useState(false)
// Initialize value when dialog opens
useEffect(() => {
if (isOpen) {
setValue(initialValue)
setAllowMerge(false)
}
}, [isOpen, initialValue])
// 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 !== '') {
const options = propertyName === 'entity_id' ? { allowMerge } : undefined
await onSave(trimmedValue, options)
}
}
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>
{/* 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) => setValue(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) => setValue(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) => setAllowMerge(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}
>
{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,426 @@
import { useState, useCallback, useEffect} 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 } 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
}: {
checked: boolean
onCheckedChange: () => void
label: string
}) => {
// Create unique ID using the label text converted to lowercase with spaces removed
const id = `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;
return (
<div className="flex items-center gap-2">
<Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} />
<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, '-')}`;
useEffect(() => {
setCurrentValue(value)
}, [value])
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 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 graphLayoutMaxIterations = useSettingsStore.use.graphLayoutMaxIterations()
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 })
const currentLabel = useSettingsStore.getState().queryLabel
useSettingsStore.getState().setQueryLabel('')
setTimeout(() => {
useSettingsStore.getState().setQueryLabel(currentLabel)
}, 300)
}, [])
const setGraphMaxNodes = useCallback((nodes: number) => {
const maxLimit = backendMaxGraphNodes || 1000
if (nodes < 1 || nodes > maxLimit) return
useSettingsStore.getState().setGraphMaxNodes(nodes, true)
}, [backendMaxGraphNodes])
const setGraphLayoutMaxIterations = useCallback((iterations: number) => {
if (iterations < 1) return
useSettingsStore.setState({ graphLayoutMaxIterations: iterations })
}, [])
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')}
/>
<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}
/>
<LabeledNumberInput
label={t('graphPanel.sideBar.settings.maxLayoutIterations')}
min={1}
max={30}
value={graphLayoutMaxIterations}
defaultValue={15}
onEditFinished={setGraphLayoutMaxIterations}
/>
{/* 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,21 @@
import { useSettingsStore } from '@/stores/settings'
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()
const graphMaxNodes = useSettingsStore.use.graphMaxNodes()
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.max')}: {graphMaxNodes}</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,508 @@
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
/**
* 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
useEffect(() => {
if (isThinking) {
// When thinking starts, always reset to collapsed state
setIsThinkingExpanded(false)
}
}, [isThinking, message.id])
// 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 dynamically
useEffect(() => {
const loadKaTeX = async () => {
try {
const { default: rehypeKatex } = await import('rehype-katex');
setKatexPlugin(() => rehypeKatex);
} catch (error) {
console.error('Failed to load KaTeX plugin:', error);
// Set to null to ensure we don't try to use a failed plugin
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">
<ReactMarkdown
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'
}`}
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>
)}
{/* Loading indicator - only show in active tab */}
{isTabActive && (() => {
// 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,457 @@
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'
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])
// Reset button component
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>
)
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="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"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<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="hybrid">{t('retrievePanel.querySettings.queryModeOptions.hybrid')}</SelectItem>
<SelectItem value="mix">{t('retrievePanel.querySettings.queryModeOptions.mix')}</SelectItem>
<SelectItem value="bypass">{t('retrievePanel.querySettings.queryModeOptions.bypass')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<ResetButton
onClick={() => handleReset('mode')}
title="Reset to default (Mix)"
/>
</div>
</>
{/* 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,91 @@
import { LightragStatus } from '@/api/lightrag'
import { useTranslation } from 'react-i18next'
const StatusCard = ({ status }: { status: LightragStatus | null }) => {
const { t } = useTranslation()
if (!status) {
return <div className="text-foreground text-xs">{t('graphPanel.statusCard.unavailable')}</div>
}
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.workingDirectory')}:</span>
<span className="truncate">{status.working_directory}</span>
<span>{t('graphPanel.statusCard.inputDirectory')}:</span>
<span className="truncate">{status.input_directory}</span>
<span>{t('graphPanel.statusCard.summarySettings')}:</span>
<span>{status.configuration.summary_language} / LLM summary on {status.configuration.force_llm_summary_on_merge.toString()} fragments</span>
<span>{t('graphPanel.statusCard.threshold')}:</span>
<span>cosine {status.configuration.cosine_threshold} / rerank_score {status.configuration.min_rerank_score} / max_related {status.configuration.related_chunk_number}</span>
<span>{t('graphPanel.statusCard.maxParallelInsert')}:</span>
<span>{status.configuration.max_parallel_insert}</span>
</div>
</div>
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.llmConfig')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.llmBindingHost')}:</span>
<span>{status.configuration.llm_binding_host}</span>
<span>{t('graphPanel.statusCard.llmModel')}:</span>
<span>{status.configuration.llm_binding}: {status.configuration.llm_model} (#{status.configuration.max_async} Async)</span>
</div>
</div>
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.embeddingConfig')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.embeddingBindingHost')}:</span>
<span>{status.configuration.embedding_binding_host}</span>
<span>{t('graphPanel.statusCard.embeddingModel')}:</span>
<span>{status.configuration.embedding_binding}: {status.configuration.embedding_model} (#{status.configuration.embedding_func_max_async} Async * {status.configuration.embedding_batch_num} batches)</span>
</div>
</div>
{status.configuration.enable_rerank && (
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.rerankerConfig')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.rerankerBindingHost')}:</span>
<span>{status.configuration.rerank_binding_host || '-'}</span>
<span>{t('graphPanel.statusCard.rerankerModel')}:</span>
<span>{(status.configuration.rerank_binding || '-')} : {(status.configuration.rerank_model || '-')}</span>
</div>
</div>
)}
<div className="space-y-1">
<h4 className="font-medium">{t('graphPanel.statusCard.storageConfig')}</h4>
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
<span>{t('graphPanel.statusCard.kvStorage')}:</span>
<span>{status.configuration.kv_storage}</span>
<span>{t('graphPanel.statusCard.docStatusStorage')}:</span>
<span>{status.configuration.doc_status_storage}</span>
<span>{t('graphPanel.statusCard.graphStorage')}:</span>
<span>{status.configuration.graph_storage}</span>
<span>{t('graphPanel.statusCard.vectorStorage')}:</span>
<span>{status.configuration.vector_storage}</span>
<span>{t('graphPanel.statusCard.workspace')}:</span>
<span>{status.configuration.workspace || '-'}</span>
<span>{t('graphPanel.statusCard.maxGraphNodes')}:</span>
<span>{status.configuration.max_graph_nodes || '-'}</span>
{status.keyed_locks && (
<>
<span>{t('graphPanel.statusCard.lockStatus')}:</span>
<span>
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>
)
}
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="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>{t('graphPanel.statusDialog.title')}</DialogTitle>
<DialogDescription>
{t('graphPanel.statusDialog.description')}
</DialogDescription>
</DialogHeader>
<StatusCard status={status} />
</DialogContent>
</Dialog>
)
}
export default StatusDialog
@@ -0,0 +1,52 @@
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(() => {
setAnimate(true)
const timer = setTimeout(() => setAnimate(false), 300)
return () => 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,242 @@
import React, { useState, useEffect, useCallback, 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 [mounted, setMounted] = useState(false)
const [open, setOpen] = useState(false)
const [options, setOptions] = useState<T[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setMounted(true)
}, [])
// 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)
setOptions(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch options')
} finally {
setLoading(false)
}
}, [fetcher])
// Load options when search term changes
useEffect(() => {
if (!mounted) return
if (preload) {
if (debouncedSearchTerm) {
setOptions((prev) =>
prev.filter((option) =>
filterFn ? filterFn(option, debouncedSearchTerm) : true
)
)
}
} else {
fetchOptions(debouncedSearchTerm)
}
}, [mounted, debouncedSearchTerm, preload, filterFn, fetchOptions])
// Load initial value
useEffect(() => {
if (!mounted || !value) return
fetchOptions(value)
}, [mounted, value, fetchOptions])
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,315 @@
import { useState, useEffect, useCallback } 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 [mounted, setMounted] = useState(false)
const [open, setOpen] = useState(false)
const [options, setOptions] = useState<T[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedValue, setSelectedValue] = useState(value)
const [selectedOption, setSelectedOption] = useState<T | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : debounceTime)
const [originalOptions, setOriginalOptions] = useState<T[]>([])
const [initialValueDisplay, setInitialValueDisplay] = useState<React.ReactNode | null>(null)
useEffect(() => {
setMounted(true)
setSelectedValue(value)
}, [value])
// Add an effect to handle initial value display
useEffect(() => {
if (value && (!options.length || !selectedOption)) {
// Create a temporary display until options are loaded
setInitialValueDisplay(<div>{value}</div>)
} else if (selectedOption) {
// Once we find the actual selectedOption, clear the temporary display
setInitialValueDisplay(null)
}
}, [value, options.length, selectedOption])
// Initialize selectedOption when options are loaded and value exists
useEffect(() => {
if (value && options.length > 0) {
const option = options.find((opt) => getOptionValue(opt) === value)
if (option) {
setSelectedOption(option)
}
}
}, [value, options, getOptionValue])
// Effect for initial fetch
useEffect(() => {
const initializeOptions = async () => {
try {
setLoading(true)
setError(null)
// Always use empty query for initial load to show search history
const data = await fetcher('')
setOriginalOptions(data)
setOptions(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch options')
} finally {
setLoading(false)
}
}
if (!mounted) {
initializeOptions()
}
}, [mounted, fetcher])
useEffect(() => {
const fetchOptions = async () => {
try {
setLoading(true)
setError(null)
const data = await fetcher(debouncedSearchTerm)
setOriginalOptions(data)
setOptions(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch options')
} finally {
setLoading(false)
}
}
if (!mounted) {
fetchOptions()
} else if (!preload) {
fetchOptions()
} else if (preload) {
if (debouncedSearchTerm) {
setOptions(
originalOptions.filter((option) =>
filterFn ? filterFn(option, debouncedSearchTerm) : true
)
)
} else {
setOptions(originalOptions)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher, debouncedSearchTerm, mounted, preload, filterFn])
const handleSelect = useCallback(
(currentValue: string) => {
const newValue = clearable && currentValue === selectedValue ? '' : currentValue
setSelectedValue(newValue)
setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null)
onChange(newValue)
setOpen(false)
},
[selectedValue, onChange, clearable, options, getOptionValue]
)
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',
selectedValue === 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
@@ -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,143 @@
import * as React from 'react'
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent } from './Dialog'
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
// eslint-disable-next-line react/no-unknown-property
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
// eslint-disable-next-line @stylistic/js/quotes
"data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator
}
@@ -0,0 +1,64 @@
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>) {
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>
)
}
@@ -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 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}
>
{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 w-full flex-col items-center justify-center space-y-6 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,454 @@
/**
* @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({
prop: valueProp,
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,131 @@
import { ChevronDown, ChevronUp } from 'lucide-react'
import { forwardRef, useCallback, useEffect, 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)
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])
useEffect(() => {
if (controlledValue !== undefined) {
setValue(controlledValue)
}
}, [controlledValue])
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,259 @@
import { useState, useEffect, 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())
// Update input when currentPage changes
useEffect(() => {
setInputPage(currentPage.toString())
}, [currentPage])
// 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 }
@@ -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 }
@@ -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 }
@@ -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,59 @@
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
useEffect(() => {
setVisibleTabs((prev) => ({
...prev,
'documents': true,
'knowledge-graph': true,
'retrieval': true,
'api': true
}));
}, [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;
@@ -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);
@@ -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;
};
@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react'
import { useTabVisibility } from '@/contexts/useTabVisibility'
import { backendBaseUrl } from '@/lib/constants'
import { useTranslation } from 'react-i18next'
export default function ApiSite() {
const { t } = useTranslation()
const { isTabVisible } = useTabVisibility()
const isApiTabVisible = isTabVisible('api')
const [iframeLoaded, setIframeLoaded] = useState(false)
// Load the iframe once on component mount
useEffect(() => {
if (!iframeLoaded) {
setIframeLoaded(true)
}
}, [iframeLoaded])
// Use CSS to hide content when tab is not visible
return (
<div className={`size-full ${isApiTabVisible ? '' : 'hidden'}`}>
{iframeLoaded ? (
<iframe
src={backendBaseUrl + '/docs'}
className="size-full w-full h-full"
style={{ width: '100%', height: '100%', border: 'none' }}
// Use key to ensure iframe doesn't reload
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
@@ -0,0 +1,260 @@
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, 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 { labelColorDarkTheme, labelColorLightTheme } 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
const createSigmaSettings = (isDarkTheme: boolean): Partial<SigmaSettings> => ({
allowInvalidContainer: true,
defaultNodeType: 'default',
defaultEdgeType: 'curvedNoArrow',
renderEdgeLabels: false,
edgeProgramClasses: {
arrow: EdgeArrowProgram,
curvedArrow: EdgeCurvedArrowProgram,
curvedNoArrow: createEdgeCurveProgram()
},
nodeProgramClasses: {
default: NodeBorderProgram,
circel: NodeCircleProgram,
point: NodePointProgram
},
labelGridCellSize: 60,
labelRenderedSizeThreshold: 12,
enableEdgeEvents: true,
labelColor: {
color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,
attribute: 'labelColor'
},
edgeLabelColor: {
color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,
attribute: 'labelColor'
},
edgeLabelSize: 8,
labelSize: 12
// minEdgeThickness: 2
// labelFont: 'Lato, sans-serif'
})
const GraphEvents = () => {
const registerEvents = useRegisterEvents()
const sigma = useSigma()
const [draggedNode, setDraggedNode] = useState<string | null>(null)
useEffect(() => {
// Register the events
registerEvents({
downNode: (e) => {
setDraggedNode(e.node)
sigma.getGraph().setNodeAttribute(e.node, 'highlighted', true)
},
// On mouse move, if the drag mode is enabled, we change the position of the draggedNode
mousemovebody: (e) => {
if (!draggedNode) return
// Get new position of node
const pos = sigma.viewportToGraph(e)
sigma.getGraph().setNodeAttribute(draggedNode, 'x', pos.x)
sigma.getGraph().setNodeAttribute(draggedNode, 'y', pos.y)
// Prevent sigma to move camera:
e.preventSigmaDefault()
e.original.preventDefault()
e.original.stopPropagation()
},
// On mouse up, we reset the autoscale and the dragging mode
mouseup: () => {
if (draggedNode) {
setDraggedNode(null)
sigma.getGraph().removeNodeAttribute(draggedNode, 'highlighted')
}
},
// Disable the autoscale at the first down interaction
mousedown: (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 [isThemeSwitching, setIsThemeSwitching] = useState(false)
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 showPropertyPanel = useSettingsStore.use.showPropertyPanel()
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
const showLegend = useSettingsStore.use.showLegend()
const theme = useSettingsStore.use.theme()
// Memoize sigma settings to prevent unnecessary re-creation
const memoizedSigmaSettings = useMemo(() => {
const isDarkTheme = theme === 'dark'
return createSigmaSettings(isDarkTheme)
}, [theme])
// Initialize sigma settings based on theme with theme switching protection
useEffect(() => {
// Detect theme change
const isThemeChange = prevTheme.current && prevTheme.current !== theme
if (isThemeChange) {
setIsThemeSwitching(true)
console.log('Theme switching detected:', prevTheme.current, '->', theme)
// Reset theme switching state after a short delay
const timer = setTimeout(() => {
setIsThemeSwitching(false)
console.log('Theme switching completed')
}, 150)
return () => 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 bottom-10 right-2 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 when data is loading or theme is switching */}
{(isFetching || isThemeSwitching) && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<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>{isThemeSwitching ? 'Switching Theme...' : 'Loading Graph Data...'}</p>
</div>
</div>
)}
</div>
)
}
export default GraphViewer
@@ -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,824 @@
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 } 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 RetrievalTesting() {
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)
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]
// 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)
// 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
})
if (errorMessage) {
if (assistantMessage.content) {
errorMessage = assistantMessage.content + '\n' + errorMessage
}
updateAssistantMessage(errorMessage, true)
}
} else {
const response = await queryText(queryParams)
updateAssistantMessage(response.response)
}
} catch (err) {
// Handle error
updateAssistantMessage(`${t('retrievePanel.retrieval.error')}\n${errorMessage(err)}`, true)
} finally {
// Clear loading and add messages to state
setIsLoading(false)
isReceivingResponseRef.current = false
// 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)
// 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;
}
};
}, []);
// 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])
// Handle copying message content with robust clipboard support
const handleCopyMessage = useCallback(async (message: MessageWithError) => {
let contentToCopy = '';
if (message.role === 'user') {
// User messages: copy original content
contentToCopy = message.content || '';
} else {
// Assistant messages: prefer processed display content, fallback to original content
const finalDisplayContent = message.displayContent !== undefined
? message.displayContent
: (message.content || '');
contentToCopy = finalDisplayContent;
}
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={() => {
if (shouldFollowScrollRef.current) {
shouldFollowScrollRef.current = false;
}
}}
>
<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>
<Button type="submit" variant="default" disabled={isLoading} size="sm">
<SendIcon />
{t('retrievePanel.retrieval.send')}
</Button>
</form>
</div>
<QuerySettings />
</div>
)
}
@@ -0,0 +1,149 @@
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, GithubIcon, LogOutIcon } from 'lucide-react'
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" aria-hidden="true" />
</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,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,961 @@
import Graph, { UndirectedGraph } from 'graphology'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { errorMessage } from '@/lib/utils'
import * as Constants from '@/lib/constants'
import { useGraphStore, RawGraph, RawNodeType, RawEdgeType } from '@/stores/graph'
import { toast } from 'sonner'
import { queryGraphs } from '@/api/lightrag'
import { useBackendState } from '@/stores/state'
import { useSettingsStore } from '@/stores/settings'
import seedrandom from 'seedrandom'
import { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor'
// Select color based on node type
const getNodeColorByType = (nodeType: string | undefined): string => {
const state = useGraphStore.getState()
const { color, map, updated } = resolveNodeColor(nodeType, state.typeColorMap)
if (updated) {
useGraphStore.setState({ typeColorMap: map })
}
return color || DEFAULT_NODE_COLOR
};
const validateGraph = (graph: RawGraph) => {
// Check if graph exists
if (!graph) {
console.log('Graph validation failed: graph is null');
return false;
}
// Check if nodes and edges are arrays
if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
console.log('Graph validation failed: nodes or edges is not an array');
return false;
}
// Check if nodes array is empty
if (graph.nodes.length === 0) {
console.log('Graph validation failed: nodes array is empty');
return false;
}
// Validate each node
for (const node of graph.nodes) {
if (!node.id || !node.labels || !node.properties) {
console.log('Graph validation failed: invalid node structure');
return false;
}
}
// Validate each edge
for (const edge of graph.edges) {
if (!edge.id || !edge.source || !edge.target) {
console.log('Graph validation failed: invalid edge structure');
return false;
}
}
// Validate edge connections
for (const edge of graph.edges) {
const source = graph.getNode(edge.source);
const target = graph.getNode(edge.target);
if (source == undefined || target == undefined) {
console.log('Graph validation failed: edge references non-existent node');
return false;
}
}
console.log('Graph validation passed');
return true;
}
export type NodeType = {
x: number
y: number
label: string
size: number
color: string
highlighted?: boolean
}
export type EdgeType = {
label: string
originalWeight?: number
size?: number
color?: string
hidden?: boolean
}
const fetchGraph = async (label: string, maxDepth: number, maxNodes: number) => {
let rawData: any = null;
// Trigger GraphLabels component to check if the label is valid
// console.log('Setting labelsFetchAttempted to true');
useGraphStore.getState().setLabelsFetchAttempted(true)
// If label is empty, use default label '*'
const queryLabel = label || '*';
try {
console.log(`Fetching graph label: ${queryLabel}, depth: ${maxDepth}, nodes: ${maxNodes}`);
rawData = await queryGraphs(queryLabel, maxDepth, maxNodes);
} catch (e) {
useBackendState.getState().setErrorMessage(errorMessage(e), 'Query Graphs Error!');
return null;
}
let rawGraph = null;
if (rawData) {
const nodeIdMap: Record<string, number> = {}
const edgeIdMap: Record<string, number> = {}
for (let i = 0; i < rawData.nodes.length; i++) {
const node = rawData.nodes[i]
nodeIdMap[node.id] = i
node.x = Math.random()
node.y = Math.random()
node.degree = 0
node.size = 10
}
for (let i = 0; i < rawData.edges.length; i++) {
const edge = rawData.edges[i]
edgeIdMap[edge.id] = i
const source = nodeIdMap[edge.source]
const target = nodeIdMap[edge.target]
if (source !== undefined && target !== undefined) {
const sourceNode = rawData.nodes[source]
if (!sourceNode) {
console.error(`Source node ${edge.source} is undefined`)
continue
}
const targetNode = rawData.nodes[target]
if (!targetNode) {
console.error(`Target node ${edge.target} is undefined`)
continue
}
sourceNode.degree += 1
targetNode.degree += 1
}
}
// generate node size
let minDegree = Number.MAX_SAFE_INTEGER
let maxDegree = 0
for (const node of rawData.nodes) {
minDegree = Math.min(minDegree, node.degree)
maxDegree = Math.max(maxDegree, node.degree)
}
const range = maxDegree - minDegree
if (range > 0) {
const scale = Constants.maxNodeSize - Constants.minNodeSize
for (const node of rawData.nodes) {
node.size = Math.round(
Constants.minNodeSize + scale * Math.pow((node.degree - minDegree) / range, 0.5)
)
}
}
rawGraph = new RawGraph()
rawGraph.nodes = rawData.nodes
rawGraph.edges = rawData.edges
rawGraph.nodeIdMap = nodeIdMap
rawGraph.edgeIdMap = edgeIdMap
if (!validateGraph(rawGraph)) {
rawGraph = null
console.warn('Invalid graph data')
}
console.log('Graph data loaded')
}
// console.debug({ data: JSON.parse(JSON.stringify(rawData)) })
return { rawGraph, is_truncated: rawData.is_truncated }
}
// Create a new graph instance with the raw graph data
const createSigmaGraph = (rawGraph: RawGraph | null) => {
// Get edge size settings from store
const minEdgeSize = useSettingsStore.getState().minEdgeSize
const maxEdgeSize = useSettingsStore.getState().maxEdgeSize
// Skip graph creation if no data or empty nodes
if (!rawGraph || !rawGraph.nodes.length) {
console.log('No graph data available, skipping sigma graph creation');
return null;
}
// Create new graph instance
const graph = new UndirectedGraph()
// Add nodes from raw graph data
for (const rawNode of rawGraph?.nodes ?? []) {
// Ensure we have fresh random positions for nodes
seedrandom(rawNode.id + Date.now().toString(), { global: true })
const x = Math.random()
const y = Math.random()
graph.addNode(rawNode.id, {
label: rawNode.labels.join(', '),
color: rawNode.color,
x: x,
y: y,
size: rawNode.size,
// for node-border
borderColor: Constants.nodeBorderColor,
borderSize: 0.2
})
}
// Add edges from raw graph data
for (const rawEdge of rawGraph?.edges ?? []) {
// Get weight from edge properties or default to 1
const weight = rawEdge.properties?.weight !== undefined ? Number(rawEdge.properties.weight) : 1
rawEdge.dynamicId = graph.addEdge(rawEdge.source, rawEdge.target, {
label: rawEdge.properties?.keywords || undefined,
size: weight, // Set initial size based on weight
originalWeight: weight, // Store original weight for recalculation
type: 'curvedNoArrow' // Explicitly set edge type to no arrow
})
}
// Calculate edge size based on weight range, similar to node size calculation
let minWeight = Number.MAX_SAFE_INTEGER
let maxWeight = 0
// Find min and max weight values
graph.forEachEdge(edge => {
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1
minWeight = Math.min(minWeight, weight)
maxWeight = Math.max(maxWeight, weight)
})
// Scale edge sizes based on weight range
const weightRange = maxWeight - minWeight
if (weightRange > 0) {
const sizeScale = maxEdgeSize - minEdgeSize
graph.forEachEdge(edge => {
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1
const scaledSize = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5)
graph.setEdgeAttribute(edge, 'size', scaledSize)
})
} else {
// If all weights are the same, use default size
graph.forEachEdge(edge => {
graph.setEdgeAttribute(edge, 'size', minEdgeSize)
})
}
return graph
}
const useLightrangeGraph = () => {
const { t } = useTranslation()
const queryLabel = useSettingsStore.use.queryLabel()
const rawGraph = useGraphStore.use.rawGraph()
const sigmaGraph = useGraphStore.use.sigmaGraph()
const maxQueryDepth = useSettingsStore.use.graphQueryMaxDepth()
const maxNodes = useSettingsStore.use.graphMaxNodes()
const isFetching = useGraphStore.use.isFetching()
const nodeToExpand = useGraphStore.use.nodeToExpand()
const nodeToPrune = useGraphStore.use.nodeToPrune()
const graphDataVersion = useGraphStore.use.graphDataVersion()
// Use ref to track if data has been loaded and initial load
const dataLoadedRef = useRef(false)
const initialLoadRef = useRef(false)
// Use ref to track if empty data has been handled
const emptyDataHandledRef = useRef(false)
const getNode = useCallback(
(nodeId: string) => {
return rawGraph?.getNode(nodeId) || null
},
[rawGraph]
)
const getEdge = useCallback(
(edgeId: string, dynamicId: boolean = true) => {
return rawGraph?.getEdge(edgeId, dynamicId) || null
},
[rawGraph]
)
// Track if a fetch is in progress to prevent multiple simultaneous fetches
const fetchInProgressRef = useRef(false)
// Reset graph when query label is cleared
useEffect(() => {
if (!queryLabel && (rawGraph !== null || sigmaGraph !== null)) {
const state = useGraphStore.getState()
state.reset()
state.setGraphDataFetchAttempted(false)
state.setLabelsFetchAttempted(false)
dataLoadedRef.current = false
initialLoadRef.current = false
}
}, [queryLabel, rawGraph, sigmaGraph])
// Graph data fetching logic
useEffect(() => {
// Skip if fetch is already in progress
if (fetchInProgressRef.current) {
return
}
// Empty queryLabel should be only handle once(avoid infinite loop)
if (!queryLabel && emptyDataHandledRef.current) {
return;
}
// Only fetch data when graphDataFetchAttempted is false (avoids re-fetching on vite dev mode)
// GraphDataFetchAttempted must set to false when queryLabel is changed
if (!isFetching && !useGraphStore.getState().graphDataFetchAttempted) {
// Set flags
fetchInProgressRef.current = true
useGraphStore.getState().setGraphDataFetchAttempted(true)
const state = useGraphStore.getState()
state.setIsFetching(true)
// Clear selection and highlighted nodes before fetching new graph
state.clearSelection()
if (state.sigmaGraph) {
state.sigmaGraph.forEachNode((node) => {
state.sigmaGraph?.setNodeAttribute(node, 'highlighted', false)
})
}
console.log('Preparing graph data...')
// Use a local copy of the parameters
const currentQueryLabel = queryLabel
const currentMaxQueryDepth = maxQueryDepth
const currentMaxNodes = maxNodes
// Declare a variable to store data promise
let dataPromise: Promise<{ rawGraph: RawGraph | null; is_truncated: boolean | undefined } | null>;
// 1. If query label is not empty, use fetchGraph
if (currentQueryLabel) {
dataPromise = fetchGraph(currentQueryLabel, currentMaxQueryDepth, currentMaxNodes);
} else {
// 2. If query label is empty, set data to null
console.log('Query label is empty, show empty graph')
dataPromise = Promise.resolve({ rawGraph: null, is_truncated: false });
}
// 3. Process data
dataPromise.then((result) => {
const state = useGraphStore.getState()
const data = result?.rawGraph;
// Assign colors based on entity_type *after* fetching
if (data && data.nodes) {
data.nodes.forEach(node => {
// Use entity_type instead of type
const nodeEntityType = node.properties?.entity_type as string | undefined;
node.color = getNodeColorByType(nodeEntityType);
});
}
if (result?.is_truncated) {
toast.info(t('graphPanel.dataIsTruncated', 'Graph data is truncated to Max Nodes'));
}
// Reset state
state.reset()
// Check if data is empty or invalid
if (!data || !data.nodes || data.nodes.length === 0) {
// Create a graph with a single "Graph Is Empty" node
const emptyGraph = new UndirectedGraph();
// Add a single node with "Graph Is Empty" label
emptyGraph.addNode('empty-graph-node', {
label: t('graphPanel.emptyGraph'),
color: '#5D6D7E', // gray color
x: 0.5,
y: 0.5,
size: 15,
borderColor: Constants.nodeBorderColor,
borderSize: 0.2
});
// Set graph to store
state.setSigmaGraph(emptyGraph);
state.setRawGraph(null);
// Still mark graph as empty for other logic
state.setGraphIsEmpty(true);
// Check if the empty graph is due to 401 authentication error
const errorMessage = useBackendState.getState().message;
const isAuthError = errorMessage && errorMessage.includes('Authentication required');
// Only clear queryLabel if it's not an auth error and current label is not empty
if (!isAuthError && currentQueryLabel) {
useSettingsStore.getState().setQueryLabel('');
}
// Only clear last successful query label if it's not an auth error
if (!isAuthError) {
state.setLastSuccessfulQueryLabel('');
} else {
console.log('Keep queryLabel for post-login reload');
}
console.log(`Graph data is empty, created graph with empty graph node. Auth error: ${isAuthError}`);
} else {
// Create and set new graph
const newSigmaGraph = createSigmaGraph(data);
data.buildDynamicMap();
// Set new graph data
state.setSigmaGraph(newSigmaGraph);
state.setRawGraph(data);
state.setGraphIsEmpty(false);
// Update last successful query label
state.setLastSuccessfulQueryLabel(currentQueryLabel);
// Reset camera view
state.setMoveToSelectedNode(true);
}
// Update flags
dataLoadedRef.current = true
initialLoadRef.current = true
fetchInProgressRef.current = false
state.setIsFetching(false)
// Mark empty data as handled if data is empty and query label is empty
if ((!data || !data.nodes || data.nodes.length === 0) && !currentQueryLabel) {
emptyDataHandledRef.current = true;
}
}).catch((error) => {
console.error('Error fetching graph data:', error)
// Reset state on error
const state = useGraphStore.getState()
state.setIsFetching(false)
dataLoadedRef.current = false;
fetchInProgressRef.current = false
state.setGraphDataFetchAttempted(false)
state.setLastSuccessfulQueryLabel('') // Clear last successful query label on error
})
}
}, [queryLabel, maxQueryDepth, maxNodes, isFetching, t, graphDataVersion])
// Handle node expansion
useEffect(() => {
const handleNodeExpand = async (nodeId: string | null) => {
if (!nodeId || !sigmaGraph || !rawGraph) return;
try {
// Get the node to expand
const nodeToExpand = rawGraph.getNode(nodeId);
if (!nodeToExpand) {
console.error('Node not found:', nodeId);
return;
}
// Get the label of the node to expand
const label = nodeToExpand.labels[0];
if (!label) {
console.error('Node has no label:', nodeId);
return;
}
// Fetch the extended subgraph with depth 2
const extendedGraph = await queryGraphs(label, 2, 1000);
if (!extendedGraph || !extendedGraph.nodes || !extendedGraph.edges) {
console.error('Failed to fetch extended graph');
return;
}
// Process nodes to add required properties for RawNodeType
const processedNodes: RawNodeType[] = [];
for (const node of extendedGraph.nodes) {
// Generate random color values
seedrandom(node.id, { global: true });
const nodeEntityType = node.properties?.entity_type as string | undefined;
const color = getNodeColorByType(nodeEntityType);
// Create a properly typed RawNodeType
processedNodes.push({
id: node.id,
labels: node.labels,
properties: node.properties,
size: 10, // Default size, will be calculated later
x: Math.random(), // Random position, will be adjusted later
y: Math.random(), // Random position, will be adjusted later
color: color, // Random color
degree: 0 // Initial degree, will be calculated later
});
}
// Process edges to add required properties for RawEdgeType
const processedEdges: RawEdgeType[] = [];
for (const edge of extendedGraph.edges) {
// Create a properly typed RawEdgeType
processedEdges.push({
id: edge.id,
source: edge.source,
target: edge.target,
type: edge.type,
properties: edge.properties,
dynamicId: '' // Will be set when adding to sigma graph
});
}
// Store current node positions
const nodePositions: Record<string, {x: number, y: number}> = {};
sigmaGraph.forEachNode((node) => {
nodePositions[node] = {
x: sigmaGraph.getNodeAttribute(node, 'x'),
y: sigmaGraph.getNodeAttribute(node, 'y')
};
});
// Get existing node IDs
const existingNodeIds = new Set(sigmaGraph.nodes());
// Identify nodes and edges to keep
const nodesToAdd = new Set<string>();
const edgesToAdd = new Set<string>();
// Get degree maxDegree from existing graph for size calculations
const minDegree = 1;
let maxDegree = 0;
// Initialize edge weight min and max values
let minWeight = Number.MAX_SAFE_INTEGER;
let maxWeight = 0;
// Calculate node degrees and edge weights from existing graph
sigmaGraph.forEachNode(node => {
const degree = sigmaGraph.degree(node);
maxDegree = Math.max(maxDegree, degree);
});
// Calculate edge weights from existing graph
sigmaGraph.forEachEdge(edge => {
const weight = sigmaGraph.getEdgeAttribute(edge, 'originalWeight') || 1;
minWeight = Math.min(minWeight, weight);
maxWeight = Math.max(maxWeight, weight);
});
// First identify connectable nodes (nodes connected to the expanded node)
for (const node of processedNodes) {
// Skip if node already exists
if (existingNodeIds.has(node.id)) {
continue;
}
// Check if this node is connected to the selected node
const isConnected = processedEdges.some(
edge => (edge.source === nodeId && edge.target === node.id) ||
(edge.target === nodeId && edge.source === node.id)
);
if (isConnected) {
nodesToAdd.add(node.id);
}
}
// Calculate node degrees and track discarded edges in one pass
const nodeDegrees = new Map<string, number>();
const existingNodeDegreeIncrements = new Map<string, number>(); // Track degree increments for existing nodes
const nodesWithDiscardedEdges = new Set<string>();
for (const edge of processedEdges) {
const sourceExists = existingNodeIds.has(edge.source) || nodesToAdd.has(edge.source);
const targetExists = existingNodeIds.has(edge.target) || nodesToAdd.has(edge.target);
if (sourceExists && targetExists) {
edgesToAdd.add(edge.id);
// Add degrees for both new and existing nodes
if (nodesToAdd.has(edge.source)) {
nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1);
} else if (existingNodeIds.has(edge.source)) {
// Track degree increments for existing nodes
existingNodeDegreeIncrements.set(edge.source, (existingNodeDegreeIncrements.get(edge.source) || 0) + 1);
}
if (nodesToAdd.has(edge.target)) {
nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1);
} else if (existingNodeIds.has(edge.target)) {
// Track degree increments for existing nodes
existingNodeDegreeIncrements.set(edge.target, (existingNodeDegreeIncrements.get(edge.target) || 0) + 1);
}
} else {
// Track discarded edges for both new and existing nodes
if (sigmaGraph.hasNode(edge.source)) {
nodesWithDiscardedEdges.add(edge.source);
} else if (nodesToAdd.has(edge.source)) {
nodesWithDiscardedEdges.add(edge.source);
nodeDegrees.set(edge.source, (nodeDegrees.get(edge.source) || 0) + 1); // +1 for discarded edge
}
if (sigmaGraph.hasNode(edge.target)) {
nodesWithDiscardedEdges.add(edge.target);
} else if (nodesToAdd.has(edge.target)) {
nodesWithDiscardedEdges.add(edge.target);
nodeDegrees.set(edge.target, (nodeDegrees.get(edge.target) || 0) + 1); // +1 for discarded edge
}
}
}
// Helper function to update node sizes
const updateNodeSizes = (
sigmaGraph: UndirectedGraph,
nodesWithDiscardedEdges: Set<string>,
minDegree: number,
maxDegree: number
) => {
// Calculate derived values inside the function
const range = maxDegree - minDegree || 1; // Avoid division by zero
const scale = Constants.maxNodeSize - Constants.minNodeSize;
// Update node sizes
for (const nodeId of nodesWithDiscardedEdges) {
if (sigmaGraph.hasNode(nodeId)) {
let newDegree = sigmaGraph.degree(nodeId);
newDegree += 1; // Add +1 for discarded edges
// Limit newDegree to maxDegree + 1 to prevent nodes from being too large
const limitedDegree = Math.min(newDegree, maxDegree + 1);
const newSize = Math.round(
Constants.minNodeSize + scale * Math.pow((limitedDegree - minDegree) / range, 0.5)
);
sigmaGraph.setNodeAttribute(nodeId, 'size', newSize);
}
}
};
// Helper function to update edge sizes
const updateEdgeSizes = (
sigmaGraph: UndirectedGraph,
minWeight: number,
maxWeight: number
) => {
// Update edge sizes
const minEdgeSize = useSettingsStore.getState().minEdgeSize;
const maxEdgeSize = useSettingsStore.getState().maxEdgeSize;
const weightRange = maxWeight - minWeight || 1; // Avoid division by zero
const sizeScale = maxEdgeSize - minEdgeSize;
sigmaGraph.forEachEdge(edge => {
const weight = sigmaGraph.getEdgeAttribute(edge, 'originalWeight') || 1;
const scaledSize = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5);
sigmaGraph.setEdgeAttribute(edge, 'size', scaledSize);
});
};
// If no new connectable nodes found, show toast and return
if (nodesToAdd.size === 0) {
updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, maxDegree);
toast.info(t('graphPanel.propertiesView.node.noNewNodes'));
return;
}
// Update maxDegree considering all nodes (both new and existing)
// 1. Consider degrees of new nodes
for (const [, degree] of nodeDegrees.entries()) {
maxDegree = Math.max(maxDegree, degree);
}
// 2. Consider degree increments for existing nodes
for (const [nodeId, increment] of existingNodeDegreeIncrements.entries()) {
const currentDegree = sigmaGraph.degree(nodeId);
const projectedDegree = currentDegree + increment;
maxDegree = Math.max(maxDegree, projectedDegree);
}
const range = maxDegree - minDegree || 1; // Avoid division by zero
const scale = Constants.maxNodeSize - Constants.minNodeSize;
// SAdd nodes and edges to the graph
// Calculate camera ratio and spread factor once before the loop
const cameraRatio = useGraphStore.getState().sigmaInstance?.getCamera().ratio || 1;
const spreadFactor = Math.max(
Math.sqrt(nodeToExpand.size) * 4, // Base on node size
Math.sqrt(nodesToAdd.size) * 3 // Scale with number of nodes
) / cameraRatio; // Adjust for zoom level
seedrandom(Date.now().toString(), { global: true });
const randomAngle = Math.random() * 2 * Math.PI
console.log('nodeSize:', nodeToExpand.size, 'nodesToAdd:', nodesToAdd.size);
console.log('cameraRatio:', Math.round(cameraRatio*100)/100, 'spreadFactor:', Math.round(spreadFactor*100)/100);
// Add new nodes
for (const nodeId of nodesToAdd) {
const newNode = processedNodes.find(n => n.id === nodeId)!;
const nodeDegree = nodeDegrees.get(nodeId) || 0;
// Calculate node size
// Limit nodeDegree to maxDegree + 1 to prevent new nodes from being too large
const limitedDegree = Math.min(nodeDegree, maxDegree + 1);
const nodeSize = Math.round(
Constants.minNodeSize + scale * Math.pow((limitedDegree - minDegree) / range, 0.5)
);
// Calculate angle for polar coordinates
const angle = 2 * Math.PI * (Array.from(nodesToAdd).indexOf(nodeId) / nodesToAdd.size);
// Calculate final position
const x = nodePositions[nodeId]?.x ||
(nodePositions[nodeToExpand.id].x + Math.cos(randomAngle + angle) * spreadFactor);
const y = nodePositions[nodeId]?.y ||
(nodePositions[nodeToExpand.id].y + Math.sin(randomAngle + angle) * spreadFactor);
// Add the new node to the sigma graph with calculated position
sigmaGraph.addNode(nodeId, {
label: newNode.labels.join(', '),
color: newNode.color,
x: x,
y: y,
size: nodeSize,
borderColor: Constants.nodeBorderColor,
borderSize: 0.2
});
// Add the node to the raw graph
if (!rawGraph.getNode(nodeId)) {
// Update node properties
newNode.size = nodeSize;
newNode.x = x;
newNode.y = y;
newNode.degree = nodeDegree;
// Add to nodes array
rawGraph.nodes.push(newNode);
// Update nodeIdMap
rawGraph.nodeIdMap[nodeId] = rawGraph.nodes.length - 1;
}
}
// Add new edges
for (const edgeId of edgesToAdd) {
const newEdge = processedEdges.find(e => e.id === edgeId)!;
// Skip if edge already exists
if (sigmaGraph.hasEdge(newEdge.source, newEdge.target)) {
continue;
}
// Get weight from edge properties or default to 1
const weight = newEdge.properties?.weight !== undefined ? Number(newEdge.properties.weight) : 1;
// Update min and max weight values
minWeight = Math.min(minWeight, weight);
maxWeight = Math.max(maxWeight, weight);
// Add the edge to the sigma graph
newEdge.dynamicId = sigmaGraph.addEdge(newEdge.source, newEdge.target, {
label: newEdge.properties?.keywords || undefined,
size: weight, // Set initial size based on weight
originalWeight: weight, // Store original weight for recalculation
type: 'curvedNoArrow' // Explicitly set edge type to no arrow
});
// Add the edge to the raw graph
if (!rawGraph.getEdge(newEdge.id, false)) {
// Add to edges array
rawGraph.edges.push(newEdge);
// Update edgeIdMap
rawGraph.edgeIdMap[newEdge.id] = rawGraph.edges.length - 1;
// Update dynamic edge map
rawGraph.edgeDynamicIdMap[newEdge.dynamicId] = rawGraph.edges.length - 1;
} else {
console.error('Edge already exists in rawGraph:', newEdge.id);
}
}
// Update the dynamic edge map and invalidate search cache
rawGraph.buildDynamicMap();
// Reset search engine to force rebuild
useGraphStore.getState().resetSearchEngine();
// Update sizes for all nodes and edges
updateNodeSizes(sigmaGraph, nodesWithDiscardedEdges, minDegree, maxDegree);
updateEdgeSizes(sigmaGraph, minWeight, maxWeight);
// Final update for the expanded node
if (sigmaGraph.hasNode(nodeId)) {
const finalDegree = sigmaGraph.degree(nodeId);
const limitedDegree = Math.min(finalDegree, maxDegree + 1);
const newSize = Math.round(
Constants.minNodeSize + scale * Math.pow((limitedDegree - minDegree) / range, 0.5)
);
sigmaGraph.setNodeAttribute(nodeId, 'size', newSize);
nodeToExpand.size = newSize;
nodeToExpand.degree = finalDegree;
}
} catch (error) {
console.error('Error expanding node:', error);
}
};
// If there's a node to expand, handle it
if (nodeToExpand) {
handleNodeExpand(nodeToExpand);
// Reset the nodeToExpand state after handling
window.setTimeout(() => {
useGraphStore.getState().triggerNodeExpand(null);
}, 0);
}
}, [nodeToExpand, sigmaGraph, rawGraph, t]);
// Helper function to get all nodes that will be deleted
const getNodesThatWillBeDeleted = useCallback((nodeId: string, graph: UndirectedGraph) => {
const nodesToDelete = new Set<string>([nodeId]);
// Find all nodes that would become isolated after deletion
graph.forEachNode((node) => {
if (node === nodeId) return; // Skip the node being deleted
// Get all neighbors of this node
const neighbors = graph.neighbors(node);
// If this node has only one neighbor and that neighbor is the node being deleted,
// this node will become isolated, so we should delete it too
if (neighbors.length === 1 && neighbors[0] === nodeId) {
nodesToDelete.add(node);
}
});
return nodesToDelete;
}, []);
// Handle node pruning
useEffect(() => {
const handleNodePrune = (nodeId: string | null) => {
if (!nodeId || !sigmaGraph || !rawGraph) return;
try {
const state = useGraphStore.getState();
// 1. Check if node exists
if (!sigmaGraph.hasNode(nodeId)) {
console.error('Node not found:', nodeId);
return;
}
// 2. Get nodes to delete
const nodesToDelete = getNodesThatWillBeDeleted(nodeId, sigmaGraph);
// 3. Check if this would delete all nodes
if (nodesToDelete.size === sigmaGraph.nodes().length) {
toast.error(t('graphPanel.propertiesView.node.deleteAllNodesError'));
return;
}
// 4. Clear selection - this will cause PropertiesView to close immediately
state.clearSelection();
// 5. Delete nodes and related edges
for (const nodeToDelete of nodesToDelete) {
// Remove the node from the sigma graph (this will also remove connected edges)
sigmaGraph.dropNode(nodeToDelete);
// Remove the node from the raw graph
const nodeIndex = rawGraph.nodeIdMap[nodeToDelete];
if (nodeIndex !== undefined) {
// Find all edges connected to this node
const edgesToRemove = rawGraph.edges.filter(
edge => edge.source === nodeToDelete || edge.target === nodeToDelete
);
// Remove edges from raw graph
for (const edge of edgesToRemove) {
const edgeIndex = rawGraph.edgeIdMap[edge.id];
if (edgeIndex !== undefined) {
// Remove from edges array
rawGraph.edges.splice(edgeIndex, 1);
// Update edgeIdMap for all edges after this one
for (const [id, idx] of Object.entries(rawGraph.edgeIdMap)) {
if (idx > edgeIndex) {
rawGraph.edgeIdMap[id] = idx - 1;
}
}
// Remove from edgeIdMap
delete rawGraph.edgeIdMap[edge.id];
// Remove from edgeDynamicIdMap
delete rawGraph.edgeDynamicIdMap[edge.dynamicId];
}
}
// Remove node from nodes array
rawGraph.nodes.splice(nodeIndex, 1);
// Update nodeIdMap for all nodes after this one
for (const [id, idx] of Object.entries(rawGraph.nodeIdMap)) {
if (idx > nodeIndex) {
rawGraph.nodeIdMap[id] = idx - 1;
}
}
// Remove from nodeIdMap
delete rawGraph.nodeIdMap[nodeToDelete];
}
}
// Rebuild the dynamic edge map and invalidate search cache
rawGraph.buildDynamicMap();
// Reset search engine to force rebuild
useGraphStore.getState().resetSearchEngine();
// Show notification if we deleted more than just the selected node
if (nodesToDelete.size > 1) {
toast.info(t('graphPanel.propertiesView.node.nodesRemoved', { count: nodesToDelete.size }));
}
} catch (error) {
console.error('Error pruning node:', error);
}
};
// If there's a node to prune, handle it
if (nodeToPrune) {
handleNodePrune(nodeToPrune);
// Reset the nodeToPrune state after handling
window.setTimeout(() => {
useGraphStore.getState().triggerNodePrune(null);
}, 0);
}
}, [nodeToPrune, sigmaGraph, rawGraph, getNodesThatWillBeDeleted, t]);
const lightrageGraph = useCallback(() => {
// If we already have a graph instance, return it
if (sigmaGraph) {
return sigmaGraph as Graph<NodeType, EdgeType>
}
// If no graph exists yet, create a new one and store it
console.log('Creating new Sigma graph instance')
const graph = new UndirectedGraph()
useGraphStore.getState().setSigmaGraph(graph)
return graph as Graph<NodeType, EdgeType>
}, [sigmaGraph])
return { lightrageGraph, getNode, getEdge }
}
export default useLightrangeGraph
@@ -0,0 +1,75 @@
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)
useEffect(() => {
// Globally seed the Math.random
const params = new URLSearchParams(document.location.search)
const seed = params.get('seed') // is the string "Jonathan"
if (seed) {
seedrandom(seed, { global: true })
// seed faker with the random function
const f = new Faker({ locale: en })
f.seed(Math.random())
setFaker(f)
}
}, [])
const randomGraph = useCallback(() => {
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
@@ -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
+52
View File
@@ -0,0 +1,52 @@
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'
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 }
},
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
+214
View File
@@ -0,0 +1,214 @@
@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;
}
/* 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;
}
@@ -0,0 +1,89 @@
import { ButtonVariantType } from '@/components/ui/Button'
export const backendBaseUrl = ''
export const webuiPrefix = '/webui/'
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',
'.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
'.cpp', // # C++ source code
'.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'
}
@@ -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 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { StoreApi, UseBoundStore } from 'zustand'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function randomColor() {
const digits = '0123456789abcdef'
let code = '#'
for (let i = 0; i < 6; i++) {
code += digits.charAt(Math.floor(Math.random() * 16))
}
return code
}
export function errorMessage(error: any) {
return error instanceof Error ? error.message : `${error}`
}
/**
* Creates a throttled function that limits how often the original function can be called
* @param fn The function to throttle
* @param delay The delay in milliseconds
* @returns A throttled version of the function
*/
export function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
let lastCall = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
return function(this: any, ...args: Parameters<T>) {
const now = Date.now()
const remaining = delay - (now - lastCall)
if (remaining <= 0) {
// If enough time has passed, execute the function immediately
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
lastCall = now
fn.apply(this, args)
} else if (!timeoutId) {
// If not enough time has passed, set a timeout to execute after the remaining time
timeoutId = setTimeout(() => {
lastCall = Date.now()
timeoutId = null
fn.apply(this, args)
}, remaining)
}
}
}
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {
const store = _store as WithSelectors<typeof _store>
store.use = {}
for (const k of Object.keys(store.getState())) {
;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
}
return store
}
+465
View File
@@ -0,0 +1,465 @@
{
"settings": {
"language": "اللغة",
"theme": "السمة",
"light": "فاتح",
"dark": "داكن",
"system": "النظام"
},
"header": {
"documents": "المستندات",
"knowledgeGraph": "شبكة المعرفة",
"retrieval": "الاسترجاع",
"api": "واجهة برمجة التطبيقات",
"projectRepository": "مستودع المشروع",
"logout": "تسجيل الخروج",
"frontendNeedsRebuild": "الواجهة الأمامية تحتاج إلى إعادة البناء",
"themeToggle": {
"switchToLight": "التحويل إلى السمة الفاتحة",
"switchToDark": "التحويل إلى السمة الداكنة"
}
},
"login": {
"description": "الرجاء إدخال حسابك وكلمة المرور لتسجيل الدخول إلى النظام",
"username": "اسم المستخدم",
"usernamePlaceholder": "الرجاء إدخال اسم المستخدم",
"password": "كلمة المرور",
"passwordPlaceholder": "الرجاء إدخال كلمة المرور",
"loginButton": "تسجيل الدخول",
"loggingIn": "جاري تسجيل الدخول...",
"successMessage": "تم تسجيل الدخول بنجاح",
"errorEmptyFields": "الرجاء إدخال اسم المستخدم وكلمة المرور",
"errorInvalidCredentials": "فشل تسجيل الدخول، يرجى التحقق من اسم المستخدم وكلمة المرور",
"authDisabled": "تم تعطيل المصادقة. استخدام وضع بدون تسجيل دخول.",
"guestMode": "وضع بدون تسجيل دخول"
},
"common": {
"cancel": "إلغاء",
"save": "حفظ",
"saving": "جارٍ الحفظ...",
"saveFailed": "فشل الحفظ"
},
"documentPanel": {
"clearDocuments": {
"button": "مسح",
"tooltip": "مسح المستندات",
"title": "مسح المستندات",
"description": "سيؤدي هذا إلى إزالة جميع المستندات من النظام",
"warning": "تحذير: سيؤدي هذا الإجراء إلى حذف جميع المستندات بشكل دائم ولا يمكن التراجع عنه!",
"confirm": "هل تريد حقًا مسح جميع المستندات؟",
"confirmPrompt": "اكتب 'yes' لتأكيد هذا الإجراء",
"confirmPlaceholder": "اكتب yes للتأكيد",
"clearCache": "مسح كاش نموذج اللغة",
"confirmButton": "نعم",
"clearing": "جارٍ المسح...",
"timeout": "انتهت مهلة عملية المسح، يرجى المحاولة مرة أخرى",
"success": "تم مسح المستندات بنجاح",
"cacheCleared": "تم مسح ذاكرة التخزين المؤقت بنجاح",
"cacheClearFailed": "فشل مسح ذاكرة التخزين المؤقت:\n{{error}}",
"failed": "فشل مسح المستندات:\n{{message}}",
"error": "فشل مسح المستندات:\n{{error}}"
},
"deleteDocuments": {
"button": "حذف",
"tooltip": "حذف المستندات المحددة",
"title": "حذف المستندات",
"description": "سيؤدي هذا إلى حذف المستندات المحددة نهائيًا من النظام",
"warning": "تحذير: سيؤدي هذا الإجراء إلى حذف المستندات المحددة نهائيًا ولا يمكن التراجع عنه!",
"confirm": "هل تريد حقًا حذف {{count}} مستند(ات) محدد(ة)؟",
"confirmPrompt": "اكتب 'yes' لتأكيد هذا الإجراء",
"confirmPlaceholder": "اكتب yes للتأكيد",
"confirmButton": "نعم",
"deleteFileOption": "حذف الملفات المرفوعة أيضًا",
"deleteFileTooltip": "حدد هذا الخيار لحذف الملفات المرفوعة المقابلة على الخادم أيضًا",
"deleteLLMCacheOption": "حذف ذاكرة LLM المؤقتة للاستخراج أيضًا",
"success": "تم بدء تشغيل خط معالجة حذف المستندات بنجاح",
"failed": "فشل حذف المستندات:\n{{message}}",
"error": "فشل حذف المستندات:\n{{error}}",
"busy": "خط المعالجة مشغول، يرجى المحاولة مرة أخرى لاحقًا",
"notAllowed": "لا توجد صلاحية لتنفيذ هذه العملية"
},
"selectDocuments": {
"selectCurrentPage": "تحديد الصفحة الحالية ({{count}})",
"deselectAll": "إلغاء تحديد الكل ({{count}})"
},
"uploadDocuments": {
"button": "رفع",
"tooltip": "رفع المستندات",
"title": "رفع المستندات",
"description": "اسحب وأفلت مستنداتك هنا أو انقر للتصفح.",
"single": {
"uploading": "جارٍ الرفع {{name}}: {{percent}}%",
"success": "نجاح الرفع:\nتم رفع {{name}} بنجاح",
"failed": "فشل الرفع:\n{{name}}\n{{message}}",
"error": "فشل الرفع:\n{{name}}\n{{error}}"
},
"batch": {
"uploading": "جارٍ رفع الملفات...",
"success": "تم رفع الملفات بنجاح",
"error": "فشل رفع بعض الملفات"
},
"generalError": "فشل الرفع\n{{error}}",
"fileTypes": "الأنواع المدعومة: TXT، MD، DOCX، PDF، PPTX، XLSX، RTF، ODT، EPUB، HTML، HTM، TEX، JSON، XML، YAML، YML، CSV، LOG، CONF، INI، PROPERTIES، SQL، BAT، SH، C، CPP، PY، JAVA، JS، TS، SWIFT، GO، RB، PHP، CSS، SCSS، LESS",
"fileUploader": {
"singleFileLimit": "لا يمكن رفع أكثر من ملف واحد في المرة الواحدة",
"maxFilesLimit": "لا يمكن رفع أكثر من {{count}} ملفات",
"fileRejected": "تم رفض الملف {{name}}",
"unsupportedType": "نوع الملف غير مدعوم",
"fileTooLarge": "حجم الملف كبير جدًا، الحد الأقصى {{maxSize}}",
"dropHere": "أفلت الملفات هنا",
"dragAndDrop": "اسحب وأفلت الملفات هنا، أو انقر للاختيار",
"removeFile": "إزالة الملف",
"uploadDescription": "يمكنك رفع {{isMultiple ? 'عدة' : count}} ملفات (حتى {{maxSize}} لكل منها)",
"duplicateFile": "اسم الملف موجود بالفعل في ذاكرة التخزين المؤقت للخادم"
}
},
"documentManager": {
"title": "إدارة المستندات",
"scanButton": "مسح/إعادة محاولة",
"scanTooltip": "مسح ومعالجة المستندات في مجلد الإدخال، وإعادة معالجة جميع المستندات الفاشلة أيضًا",
"refreshTooltip": "إعادة تعيين قائمة المستندات",
"pipelineStatusButton": "خط المعالجة",
"pipelineStatusTooltip": "عرض حالة خط معالجة المستندات",
"uploadedTitle": "المستندات المرفوعة",
"uploadedDescription": "قائمة المستندات المرفوعة وحالاتها.",
"emptyTitle": "لا توجد مستندات",
"emptyDescription": "لا توجد مستندات مرفوعة بعد.",
"columns": {
"id": "المعرف",
"fileName": "اسم الملف",
"summary": "الملخص",
"status": "الحالة",
"length": "الطول",
"chunks": "الأجزاء",
"created": "تم الإنشاء",
"updated": "تم التحديث",
"metadata": "البيانات الوصفية",
"select": "اختيار"
},
"status": {
"all": "الكل",
"completed": "مكتمل",
"preprocessed": "مُعالج مسبقًا",
"processing": "قيد المعالجة",
"pending": "معلق",
"failed": "فشل"
},
"errors": {
"loadFailed": "فشل تحميل المستندات\n{{error}}",
"scanFailed": "فشل مسح المستندات\n{{error}}",
"scanProgressFailed": "فشل الحصول على تقدم المسح\n{{error}}"
},
"fileNameLabel": "اسم الملف",
"showButton": "عرض",
"hideButton": "إخفاء",
"showFileNameTooltip": "عرض اسم الملف",
"hideFileNameTooltip": "إخفاء اسم الملف"
},
"pipelineStatus": {
"title": "حالة خط الأنابيب",
"busy": "خط الأنابيب مشغول",
"requestPending": "طلب معلق",
"cancellationRequested": "طلب الإلغاء",
"jobName": "اسم المهمة",
"startTime": "وقت البدء",
"progress": "التقدم",
"unit": "دفعة",
"pipelineMessages": "رسائل خط الأنابيب",
"cancelButton": "إلغاء",
"cancelTooltip": "إلغاء معالجة خط الأنابيب",
"cancelConfirmTitle": "تأكيد إلغاء خط الأنابيب",
"cancelConfirmDescription": "سيؤدي هذا الإجراء إلى إيقاف معالجة خط الأنابيب الجارية. هل أنت متأكد من أنك تريد المتابعة؟",
"cancelConfirmButton": "تأكيد الإلغاء",
"cancelInProgress": "الإلغاء قيد التقدم...",
"pipelineNotRunning": "خط الأنابيب غير قيد التشغيل",
"cancelSuccess": "تم طلب إلغاء خط الأنابيب",
"cancelFailed": "فشل إلغاء خط الأنابيب\n{{error}}",
"cancelNotBusy": "خط الأنابيب غير قيد التشغيل، لا حاجة للإلغاء",
"errors": {
"fetchFailed": "فشل في جلب حالة خط الأنابيب\n{{error}}"
}
}
},
"graphPanel": {
"dataIsTruncated": "تم اقتصار بيانات الرسم البياني على الحد الأقصى للعقد",
"statusDialog": {
"title": "إعدادات خادم LightRAG",
"description": "عرض حالة النظام الحالية ومعلومات الاتصال"
},
"legend": "المفتاح",
"nodeTypes": {
"person": "شخص",
"category": "فئة",
"geo": "كيان جغرافي",
"location": "موقع",
"organization": "منظمة",
"event": "حدث",
"equipment": "معدات",
"weapon": "سلاح",
"animal": "حيوان",
"unknown": "غير معروف",
"object": "مصنوع",
"group": "مجموعة",
"technology": "العلوم",
"product": "منتج",
"document": "وثيقة",
"content": "محتوى",
"data": "بيانات",
"artifact": "قطعة أثرية",
"concept": "مفهوم",
"naturalobject": "كائن طبيعي",
"method": "عملية",
"creature": "مخلوق",
"plant": "نبات",
"disease": "مرض",
"drug": "دواء",
"food": "طعام",
"other": "أخرى"
},
"sideBar": {
"settings": {
"settings": "الإعدادات",
"healthCheck": "فحص الحالة",
"showPropertyPanel": "إظهار لوحة الخصائص",
"showSearchBar": "إظهار شريط البحث",
"showNodeLabel": "إظهار تسمية العقدة",
"nodeDraggable": "العقدة قابلة للسحب",
"showEdgeLabel": "إظهار تسمية الحافة",
"hideUnselectedEdges": "إخفاء الحواف غير المحددة",
"edgeEvents": "أحداث الحافة",
"maxQueryDepth": "أقصى عمق للاستعلام",
"maxNodes": "الحد الأقصى للعقد",
"maxLayoutIterations": "أقصى تكرارات التخطيط",
"resetToDefault": "إعادة التعيين إلى الافتراضي",
"edgeSizeRange": "نطاق حجم الحافة",
"depth": "D",
"max": "Max",
"degree": "الدرجة",
"apiKey": "مفتاح واجهة برمجة التطبيقات",
"enterYourAPIkey": "أدخل مفتاح واجهة برمجة التطبيقات الخاص بك",
"save": "حفظ",
"refreshLayout": "تحديث التخطيط"
},
"zoomControl": {
"zoomIn": "تكبير",
"zoomOut": "تصغير",
"resetZoom": "إعادة تعيين التكبير",
"rotateCamera": "تدوير في اتجاه عقارب الساعة",
"rotateCameraCounterClockwise": "تدوير عكس اتجاه عقارب الساعة"
},
"layoutsControl": {
"startAnimation": "بدء حركة التخطيط",
"stopAnimation": "إيقاف حركة التخطيط",
"layoutGraph": "تخطيط الرسم البياني",
"layouts": {
"Circular": "دائري",
"Circlepack": "حزمة دائرية",
"Random": "عشوائي",
"Noverlaps": "بدون تداخل",
"Force Directed": "موجه بالقوة",
"Force Atlas": "أطلس القوة"
}
},
"fullScreenControl": {
"fullScreen": "شاشة كاملة",
"windowed": "نوافذ"
},
"legendControl": {
"toggleLegend": "تبديل المفتاح"
}
},
"statusIndicator": {
"connected": "متصل",
"disconnected": "غير متصل"
},
"statusCard": {
"unavailable": "معلومات الحالة غير متوفرة",
"serverInfo": "معلومات الخادم",
"workingDirectory": "دليل العمل",
"inputDirectory": "دليل الإدخال",
"maxParallelInsert": "معالجة المستندات المتزامنة",
"summarySettings": "إعدادات الملخص",
"llmConfig": "تكوين نموذج اللغة الكبير",
"llmBinding": "ربط نموذج اللغة الكبير",
"llmBindingHost": "نقطة نهاية نموذج اللغة الكبير",
"llmModel": "نموذج اللغة الكبير",
"embeddingConfig": "تكوين التضمين",
"embeddingBinding": "ربط التضمين",
"embeddingBindingHost": "نقطة نهاية التضمين",
"embeddingModel": "نموذج التضمين",
"storageConfig": "تكوين التخزين",
"kvStorage": "تخزين المفتاح-القيمة",
"docStatusStorage": "تخزين حالة المستند",
"graphStorage": "تخزين الرسم البياني",
"vectorStorage": "تخزين المتجهات",
"workspace": "مساحة العمل",
"maxGraphNodes": "الحد الأقصى لعقد الرسم البياني",
"rerankerConfig": "تكوين إعادة الترتيب",
"rerankerBindingHost": "نقطة نهاية إعادة الترتيب",
"rerankerModel": "نموذج إعادة الترتيب",
"lockStatus": "حالة القفل",
"threshold": "العتبة"
},
"propertiesView": {
"editProperty": "تعديل {{property}}",
"editPropertyDescription": "قم بتحرير قيمة الخاصية في منطقة النص أدناه.",
"errors": {
"duplicateName": "اسم العقدة موجود بالفعل",
"updateFailed": "فشل تحديث العقدة",
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا",
"updateSuccessButMergeFailed": "تم تحديث الخصائص، لكن الدمج فشل: {{error}}",
"mergeFailed": "فشل الدمج: {{error}}"
},
"success": {
"entityUpdated": "تم تحديث العقدة بنجاح",
"relationUpdated": "تم تحديث العلاقة بنجاح",
"entityMerged": "تم دمج العقد بنجاح"
},
"mergeOptionLabel": "دمج تلقائي عند العثور على اسم مكرر",
"mergeOptionDescription": "عند التفعيل، سيتم دمج هذه العقدة تلقائيًا في العقدة الموجودة بدلاً من ظهور خطأ عند إعادة التسمية بنفس الاسم.",
"mergeDialog": {
"title": "تم دمج العقدة",
"description": "\"{{source}}\" تم دمجها في \"{{target}}\".",
"refreshHint": "يجب تحديث الرسم البياني لتحميل البنية الأحدث.",
"keepCurrentStart": "تحديث مع الحفاظ على عقدة البدء الحالية",
"useMergedStart": "تحديث واستخدام العقدة المدمجة كنقطة بدء",
"refreshing": "جارٍ تحديث الرسم البياني..."
},
"node": {
"title": "عقدة",
"id": "المعرف",
"labels": "التسميات",
"degree": "الدرجة",
"properties": "الخصائص",
"relationships": "العلاقات (داخل الرسم الفرعي)",
"expandNode": "توسيع العقدة",
"pruneNode": "تقليم العقدة",
"deleteAllNodesError": "رفض حذف جميع العقد في الرسم البياني",
"nodesRemoved": "تم إزالة {{count}} عقدة، بما في ذلك العقد اليتيمة",
"noNewNodes": "لم يتم العثور على عقد قابلة للتوسيع",
"propertyNames": {
"description": "الوصف",
"entity_id": "الاسم",
"entity_type": "النوع",
"source_id": "C-ID",
"Neighbour": "الجار",
"file_path": "File",
"keywords": "Keyword",
"weight": "الوزن"
}
},
"edge": {
"title": "علاقة",
"id": "المعرف",
"type": "النوع",
"source": "المصدر",
"target": "الهدف",
"properties": "الخصائص"
}
},
"search": {
"placeholder": "ابحث في العقد في الصفحة...",
"message": "و {{count}} آخرون"
},
"graphLabels": {
"selectTooltip": "الحصول على الرسم البياني الفرعي لعقدة (تسمية)",
"noLabels": "لم يتم العثور على عقد مطابقة",
"label": "البحث عن اسم العقدة",
"placeholder": "البحث عن اسم العقدة...",
"andOthers": "و {{count}} آخرون",
"refreshGlobalTooltip": "تحديث بيانات الرسم البياني العالمي وإعادة تعيين سجل البحث",
"refreshCurrentLabelTooltip": "تحديث بيانات الرسم البياني للصفحة الحالية",
"refreshingTooltip": "جارٍ تحديث البيانات..."
},
"emptyGraph": "فارغ (حاول إعادة التحميل)"
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "نسخ إلى الحافظة",
"copyError": "فشل نسخ النص إلى الحافظة",
"copyEmpty": "لا يوجد محتوى للنسخ",
"copySuccess": "تم نسخ المحتوى إلى الحافظة",
"copySuccessLegacy": "تم نسخ المحتوى (الطريقة التقليدية)",
"copySuccessManual": "تم نسخ المحتوى (الطريقة اليدوية)",
"copyFailed": "فشل نسخ المحتوى",
"copyManualInstruction": "يرجى تحديد ونسخ النص يدوياً",
"thinking": "جاري التفكير...",
"thinkingTime": "وقت التفكير {{time}} ثانية",
"thinkingInProgress": "التفكير قيد التقدم..."
},
"retrieval": {
"startPrompt": "ابدأ الاسترجاع بكتابة استفسارك أدناه",
"clear": "مسح",
"send": "إرسال",
"placeholder": "اكتب استفسارك (بادئة وضع الاستعلام: /<Query Mode>)",
"error": "خطأ: فشل الحصول على الرد",
"queryModeError": "يُسمح فقط بأنماط الاستعلام التالية: {{modes}}",
"queryModePrefixInvalid": "بادئة وضع الاستعلام غير صالحة. استخدم: /<الوضع> [مسافة] استفسارك"
},
"querySettings": {
"parametersTitle": "المعلمات",
"parametersDescription": "تكوين معلمات الاستعلام الخاص بك",
"queryMode": "وضع الاستعلام",
"queryModeTooltip": "حدد استراتيجية الاسترجاع:\n• ساذج: استرجاع متجهي تقليدي لقطع النص\n• محلي: يركز على استرجاع الكيانات\n• عالمي: يركز على استرجاع العلاقات\n• مختلط: محلي+عالمي\n• مزيج: محلي+عالمي+ساذج\n• تجاوز: تخطي الاسترجاع، إرسال تاريخ المحادثة والسؤال الحالي إلى LLM",
"queryModeOptions": {
"naive": "ساذج",
"local": "محلي",
"global": "عالمي",
"hybrid": "مختلط",
"mix": "مزيج",
"bypass": "تجاوز"
},
"responseFormat": "تنسيق الرد",
"responseFormatTooltip": "يحدد تنسيق الرد. أمثلة:\n• فقرات متعددة\n• فقرة واحدة\n• نقاط نقطية",
"responseFormatOptions": {
"multipleParagraphs": "فقرات متعددة",
"singleParagraph": "فقرة واحدة",
"bulletPoints": "نقاط نقطية"
},
"topK": "KG أعلى K",
"topKTooltip": "عدد الكيانات والعلاقات المطلوب استردادها، لا ينطبق على الوضع наивный.",
"topKPlaceholder": "أدخل قيمة top_k",
"chunkTopK": "أعلى K للقطع",
"chunkTopKTooltip": "عدد أجزاء النص المطلوب استردادها، وينطبق على جميع الأوضاع.",
"chunkTopKPlaceholder": "أدخل قيمة chunk_top_k",
"maxEntityTokens": "الحد الأقصى لرموز الكيان",
"maxEntityTokensTooltip": "الحد الأقصى لعدد الرموز المخصصة لسياق الكيان في نظام التحكم الموحد في الرموز",
"maxRelationTokens": "الحد الأقصى لرموز العلاقة",
"maxRelationTokensTooltip": "الحد الأقصى لعدد الرموز المخصصة لسياق العلاقة في نظام التحكم الموحد في الرموز",
"maxTotalTokens": "إجمالي الحد الأقصى للرموز",
"maxTotalTokensTooltip": "الحد الأقصى الإجمالي لميزانية الرموز لسياق الاستعلام بالكامل (الكيانات + العلاقات + الأجزاء + موجه النظام)",
"historyTurns": "أدوار التاريخ",
"historyTurnsTooltip": "عدد الدورات الكاملة للمحادثة (أزواج المستخدم-المساعد) التي يجب مراعاتها في سياق الرد",
"historyTurnsPlaceholder": "عدد دورات التاريخ",
"onlyNeedContext": "تحتاج فقط إلى السياق",
"onlyNeedContextTooltip": "إذا كان صحيحًا، يتم إرجاع السياق المسترجع فقط دون إنشاء رد",
"onlyNeedPrompt": "تحتاج فقط إلى المطالبة",
"onlyNeedPromptTooltip": "إذا كان صحيحًا، يتم إرجاع المطالبة المولدة فقط دون إنتاج رد",
"streamResponse": "تدفق الرد",
"streamResponseTooltip": "إذا كان صحيحًا، يتيح إخراج التدفق للردود في الوقت الفعلي",
"userPrompt": "مطالبة إخراج إضافية",
"userPromptTooltip": "تقديم متطلبات استجابة إضافية إلى نموذج اللغة الكبير (غير متعلقة بمحتوى الاستعلام، فقط لمعالجة المخرجات).",
"userPromptPlaceholder": "أدخل مطالبة مخصصة (اختياري)",
"enableRerank": "تمكين إعادة الترتيب",
"enableRerankTooltip": "تمكين إعادة ترتيب أجزاء النص المسترجعة. إذا كان True ولكن لم يتم تكوين نموذج إعادة الترتيب، فسيتم إصدار تحذير. افتراضي True."
}
},
"apiSite": {
"loading": "جارٍ تحميل وثائق واجهة برمجة التطبيقات..."
},
"apiKeyAlert": {
"title": "مفتاح واجهة برمجة التطبيقات مطلوب",
"description": "الرجاء إدخال مفتاح واجهة برمجة التطبيقات للوصول إلى الخدمة",
"placeholder": "أدخل مفتاح واجهة برمجة التطبيقات",
"save": "حفظ"
},
"pagination": {
"showing": "عرض {{start}} إلى {{end}} من أصل {{total}} إدخالات",
"page": "الصفحة",
"pageSize": "حجم الصفحة",
"firstPage": "الصفحة الأولى",
"prevPage": "الصفحة السابقة",
"nextPage": "الصفحة التالية",
"lastPage": "الصفحة الأخيرة"
}
}
+465
View File
@@ -0,0 +1,465 @@
{
"settings": {
"language": "Language",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"header": {
"documents": "Documents",
"knowledgeGraph": "Knowledge Graph",
"retrieval": "Retrieval",
"api": "API",
"projectRepository": "Project Repository",
"logout": "Logout",
"frontendNeedsRebuild": "Frontend needs rebuild",
"themeToggle": {
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme"
}
},
"login": {
"description": "Please enter your account and password to log in to the system",
"username": "Username",
"usernamePlaceholder": "Please input a username",
"password": "Password",
"passwordPlaceholder": "Please input a password",
"loginButton": "Login",
"loggingIn": "Logging in...",
"successMessage": "Login succeeded",
"errorEmptyFields": "Please enter your username and password",
"errorInvalidCredentials": "Login failed, please check username and password",
"authDisabled": "Authentication is disabled. Using login free mode.",
"guestMode": "Login Free"
},
"common": {
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"saveFailed": "Save failed"
},
"documentPanel": {
"clearDocuments": {
"button": "Clear",
"tooltip": "Clear documents",
"title": "Clear Documents",
"description": "This will remove all documents from the system",
"warning": "WARNING: This action will permanently delete all documents and cannot be undone!",
"confirm": "Do you really want to clear all documents?",
"confirmPrompt": "Type 'yes' to confirm this action",
"confirmPlaceholder": "Type yes to confirm",
"clearCache": "Clear LLM cache",
"confirmButton": "YES",
"clearing": "Clearing...",
"timeout": "Clear operation timed out, please try again",
"success": "Documents cleared successfully",
"cacheCleared": "Cache cleared successfully",
"cacheClearFailed": "Failed to clear cache:\n{{error}}",
"failed": "Clear Documents Failed:\n{{message}}",
"error": "Clear Documents Failed:\n{{error}}"
},
"deleteDocuments": {
"button": "Delete",
"tooltip": "Delete selected documents",
"title": "Delete Documents",
"description": "This will permanently delete the selected documents from the system",
"warning": "WARNING: This action will permanently delete the selected documents and cannot be undone!",
"confirm": "Do you really want to delete {{count}} selected document(s)?",
"confirmPrompt": "Type 'yes' to confirm this action",
"confirmPlaceholder": "Type yes to confirm",
"confirmButton": "YES",
"deleteFileOption": "Also delete uploaded files",
"deleteFileTooltip": "Check this option to also delete the corresponding uploaded files on the server",
"deleteLLMCacheOption": "Also delete extracted LLM cache",
"success": "Document deletion pipeline started successfully",
"failed": "Delete Documents Failed:\n{{message}}",
"error": "Delete Documents Failed:\n{{error}}",
"busy": "Pipeline is busy, please try again later",
"notAllowed": "No permission to perform this operation"
},
"selectDocuments": {
"selectCurrentPage": "Select Current Page ({{count}})",
"deselectAll": "Deselect All ({{count}})"
},
"uploadDocuments": {
"button": "Upload",
"tooltip": "Upload documents",
"title": "Upload Documents",
"description": "Drag and drop your documents here or click to browse.",
"single": {
"uploading": "Uploading {{name}}: {{percent}}%",
"success": "Upload Success:\n{{name}} uploaded successfully",
"failed": "Upload Failed:\n{{name}}\n{{message}}",
"error": "Upload Failed:\n{{name}}\n{{error}}"
},
"batch": {
"uploading": "Uploading files...",
"success": "Files uploaded successfully",
"error": "Some files failed to upload"
},
"generalError": "Upload Failed\n{{error}}",
"fileTypes": "Supported types: TXT, MD, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
"fileUploader": {
"singleFileLimit": "Cannot upload more than 1 file at a time",
"maxFilesLimit": "Cannot upload more than {{count}} files",
"fileRejected": "File {{name}} was rejected",
"unsupportedType": "Unsupported file type",
"fileTooLarge": "File too large, maximum size is {{maxSize}}",
"dropHere": "Drop the files here",
"dragAndDrop": "Drag and drop files here, or click to select files",
"removeFile": "Remove file",
"uploadDescription": "You can upload {{isMultiple ? 'multiple' : count}} files (up to {{maxSize}} each)",
"duplicateFile": "File name already exists in server cache"
}
},
"documentManager": {
"title": "Document Management",
"scanButton": "Scan/Retry",
"scanTooltip": "Scan and process documents in input folder, and also reprocess all failed documents",
"refreshTooltip": "Reset document list",
"pipelineStatusButton": "Pipeline",
"pipelineStatusTooltip": "View document processing pipeline status",
"uploadedTitle": "Uploaded Documents",
"uploadedDescription": "List of uploaded documents and their statuses.",
"emptyTitle": "No Documents",
"emptyDescription": "There are no uploaded documents yet.",
"columns": {
"id": "ID",
"fileName": "File Name",
"summary": "Summary",
"status": "Status",
"length": "Length",
"chunks": "Chunks",
"created": "Created",
"updated": "Updated",
"metadata": "Metadata",
"select": "Select"
},
"status": {
"all": "All",
"completed": "Completed",
"preprocessed": "Preprocessed",
"processing": "Processing",
"pending": "Pending",
"failed": "Failed"
},
"errors": {
"loadFailed": "Failed to load documents\n{{error}}",
"scanFailed": "Failed to scan documents\n{{error}}",
"scanProgressFailed": "Failed to get scan progress\n{{error}}"
},
"fileNameLabel": "File Name",
"showButton": "Show",
"hideButton": "Hide",
"showFileNameTooltip": "Show file name",
"hideFileNameTooltip": "Hide file name"
},
"pipelineStatus": {
"title": "Pipeline Status",
"busy": "Pipeline Busy",
"requestPending": "Request Pending",
"cancellationRequested": "Cancellation Requested",
"jobName": "Job Name",
"startTime": "Start Time",
"progress": "Progress",
"unit": "Batch",
"pipelineMessages": "Pipeline Messages",
"cancelButton": "Cancel",
"cancelTooltip": "Cancel pipeline processing",
"cancelConfirmTitle": "Confirm Pipeline Cancellation",
"cancelConfirmDescription": "This will interrupt the ongoing pipeline processing. Are you sure you want to continue?",
"cancelConfirmButton": "Confirm Cancellation",
"cancelInProgress": "Cancellation in progress...",
"pipelineNotRunning": "Pipeline not running",
"cancelSuccess": "Pipeline cancellation requested",
"cancelFailed": "Failed to cancel pipeline\n{{error}}",
"cancelNotBusy": "Pipeline is not running, no need to cancel",
"errors": {
"fetchFailed": "Failed to fetch pipeline status\n{{error}}"
}
}
},
"graphPanel": {
"dataIsTruncated": "Graph data is truncated to Max Nodes",
"statusDialog": {
"title": "LightRAG Server Settings",
"description": "View current system status and connection information"
},
"legend": "Legend",
"nodeTypes": {
"person": "Person",
"category": "Category",
"geo": "Geographic",
"location": "Location",
"organization": "Organization",
"event": "Event",
"equipment": "Equipment",
"weapon": "Weapon",
"animal": "Animal",
"unknown": "Unknown",
"object": "Object",
"group": "Group",
"technology": "Technology",
"product": "Product",
"document": "Document",
"content": "Content",
"data": "Data",
"artifact": "Artifact",
"concept": "Concept",
"naturalobject": "Natural Object",
"method": "Method",
"creature": "Creature",
"plant": "Plant",
"disease": "Disease",
"drug": "Drug",
"food": "Food",
"other": "Other"
},
"sideBar": {
"settings": {
"settings": "Settings",
"healthCheck": "Health Check",
"showPropertyPanel": "Show Property Panel",
"showSearchBar": "Show Search Bar",
"showNodeLabel": "Show Node Label",
"nodeDraggable": "Node Draggable",
"showEdgeLabel": "Show Edge Label",
"hideUnselectedEdges": "Hide Unselected Edges",
"edgeEvents": "Edge Events",
"maxQueryDepth": "Max Query Depth",
"maxNodes": "Max Nodes",
"maxLayoutIterations": "Max Layout Iterations",
"resetToDefault": "Reset to default",
"edgeSizeRange": "Edge Size Range",
"depth": "D",
"max": "Max",
"degree": "Degree",
"apiKey": "API Key",
"enterYourAPIkey": "Enter your API key",
"save": "Save",
"refreshLayout": "Refresh Layout"
},
"zoomControl": {
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"resetZoom": "Reset Zoom",
"rotateCamera": "Clockwise Rotate",
"rotateCameraCounterClockwise": "Counter-Clockwise Rotate"
},
"layoutsControl": {
"startAnimation": "Continue layout animation",
"stopAnimation": "Stop layout animation",
"layoutGraph": "Layout Graph",
"layouts": {
"Circular": "Circular",
"Circlepack": "Circlepack",
"Random": "Random",
"Noverlaps": "Noverlaps",
"Force Directed": "Force Directed",
"Force Atlas": "Force Atlas"
}
},
"fullScreenControl": {
"fullScreen": "Full Screen",
"windowed": "Windowed"
},
"legendControl": {
"toggleLegend": "Toggle Legend"
}
},
"statusIndicator": {
"connected": "Connected",
"disconnected": "Disconnected"
},
"statusCard": {
"unavailable": "Status information unavailable",
"serverInfo": "Server Info",
"workingDirectory": "Working Directory",
"inputDirectory": "Input Directory",
"maxParallelInsert": "Concurrent Doc Processing",
"summarySettings": "Summary Settings",
"llmConfig": "LLM Configuration",
"llmBinding": "LLM Binding",
"llmBindingHost": "LLM Endpoint",
"llmModel": "LLM Model",
"embeddingConfig": "Embedding Configuration",
"embeddingBinding": "Embedding Binding",
"embeddingBindingHost": "Embedding Endpoint",
"embeddingModel": "Embedding Model",
"storageConfig": "Storage Configuration",
"kvStorage": "KV Storage",
"docStatusStorage": "Doc Status Storage",
"graphStorage": "Graph Storage",
"vectorStorage": "Vector Storage",
"workspace": "Workspace",
"maxGraphNodes": "Max Graph Nodes",
"rerankerConfig": "Reranker Configuration",
"rerankerBindingHost": "Reranker Endpoint",
"rerankerModel": "Reranker Model",
"lockStatus": "Lock Status",
"threshold": "Threshold"
},
"propertiesView": {
"editProperty": "Edit {{property}}",
"editPropertyDescription": "Edit the property value in the text area below.",
"errors": {
"duplicateName": "Node name already exists",
"updateFailed": "Failed to update node",
"tryAgainLater": "Please try again later",
"updateSuccessButMergeFailed": "Properties updated, but merge failed: {{error}}",
"mergeFailed": "Merge failed: {{error}}"
},
"success": {
"entityUpdated": "Node updated successfully",
"relationUpdated": "Relation updated successfully",
"entityMerged": "Nodes merged successfully"
},
"mergeOptionLabel": "Automatically merge when a duplicate name is found",
"mergeOptionDescription": "If enabled, renaming to an existing name will merge this node into the existing one instead of failing.",
"mergeDialog": {
"title": "Node merged",
"description": "\"{{source}}\" has been merged into \"{{target}}\".",
"refreshHint": "Refresh the graph to load the latest structure.",
"keepCurrentStart": "Refresh and keep current start node",
"useMergedStart": "Refresh and use merged node",
"refreshing": "Refreshing graph..."
},
"node": {
"title": "Node",
"id": "ID",
"labels": "Labels",
"degree": "Degree",
"properties": "Properties",
"relationships": "Relations(within subgraph)",
"expandNode": "Expand Node",
"pruneNode": "Prune Node",
"deleteAllNodesError": "Refuse to delete all nodes in the graph",
"nodesRemoved": "{{count}} nodes removed, including orphan nodes",
"noNewNodes": "No expandable nodes found",
"propertyNames": {
"description": "Description",
"entity_id": "Name",
"entity_type": "Type",
"source_id": "C-ID",
"Neighbour": "Neigh",
"file_path": "File",
"keywords": "Keys",
"weight": "Weight"
}
},
"edge": {
"title": "Relationship",
"id": "ID",
"type": "Type",
"source": "Source",
"target": "Target",
"properties": "Properties"
}
},
"search": {
"placeholder": "Search nodes in page...",
"message": "And {{count}} others"
},
"graphLabels": {
"selectTooltip": "Get subgraph of a node (label)",
"noLabels": "No matching nodes found",
"label": "Search node name",
"placeholder": "Search node name...",
"andOthers": "And {{count}} others",
"refreshGlobalTooltip": "Refresh global graph data and reset search history",
"refreshCurrentLabelTooltip": "Refresh current page graph data",
"refreshingTooltip": "Refreshing data..."
},
"emptyGraph": "Empty(Try Reload Again)"
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "Copy to clipboard",
"copyError": "Failed to copy text to clipboard",
"copyEmpty": "No content to copy",
"copySuccess": "Content copied to clipboard",
"copySuccessLegacy": "Content copied (legacy method)",
"copySuccessManual": "Content copied (manual method)",
"copyFailed": "Failed to copy content",
"copyManualInstruction": "Please select and copy the text manually",
"thinking": "Thinking...",
"thinkingTime": "Thinking time {{time}}s",
"thinkingInProgress": "Thinking in progress..."
},
"retrieval": {
"startPrompt": "Start a retrieval by typing your query below",
"clear": "Clear",
"send": "Send",
"placeholder": "Enter your query (Support prefix: /<Query Mode>)",
"error": "Error: Failed to get response",
"queryModeError": "Only supports the following query modes: {{modes}}",
"queryModePrefixInvalid": "Invalid query mode prefix. Use: /<mode> [space] your query"
},
"querySettings": {
"parametersTitle": "Parameters",
"parametersDescription": "Configure your query parameters",
"queryMode": "Query Mode",
"queryModeTooltip": "Select the retrieval strategy:\n• Naive: Traditional text chunk vector retrieval\n• Local: Focus on entity retrieval\n• Global: Focus on relationship retrieval\n• Hybrid: Local+Global\n• Mix: Local+Global+Naive\n• Bypass: Skip retrieval, send conversation history and current question to LLM",
"queryModeOptions": {
"naive": "Naive",
"local": "Local",
"global": "Global",
"hybrid": "Hybrid",
"mix": "Mix",
"bypass": "Bypass"
},
"responseFormat": "Response Format",
"responseFormatTooltip": "Defines the response format. Examples:\n• Multiple Paragraphs\n• Single Paragraph\n• Bullet Points",
"responseFormatOptions": {
"multipleParagraphs": "Multiple Paragraphs",
"singleParagraph": "Single Paragraph",
"bulletPoints": "Bullet Points"
},
"topK": "KG Top K",
"topKTooltip": "Number of entities and relations to retrieve. Applicable for non-naive modes.",
"topKPlaceholder": "Enter top_k value",
"chunkTopK": "Chunk Top K",
"chunkTopKTooltip": "Number of text chunks to retrieve, applicable for all modes.",
"chunkTopKPlaceholder": "Enter chunk_top_k value",
"maxEntityTokens": "Max Entity Tokens",
"maxEntityTokensTooltip": "Maximum number of tokens allocated for entity context in unified token control system",
"maxRelationTokens": "Max Relation Tokens",
"maxRelationTokensTooltip": "Maximum number of tokens allocated for relationship context in unified token control system",
"maxTotalTokens": "Max Total Tokens",
"maxTotalTokensTooltip": "Maximum total tokens budget for the entire query context (entities + relations + chunks + system prompt)",
"historyTurns": "History Turns",
"historyTurnsTooltip": "Number of complete conversation turns (user-assistant pairs) to consider in the response context",
"historyTurnsPlaceholder": "Number of history turns",
"onlyNeedContext": "Only Need Context",
"onlyNeedContextTooltip": "If True, only returns the retrieved context without generating a response",
"onlyNeedPrompt": "Only Need Prompt",
"onlyNeedPromptTooltip": "If True, only returns the generated prompt without producing a response",
"streamResponse": "Stream Response",
"streamResponseTooltip": "If True, enables streaming output for real-time responses",
"userPrompt": "Additional Output Prompt",
"userPromptTooltip": "Provide additional response requirements to the LLM (unrelated to query content, only for output processing).",
"userPromptPlaceholder": "Enter custom prompt (optional)",
"enableRerank": "Enable Rerank",
"enableRerankTooltip": "Enable reranking for retrieved text chunks. If True but no rerank model is configured, a warning will be issued. Default is True."
}
},
"apiSite": {
"loading": "Loading API Documentation..."
},
"apiKeyAlert": {
"title": "API Key is required",
"description": "Please enter your API key to access the service",
"placeholder": "Enter your API key",
"save": "Save"
},
"pagination": {
"showing": "Showing {{start}} to {{end}} of {{total}} entries",
"page": "Page",
"pageSize": "Page Size",
"firstPage": "First Page",
"prevPage": "Previous Page",
"nextPage": "Next Page",
"lastPage": "Last Page"
}
}
+465
View File
@@ -0,0 +1,465 @@
{
"settings": {
"language": "Langue",
"theme": "Thème",
"light": "Clair",
"dark": "Sombre",
"system": "Système"
},
"header": {
"documents": "Documents",
"knowledgeGraph": "Graphe de connaissances",
"retrieval": "Récupération",
"api": "API",
"projectRepository": "Référentiel du projet",
"logout": "Déconnexion",
"frontendNeedsRebuild": "Le frontend nécessite une reconstruction",
"themeToggle": {
"switchToLight": "Passer au thème clair",
"switchToDark": "Passer au thème sombre"
}
},
"login": {
"description": "Veuillez entrer votre compte et mot de passe pour vous connecter au système",
"username": "Nom d'utilisateur",
"usernamePlaceholder": "Veuillez saisir un nom d'utilisateur",
"password": "Mot de passe",
"passwordPlaceholder": "Veuillez saisir un mot de passe",
"loginButton": "Connexion",
"loggingIn": "Connexion en cours...",
"successMessage": "Connexion réussie",
"errorEmptyFields": "Veuillez saisir votre nom d'utilisateur et mot de passe",
"errorInvalidCredentials": "Échec de la connexion, veuillez vérifier le nom d'utilisateur et le mot de passe",
"authDisabled": "L'authentification est désactivée. Utilisation du mode sans connexion.",
"guestMode": "Mode sans connexion"
},
"common": {
"cancel": "Annuler",
"save": "Sauvegarder",
"saving": "Sauvegarde en cours...",
"saveFailed": "Échec de la sauvegarde"
},
"documentPanel": {
"clearDocuments": {
"button": "Effacer",
"tooltip": "Effacer les documents",
"title": "Effacer les documents",
"description": "Cette action supprimera tous les documents du système",
"warning": "ATTENTION : Cette action supprimera définitivement tous les documents et ne peut pas être annulée !",
"confirm": "Voulez-vous vraiment effacer tous les documents ?",
"confirmPrompt": "Tapez 'yes' pour confirmer cette action",
"confirmPlaceholder": "Tapez yes pour confirmer",
"clearCache": "Effacer le cache LLM",
"confirmButton": "OUI",
"clearing": "Effacement en cours...",
"timeout": "L'opération d'effacement a expiré, veuillez réessayer",
"success": "Documents effacés avec succès",
"cacheCleared": "Cache effacé avec succès",
"cacheClearFailed": "Échec de l'effacement du cache :\n{{error}}",
"failed": "Échec de l'effacement des documents :\n{{message}}",
"error": "Échec de l'effacement des documents :\n{{error}}"
},
"deleteDocuments": {
"button": "Supprimer",
"tooltip": "Supprimer les documents sélectionnés",
"title": "Supprimer les documents",
"description": "Cette action supprimera définitivement les documents sélectionnés du système",
"warning": "ATTENTION : Cette action supprimera définitivement les documents sélectionnés et ne peut pas être annulée !",
"confirm": "Voulez-vous vraiment supprimer {{count}} document(s) sélectionné(s) ?",
"confirmPrompt": "Tapez 'yes' pour confirmer cette action",
"confirmPlaceholder": "Tapez yes pour confirmer",
"confirmButton": "OUI",
"deleteFileOption": "Supprimer également les fichiers téléchargés",
"deleteFileTooltip": "Cochez cette option pour supprimer également les fichiers téléchargés correspondants sur le serveur",
"deleteLLMCacheOption": "Supprimer également le cache LLM d'extraction",
"success": "Pipeline de suppression de documents démarré avec succès",
"failed": "Échec de la suppression des documents :\n{{message}}",
"error": "Échec de la suppression des documents :\n{{error}}",
"busy": "Le pipeline est occupé, veuillez réessayer plus tard",
"notAllowed": "Aucune autorisation pour effectuer cette opération"
},
"selectDocuments": {
"selectCurrentPage": "Sélectionner la page actuelle ({{count}})",
"deselectAll": "Tout désélectionner ({{count}})"
},
"uploadDocuments": {
"button": "Télécharger",
"tooltip": "Télécharger des documents",
"title": "Télécharger des documents",
"description": "Glissez-déposez vos documents ici ou cliquez pour parcourir.",
"single": {
"uploading": "Téléchargement de {{name}} : {{percent}}%",
"success": "Succès du téléchargement :\n{{name}} téléchargé avec succès",
"failed": "Échec du téléchargement :\n{{name}}\n{{message}}",
"error": "Échec du téléchargement :\n{{name}}\n{{error}}"
},
"batch": {
"uploading": "Téléchargement des fichiers...",
"success": "Fichiers téléchargés avec succès",
"error": "Certains fichiers n'ont pas pu être téléchargés"
},
"generalError": "Échec du téléchargement\n{{error}}",
"fileTypes": "Types pris en charge : TXT, MD, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
"fileUploader": {
"singleFileLimit": "Impossible de télécharger plus d'un fichier à la fois",
"maxFilesLimit": "Impossible de télécharger plus de {{count}} fichiers",
"fileRejected": "Le fichier {{name}} a été rejeté",
"unsupportedType": "Type de fichier non pris en charge",
"fileTooLarge": "Fichier trop volumineux, taille maximale {{maxSize}}",
"dropHere": "Déposez les fichiers ici",
"dragAndDrop": "Glissez et déposez les fichiers ici, ou cliquez pour sélectionner",
"removeFile": "Supprimer le fichier",
"uploadDescription": "Vous pouvez télécharger {{isMultiple ? 'plusieurs' : count}} fichiers (jusqu'à {{maxSize}} chacun)",
"duplicateFile": "Le nom du fichier existe déjà dans le cache du serveur"
}
},
"documentManager": {
"title": "Gestion des documents",
"scanButton": "Scanner/Retraiter",
"scanTooltip": "Scanner et traiter les documents dans le dossier d'entrée, et retraiter également tous les documents échoués",
"refreshTooltip": "Réinitialiser la liste des documents",
"pipelineStatusButton": "Pipeline",
"pipelineStatusTooltip": "Voir l'état du pipeline de traitement des documents",
"uploadedTitle": "Documents téléchargés",
"uploadedDescription": "Liste des documents téléchargés et leurs statuts.",
"emptyTitle": "Aucun document",
"emptyDescription": "Il n'y a pas encore de documents téléchargés.",
"columns": {
"id": "ID",
"fileName": "Nom du fichier",
"summary": "Résumé",
"status": "Statut",
"length": "Longueur",
"chunks": "Fragments",
"created": "Créé",
"updated": "Mis à jour",
"metadata": "Métadonnées",
"select": "Sélectionner"
},
"status": {
"all": "Tous",
"completed": "Terminé",
"preprocessed": "Prétraité",
"processing": "En traitement",
"pending": "En attente",
"failed": "Échoué"
},
"errors": {
"loadFailed": "Échec du chargement des documents\n{{error}}",
"scanFailed": "Échec de la numérisation des documents\n{{error}}",
"scanProgressFailed": "Échec de l'obtention de la progression de la numérisation\n{{error}}"
},
"fileNameLabel": "Nom du fichier",
"showButton": "Afficher",
"hideButton": "Masquer",
"showFileNameTooltip": "Afficher le nom du fichier",
"hideFileNameTooltip": "Masquer le nom du fichier"
},
"pipelineStatus": {
"title": "État du Pipeline",
"busy": "Pipeline Occupé",
"requestPending": "Demande en Attente",
"cancellationRequested": "Annulation Demandée",
"jobName": "Nom du Travail",
"startTime": "Heure de Début",
"progress": "Progrès",
"unit": "Lot",
"pipelineMessages": "Messages de Pipeline",
"cancelButton": "Annuler",
"cancelTooltip": "Annuler le traitement du pipeline",
"cancelConfirmTitle": "Confirmer l'Annulation du Pipeline",
"cancelConfirmDescription": "Cette action interrompra le traitement du pipeline en cours. Êtes-vous sûr de vouloir continuer ?",
"cancelConfirmButton": "Confirmer l'Annulation",
"cancelInProgress": "Annulation en cours...",
"pipelineNotRunning": "Le pipeline n'est pas en cours d'exécution",
"cancelSuccess": "Annulation du pipeline demandée",
"cancelFailed": "Échec de l'annulation du pipeline\n{{error}}",
"cancelNotBusy": "Le pipeline n'est pas en cours d'exécution, pas besoin d'annuler",
"errors": {
"fetchFailed": "Échec de la récupération de l'état du pipeline\n{{error}}"
}
}
},
"graphPanel": {
"dataIsTruncated": "Les données du graphe sont tronquées au nombre maximum de nœuds",
"statusDialog": {
"title": "Paramètres du Serveur LightRAG",
"description": "Afficher l'état actuel du système et les informations de connexion"
},
"legend": "Légende",
"nodeTypes": {
"person": "Personne",
"category": "Catégorie",
"geo": "Géographique",
"location": "Emplacement",
"organization": "Organisation",
"event": "Événement",
"equipment": "Équipement",
"weapon": "Arme",
"animal": "Animal",
"unknown": "Inconnu",
"object": "Objet",
"group": "Groupe",
"technology": "Technologie",
"product": "Produit",
"document": "Document",
"content": "Contenu",
"data": "Données",
"artifact": "Artefact",
"concept": "Concept",
"naturalobject": "Objet naturel",
"method": "Méthode",
"creature": "Créature",
"plant": "Plante",
"disease": "Maladie",
"drug": "Médicament",
"food": "Nourriture",
"other": "Autre"
},
"sideBar": {
"settings": {
"settings": "Paramètres",
"healthCheck": "Vérification de l'état",
"showPropertyPanel": "Afficher le panneau des propriétés",
"showSearchBar": "Afficher la barre de recherche",
"showNodeLabel": "Afficher l'étiquette du nœud",
"nodeDraggable": "Nœud déplaçable",
"showEdgeLabel": "Afficher l'étiquette de l'arête",
"hideUnselectedEdges": "Masquer les arêtes non sélectionnées",
"edgeEvents": "Événements des arêtes",
"maxQueryDepth": "Profondeur maximale de la requête",
"maxNodes": "Nombre maximum de nœuds",
"maxLayoutIterations": "Itérations maximales de mise en page",
"resetToDefault": "Réinitialiser par défaut",
"edgeSizeRange": "Plage de taille des arêtes",
"depth": "D",
"max": "Max",
"degree": "Degré",
"apiKey": "Clé API",
"enterYourAPIkey": "Entrez votre clé API",
"save": "Sauvegarder",
"refreshLayout": "Actualiser la mise en page"
},
"zoomControl": {
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière",
"resetZoom": "Réinitialiser le zoom",
"rotateCamera": "Rotation horaire",
"rotateCameraCounterClockwise": "Rotation antihoraire"
},
"layoutsControl": {
"startAnimation": "Démarrer l'animation de mise en page",
"stopAnimation": "Arrêter l'animation de mise en page",
"layoutGraph": "Mettre en page le graphe",
"layouts": {
"Circular": "Circulaire",
"Circlepack": "Paquet circulaire",
"Random": "Aléatoire",
"Noverlaps": "Sans chevauchement",
"Force Directed": "Dirigé par la force",
"Force Atlas": "Atlas de force"
}
},
"fullScreenControl": {
"fullScreen": "Plein écran",
"windowed": "Fenêtré"
},
"legendControl": {
"toggleLegend": "Basculer la légende"
}
},
"statusIndicator": {
"connected": "Connecté",
"disconnected": "Déconnecté"
},
"statusCard": {
"unavailable": "Informations sur l'état indisponibles",
"serverInfo": "Informations du serveur",
"workingDirectory": "Répertoire de travail",
"inputDirectory": "Répertoire d'entrée",
"maxParallelInsert": "Traitement simultané des documents",
"summarySettings": "Paramètres de résumé",
"llmConfig": "Configuration du modèle de langage",
"llmBinding": "Liaison du modèle de langage",
"llmBindingHost": "Point de terminaison LLM",
"llmModel": "Modèle de langage",
"embeddingConfig": "Configuration d'incorporation",
"embeddingBinding": "Liaison d'incorporation",
"embeddingBindingHost": "Point de terminaison d'incorporation",
"embeddingModel": "Modèle d'incorporation",
"storageConfig": "Configuration de stockage",
"kvStorage": "Stockage clé-valeur",
"docStatusStorage": "Stockage de l'état des documents",
"graphStorage": "Stockage du graphe",
"vectorStorage": "Stockage vectoriel",
"workspace": "Espace de travail",
"maxGraphNodes": "Nombre maximum de nœuds du graphe",
"rerankerConfig": "Configuration du reclassement",
"rerankerBindingHost": "Point de terminaison de reclassement",
"rerankerModel": "Modèle de reclassement",
"lockStatus": "État des verrous",
"threshold": "Seuil"
},
"propertiesView": {
"editProperty": "Modifier {{property}}",
"editPropertyDescription": "Modifiez la valeur de la propriété dans la zone de texte ci-dessous.",
"errors": {
"duplicateName": "Le nom du nœud existe déjà",
"updateFailed": "Échec de la mise à jour du nœud",
"tryAgainLater": "Veuillez réessayer plus tard",
"updateSuccessButMergeFailed": "Propriétés mises à jour, mais la fusion a échoué : {{error}}",
"mergeFailed": "Échec de la fusion : {{error}}"
},
"success": {
"entityUpdated": "Nœud mis à jour avec succès",
"relationUpdated": "Relation mise à jour avec succès",
"entityMerged": "Fusion des nœuds réussie"
},
"mergeOptionLabel": "Fusionner automatiquement en cas de nom dupliqué",
"mergeOptionDescription": "Si activé, renommer vers un nom existant fusionnera automatiquement ce nœud avec celui-ci au lieu d'échouer.",
"mergeDialog": {
"title": "Nœud fusionné",
"description": "\"{{source}}\" a été fusionné dans \"{{target}}\".",
"refreshHint": "Actualisez le graphe pour charger la structure la plus récente.",
"keepCurrentStart": "Actualiser en conservant le nœud de départ actuel",
"useMergedStart": "Actualiser en utilisant le nœud fusionné",
"refreshing": "Actualisation du graphe..."
},
"node": {
"title": "Nœud",
"id": "ID",
"labels": "Étiquettes",
"degree": "Degré",
"properties": "Propriétés",
"relationships": "Relations(dans le sous-graphe)",
"expandNode": "Développer le nœud",
"pruneNode": "Élaguer le nœud",
"deleteAllNodesError": "Refus de supprimer tous les nœuds du graphe",
"nodesRemoved": "{{count}} nœuds supprimés, y compris les nœuds orphelins",
"noNewNodes": "Aucun nœud développable trouvé",
"propertyNames": {
"description": "Description",
"entity_id": "Nom",
"entity_type": "Type",
"source_id": "C-ID",
"Neighbour": "Voisin",
"file_path": "File",
"keywords": "Keys",
"weight": "Poids"
}
},
"edge": {
"title": "Relation",
"id": "ID",
"type": "Type",
"source": "Source",
"target": "Cible",
"properties": "Propriétés"
}
},
"search": {
"placeholder": "Rechercher des nœuds dans la page...",
"message": "Et {{count}} autres"
},
"graphLabels": {
"selectTooltip": "Obtenir le sous-graphe d'un nœud (étiquette)",
"noLabels": "Aucun nœud correspondant trouvé",
"label": "Rechercher le nom du nœud",
"placeholder": "Rechercher le nom du nœud...",
"andOthers": "Et {{count}} autres",
"refreshGlobalTooltip": "Actualiser les données du graphe global et réinitialiser l'historique de recherche",
"refreshCurrentLabelTooltip": "Actualiser les données du graphe de la page actuelle",
"refreshingTooltip": "Actualisation des données en cours..."
},
"emptyGraph": "Vide (Essayez de recharger)"
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "Copier dans le presse-papiers",
"copyError": "Échec de la copie du texte dans le presse-papiers",
"copyEmpty": "Aucun contenu à copier",
"copySuccess": "Contenu copié dans le presse-papiers",
"copySuccessLegacy": "Contenu copié (méthode héritée)",
"copySuccessManual": "Contenu copié (méthode manuelle)",
"copyFailed": "Échec de la copie du contenu",
"copyManualInstruction": "Veuillez sélectionner et copier le texte manuellement",
"thinking": "Réflexion en cours...",
"thinkingTime": "Temps de réflexion {{time}}s",
"thinkingInProgress": "Réflexion en cours..."
},
"retrieval": {
"startPrompt": "Démarrez une récupération en tapant votre requête ci-dessous",
"clear": "Effacer",
"send": "Envoyer",
"placeholder": "Tapez votre requête (Préfixe de requête : /<Query Mode>)",
"error": "Erreur : Échec de l'obtention de la réponse",
"queryModeError": "Seuls les modes de requête suivants sont pris en charge : {{modes}}",
"queryModePrefixInvalid": "Préfixe de mode de requête invalide. Utilisez : /<mode> [espace] votre requête"
},
"querySettings": {
"parametersTitle": "Paramètres",
"parametersDescription": "Configurez vos paramètres de requête",
"queryMode": "Mode de requête",
"queryModeTooltip": "Sélectionnez la stratégie de récupération :\n• Naïf : Récupération vectorielle traditionnelle par blocs de texte\n• Local : Axé sur la récupération d'entités\n• Global : Axé sur la récupération de relations\n• Hybride : Local+Global\n• Mixte : Local+Global+Naïf\n• Bypass : Ignorer la récupération, envoyer l'historique de conversation et la question actuelle au LLM",
"queryModeOptions": {
"naive": "Naïf",
"local": "Local",
"global": "Global",
"hybrid": "Hybride",
"mix": "Mixte",
"bypass": "Bypass"
},
"responseFormat": "Format de réponse",
"responseFormatTooltip": "Définit le format de la réponse. Exemples :\n• Plusieurs paragraphes\n• Paragraphe unique\n• Points à puces",
"responseFormatOptions": {
"multipleParagraphs": "Plusieurs paragraphes",
"singleParagraph": "Paragraphe unique",
"bulletPoints": "Points à puces"
},
"topK": "KG Top K",
"topKTooltip": "Nombre d'entités et de relations à récupérer. Applicable pour les modes non-naïfs.",
"topKPlaceholder": "Entrez la valeur top_k",
"chunkTopK": "Top K des Chunks",
"chunkTopKTooltip": "Nombre de morceaux de texte à récupérer, applicable à tous les modes.",
"chunkTopKPlaceholder": "Entrez la valeur chunk_top_k",
"maxEntityTokens": "Limite de jetons d'entité",
"maxEntityTokensTooltip": "Nombre maximum de jetons alloués au contexte d'entité dans le système de contrôle de jetons unifié",
"maxRelationTokens": "Limite de jetons de relation",
"maxRelationTokensTooltip": "Nombre maximum de jetons alloués au contexte de relation dans le système de contrôle de jetons unifié",
"maxTotalTokens": "Limite totale de jetons",
"maxTotalTokensTooltip": "Budget total maximum de jetons pour l'ensemble du contexte de requête (entités + relations + blocs + prompt système)",
"historyTurns": "Tours d'historique",
"historyTurnsTooltip": "Nombre de tours complets de conversation (paires utilisateur-assistant) à prendre en compte dans le contexte de la réponse",
"historyTurnsPlaceholder": "Nombre de tours d'historique",
"onlyNeedContext": "Besoin uniquement du contexte",
"onlyNeedContextTooltip": "Si vrai, ne renvoie que le contexte récupéré sans générer de réponse",
"onlyNeedPrompt": "Besoin uniquement de l'invite",
"onlyNeedPromptTooltip": "Si vrai, ne renvoie que l'invite générée sans produire de réponse",
"streamResponse": "Réponse en flux",
"streamResponseTooltip": "Si vrai, active la sortie en flux pour des réponses en temps réel",
"userPrompt": "Invite de sortie supplémentaire",
"userPromptTooltip": "Fournir des exigences de réponse supplémentaires au LLM (sans rapport avec le contenu de la requête, uniquement pour le traitement de sortie).",
"userPromptPlaceholder": "Entrez une invite personnalisée (facultatif)",
"enableRerank": "Activer le Reclassement",
"enableRerankTooltip": "Active le reclassement pour les fragments de texte récupérés. Si True mais qu'aucun modèle de reclassement n'est configuré, un avertissement sera émis. True par défaut."
}
},
"apiSite": {
"loading": "Chargement de la documentation de l'API..."
},
"apiKeyAlert": {
"title": "Clé API requise",
"description": "Veuillez entrer votre clé API pour accéder au service",
"placeholder": "Entrez votre clé API",
"save": "Sauvegarder"
},
"pagination": {
"showing": "Affichage de {{start}} à {{end}} sur {{total}} entrées",
"page": "Page",
"pageSize": "Taille de la page",
"firstPage": "Première page",
"prevPage": "Page précédente",
"nextPage": "Page suivante",
"lastPage": "Dernière page"
}
}
+465
View File
@@ -0,0 +1,465 @@
{
"settings": {
"language": "语言",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "系统"
},
"header": {
"documents": "文档",
"knowledgeGraph": "知识图谱",
"retrieval": "检索",
"api": "API",
"projectRepository": "项目仓库",
"logout": "退出登录",
"frontendNeedsRebuild": "前端代码需重新构建",
"themeToggle": {
"switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题"
}
},
"login": {
"description": "请输入您的账号和密码登录系统",
"username": "用户名",
"usernamePlaceholder": "请输入用户名",
"password": "密码",
"passwordPlaceholder": "请输入密码",
"loginButton": "登录",
"loggingIn": "登录中...",
"successMessage": "登录成功",
"errorEmptyFields": "请输入您的用户名和密码",
"errorInvalidCredentials": "登录失败,请检查用户名和密码",
"authDisabled": "认证已禁用,使用无需登陆模式。",
"guestMode": "无需登陆"
},
"common": {
"cancel": "取消",
"save": "保存",
"saving": "保存中...",
"saveFailed": "保存失败"
},
"documentPanel": {
"clearDocuments": {
"button": "清空",
"tooltip": "清空文档",
"title": "清空文档",
"description": "此操作将从系统中移除所有文档",
"warning": "警告:此操作将永久删除所有文档,无法恢复!",
"confirm": "确定要清空所有文档吗?",
"confirmPrompt": "请输入 yes 确认操作",
"confirmPlaceholder": "输入 yes 确认",
"clearCache": "清空LLM缓存",
"confirmButton": "确定",
"clearing": "正在清除...",
"timeout": "清除操作超时,请重试",
"success": "文档清空成功",
"cacheCleared": "缓存清空成功",
"cacheClearFailed": "清空缓存失败:\n{{error}}",
"failed": "清空文档失败:\n{{message}}",
"error": "清空文档失败:\n{{error}}"
},
"deleteDocuments": {
"button": "删除",
"tooltip": "删除选中的文档",
"title": "删除文档",
"description": "此操作将永久删除选中的文档",
"warning": "警告:此操作将永久删除选中的文档,无法恢复!",
"confirm": "确定要删除 {{count}} 个选中的文档吗?",
"confirmPrompt": "请输入 yes 确认操作",
"confirmPlaceholder": "输入 yes 确认",
"confirmButton": "确定",
"deleteFileOption": "同时删除上传文件",
"deleteFileTooltip": "选中此选项将同时删除服务器上对应的上传文件",
"deleteLLMCacheOption": "同时删除实体关系抽取 LLM 缓存",
"success": "文档删除流水线启动成功",
"failed": "删除文档失败:\n{{message}}",
"error": "删除文档失败:\n{{error}}",
"busy": "流水线被占用,请稍后再试",
"notAllowed": "没有操作权限"
},
"selectDocuments": {
"selectCurrentPage": "全选当前页 ({{count}})",
"deselectAll": "取消全选 ({{count}})"
},
"uploadDocuments": {
"button": "上传",
"tooltip": "上传文档",
"title": "上传文档",
"description": "拖拽文件到此处或点击浏览",
"single": {
"uploading": "正在上传 {{name}}{{percent}}%",
"success": "上传成功:\n{{name}} 上传完成",
"failed": "上传失败:\n{{name}}\n{{message}}",
"error": "上传失败:\n{{name}}\n{{error}}"
},
"batch": {
"uploading": "正在上传文件...",
"success": "文件上传完成",
"error": "部分文件上传失败"
},
"generalError": "上传失败\n{{error}}",
"fileTypes": "支持的文件类型:TXT, MD, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
"fileUploader": {
"singleFileLimit": "一次只能上传一个文件",
"maxFilesLimit": "最多只能上传 {{count}} 个文件",
"fileRejected": "文件 {{name}} 被拒绝",
"unsupportedType": "不支持的文件类型",
"fileTooLarge": "文件过大,最大允许 {{maxSize}}",
"dropHere": "将文件拖放到此处",
"dragAndDrop": "拖放文件到此处,或点击选择文件",
"removeFile": "移除文件",
"uploadDescription": "您可以上传{{isMultiple ? '多个' : count}}个文件(每个文件最大{{maxSize}}",
"duplicateFile": "文件名与服务器上的缓存重复"
}
},
"documentManager": {
"title": "文档管理",
"scanButton": "扫描/重试",
"scanTooltip": "扫描处理输入目录中的文档,同时重新处理所有失败的文档",
"refreshTooltip": "复位文档清单",
"pipelineStatusButton": "流水线",
"pipelineStatusTooltip": "查看文档处理流水线状态",
"uploadedTitle": "已上传文档",
"uploadedDescription": "已上传文档列表及其状态",
"emptyTitle": "无文档",
"emptyDescription": "还没有上传任何文档",
"columns": {
"id": "ID",
"fileName": "文件名",
"summary": "摘要",
"status": "状态",
"length": "长度",
"chunks": "分块",
"created": "创建时间",
"updated": "更新时间",
"metadata": "元数据",
"select": "选择"
},
"status": {
"all": "全部",
"completed": "已完成",
"preprocessed": "预处理",
"processing": "处理中",
"pending": "等待中",
"failed": "失败"
},
"errors": {
"loadFailed": "加载文档失败\n{{error}}",
"scanFailed": "扫描文档失败\n{{error}}",
"scanProgressFailed": "获取扫描进度失败\n{{error}}"
},
"fileNameLabel": "文件名",
"showButton": "显示",
"hideButton": "隐藏",
"showFileNameTooltip": "显示文件名",
"hideFileNameTooltip": "隐藏文件名"
},
"pipelineStatus": {
"title": "流水线状态",
"busy": "流水线忙碌",
"requestPending": "待处理请求",
"cancellationRequested": "取消请求",
"jobName": "作业名称",
"startTime": "开始时间",
"progress": "进度",
"unit": "批",
"pipelineMessages": "流水线消息",
"cancelButton": "中断",
"cancelTooltip": "中断流水线处理",
"cancelConfirmTitle": "确认中断流水线",
"cancelConfirmDescription": "此操作将中断正在进行的流水线处理。确定要继续吗?",
"cancelConfirmButton": "确认中断",
"cancelInProgress": "取消请求进行中...",
"pipelineNotRunning": "流水线未运行",
"cancelSuccess": "流水线中断请求已发送",
"cancelFailed": "中断流水线失败\n{{error}}",
"cancelNotBusy": "流水线未运行,无需中断",
"errors": {
"fetchFailed": "获取流水线状态失败\n{{error}}"
}
}
},
"graphPanel": {
"dataIsTruncated": "图数据已截断至最大返回节点数",
"statusDialog": {
"title": "LightRAG 服务器设置",
"description": "查看当前系统状态和连接信息"
},
"legend": "图例",
"nodeTypes": {
"person": "人物角色",
"category": "分类",
"geo": "地理名称",
"location": "位置",
"organization": "组织机构",
"event": "事件",
"equipment": "装备",
"weapon": "武器",
"animal": "动物",
"unknown": "未知",
"object": "物品",
"group": "群组",
"technology": "技术",
"product": "产品",
"document": "文档",
"content": "内容",
"data": "数据",
"artifact": "人工制品",
"concept": "概念",
"naturalobject": "自然物品",
"method": "方法",
"creature": "生物神怪",
"plant": "植物",
"disease": "疾病",
"drug": "药物",
"food": "食物",
"other": "其他"
},
"sideBar": {
"settings": {
"settings": "设置",
"healthCheck": "健康检查",
"showPropertyPanel": "显示属性面板",
"showSearchBar": "显示搜索栏",
"showNodeLabel": "显示节点标签",
"nodeDraggable": "节点可拖动",
"showEdgeLabel": "显示边标签",
"hideUnselectedEdges": "隐藏未选中的边",
"edgeEvents": "边事件",
"maxQueryDepth": "最大查询深度",
"maxNodes": "最大返回节点数",
"maxLayoutIterations": "最大布局迭代次数",
"resetToDefault": "重置为默认值",
"edgeSizeRange": "边粗细范围",
"depth": "深",
"max": "Max",
"degree": "邻边",
"apiKey": "API密钥",
"enterYourAPIkey": "输入您的API密钥",
"save": "保存",
"refreshLayout": "刷新布局"
},
"zoomControl": {
"zoomIn": "放大",
"zoomOut": "缩小",
"resetZoom": "重置缩放",
"rotateCamera": "顺时针旋转图形",
"rotateCameraCounterClockwise": "逆时针旋转图形"
},
"layoutsControl": {
"startAnimation": "继续布局动画",
"stopAnimation": "停止布局动画",
"layoutGraph": "图布局",
"layouts": {
"Circular": "环形",
"Circlepack": "圆形打包",
"Random": "随机",
"Noverlaps": "无重叠",
"Force Directed": "力导向",
"Force Atlas": "力地图"
}
},
"fullScreenControl": {
"fullScreen": "全屏",
"windowed": "窗口"
},
"legendControl": {
"toggleLegend": "切换图例显示"
}
},
"statusIndicator": {
"connected": "已连接",
"disconnected": "未连接"
},
"statusCard": {
"unavailable": "状态信息不可用",
"serverInfo": "服务器信息",
"workingDirectory": "工作目录",
"inputDirectory": "输入目录",
"maxParallelInsert": "并行处理文档",
"summarySettings": "摘要设置",
"llmConfig": "LLM配置",
"llmBinding": "LLM绑定",
"llmBindingHost": "LLM端点",
"llmModel": "LLM模型",
"embeddingConfig": "嵌入配置",
"embeddingBinding": "嵌入绑定",
"embeddingBindingHost": "嵌入端点",
"embeddingModel": "嵌入模型",
"storageConfig": "存储配置",
"kvStorage": "KV存储",
"docStatusStorage": "文档状态存储",
"graphStorage": "图存储",
"vectorStorage": "向量存储",
"workspace": "工作空间",
"maxGraphNodes": "最大图节点数",
"rerankerConfig": "重排序配置",
"rerankerBindingHost": "重排序端点",
"rerankerModel": "重排序模型",
"lockStatus": "锁状态",
"threshold": "阈值"
},
"propertiesView": {
"editProperty": "编辑{{property}}",
"editPropertyDescription": "在下方文本区域编辑属性值。",
"errors": {
"duplicateName": "节点名称已存在",
"updateFailed": "更新节点失败",
"tryAgainLater": "请稍后重试",
"updateSuccessButMergeFailed": "属性已更新,但合并失败:{{error}}",
"mergeFailed": "合并失败:{{error}}"
},
"success": {
"entityUpdated": "节点更新成功",
"relationUpdated": "关系更新成功",
"entityMerged": "节点合并成功"
},
"mergeOptionLabel": "重名时自动合并",
"mergeOptionDescription": "勾选后,重命名为已存在的名称会将当前节点自动合并过去,而不会报错。",
"mergeDialog": {
"title": "节点已合并",
"description": "\"{{source}}\" 已合并到 \"{{target}}\"。",
"refreshHint": "请刷新图谱以获取最新结构。",
"keepCurrentStart": "刷新并保持当前起始节点",
"useMergedStart": "刷新并以合并后的节点为起始节点",
"refreshing": "正在刷新图谱..."
},
"node": {
"title": "节点",
"id": "ID",
"labels": "标签",
"degree": "度数",
"properties": "属性",
"relationships": "关系(子图内)",
"expandNode": "扩展节点",
"pruneNode": "修剪节点",
"deleteAllNodesError": "拒绝删除图中的所有节点",
"nodesRemoved": "已删除 {{count}} 个节点,包括孤立节点",
"noNewNodes": "没有发现可以扩展的节点",
"propertyNames": {
"description": "描述",
"entity_id": "名称",
"entity_type": "类型",
"source_id": "C-ID",
"Neighbour": "邻接",
"file_path": "文件",
"keywords": "Keys",
"weight": "权重"
}
},
"edge": {
"title": "关系",
"id": "ID",
"type": "类型",
"source": "源节点",
"target": "目标节点",
"properties": "属性"
}
},
"search": {
"placeholder": "页面内搜索节点...",
"message": "还有 {{count}} 个"
},
"graphLabels": {
"selectTooltip": "获取节点(标签)子图",
"noLabels": "未找到匹配的节点",
"label": "搜索节点名称",
"placeholder": "搜索节点名称...",
"andOthers": "还有 {{count}} 个",
"refreshGlobalTooltip": "刷新全图数据和重置搜索历史",
"refreshCurrentLabelTooltip": "刷新当前页面图数据",
"refreshingTooltip": "正在刷新数据..."
},
"emptyGraph": "无数据(请重载图形数据)"
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "复制到剪贴板",
"copyError": "复制文本到剪贴板失败",
"copyEmpty": "没有内容可复制",
"copySuccess": "内容已复制到剪贴板",
"copySuccessLegacy": "内容已复制(传统方法)",
"copySuccessManual": "内容已复制(手动方法)",
"copyFailed": "复制内容失败",
"copyManualInstruction": "请手动选择并复制文本",
"thinking": "正在思考...",
"thinkingTime": "思考用时 {{time}} 秒",
"thinkingInProgress": "思考进行中..."
},
"retrieval": {
"startPrompt": "输入查询开始检索",
"clear": "清空",
"send": "发送",
"placeholder": "输入查询内容 (支持模式前缀: /<Query Mode>)",
"error": "错误:获取响应失败",
"queryModeError": "仅支持以下查询模式:{{modes}}",
"queryModePrefixInvalid": "无效的查询模式前缀。请使用:/<模式> [空格] 查询内容"
},
"querySettings": {
"parametersTitle": "参数",
"parametersDescription": "配置查询参数",
"queryMode": "查询模式",
"queryModeTooltip": "选择检索策略:\n• Naive:传统文本块向量检索\n• Local:侧重实体检索\n• Global:侧重关系检索\n• HybridLocal+Global\n• MixLocal+Global+Naive\n• Bypass:跳过检索,把历史会话与当前问题送LLM",
"queryModeOptions": {
"naive": "Naive",
"local": "Local",
"global": "Global",
"hybrid": "Hybrid",
"mix": "Mix",
"bypass": "Bypass"
},
"responseFormat": "响应格式",
"responseFormatTooltip": "定义响应格式。例如:\n• 多段落\n• 单段落\n• 要点",
"responseFormatOptions": {
"multipleParagraphs": "多段落",
"singleParagraph": "单段落",
"bulletPoints": "要点"
},
"topK": "KG Top K",
"topKTooltip": "实体关系检索数量, 适用于非naive模式",
"topKPlaceholder": "输入top_k值",
"chunkTopK": "文本块 Top K",
"chunkTopKTooltip": "文本块检索数量, 适用于所有模式",
"chunkTopKPlaceholder": "输入文本块chunk_top_k值",
"maxEntityTokens": "实体令牌数上限",
"maxEntityTokensTooltip": "统一令牌控制系统中分配给实体上下文的最大令牌数",
"maxRelationTokens": "关系令牌数上限",
"maxRelationTokensTooltip": "统一令牌控制系统中分配给关系上下文的最大令牌数",
"maxTotalTokens": "总令牌数上限",
"maxTotalTokensTooltip": "整个查询上下文的最大总令牌预算(实体+关系+文档块+系统提示)",
"historyTurns": "历史轮次",
"historyTurnsTooltip": "响应上下文中考虑的完整对话轮次(用户-助手对)数量",
"historyTurnsPlaceholder": "历史轮次数",
"onlyNeedContext": "仅需上下文",
"onlyNeedContextTooltip": "如果为True,仅返回检索到的上下文而不生成响应",
"onlyNeedPrompt": "仅需提示",
"onlyNeedPromptTooltip": "如果为True,仅返回生成的提示而不产生响应",
"streamResponse": "流式响应",
"streamResponseTooltip": "如果为True,启用实时流式输出响应",
"userPrompt": "附加输出提示词",
"userPromptTooltip": "向LLM提供额外的响应要求(与查询内容无关,仅用于处理输出)。",
"userPromptPlaceholder": "输入自定义提示词(可选)",
"enableRerank": "启用重排",
"enableRerankTooltip": "为检索到的文本块启用重排。如果为True但未配置重排模型,将发出警告。默认为True。"
}
},
"apiSite": {
"loading": "正在加载 API 文档..."
},
"apiKeyAlert": {
"title": "需要 API Key",
"description": "请输入您的 API Key 以访问服务",
"placeholder": "请输入 API Key",
"save": "保存"
},
"pagination": {
"showing": "显示第 {{start}} 到 {{end}} 条,共 {{total}} 条记录",
"page": "页",
"pageSize": "每页显示",
"firstPage": "首页",
"prevPage": "上一页",
"nextPage": "下一页",
"lastPage": "末页"
}
}
@@ -0,0 +1,465 @@
{
"settings": {
"language": "語言",
"theme": "主題",
"light": "淺色",
"dark": "深色",
"system": "系統"
},
"header": {
"documents": "文件",
"knowledgeGraph": "知識圖譜",
"retrieval": "檢索",
"api": "API",
"projectRepository": "專案庫",
"logout": "登出",
"frontendNeedsRebuild": "前端程式碼需重新建置",
"themeToggle": {
"switchToLight": "切換至淺色主題",
"switchToDark": "切換至深色主題"
}
},
"login": {
"description": "請輸入您的帳號和密碼登入系統",
"username": "帳號",
"usernamePlaceholder": "請輸入帳號",
"password": "密碼",
"passwordPlaceholder": "請輸入密碼",
"loginButton": "登入",
"loggingIn": "登入中...",
"successMessage": "登入成功",
"errorEmptyFields": "請輸入您的帳號和密碼",
"errorInvalidCredentials": "登入失敗,請檢查帳號和密碼",
"authDisabled": "認證已停用,使用免登入模式",
"guestMode": "免登入"
},
"common": {
"cancel": "取消",
"save": "儲存",
"saving": "儲存中...",
"saveFailed": "儲存失敗"
},
"documentPanel": {
"clearDocuments": {
"button": "清空",
"tooltip": "清空文件",
"title": "清空文件",
"description": "此操作將從系統中移除所有文件",
"warning": "警告:此操作將永久刪除所有文件,無法復原!",
"confirm": "確定要清空所有文件嗎?",
"confirmPrompt": "請輸入 yes 確認操作",
"confirmPlaceholder": "輸入 yes 以確認",
"clearCache": "清空 LLM 快取",
"confirmButton": "確定",
"clearing": "正在清除...",
"timeout": "清除操作逾時,請重試",
"success": "文件清空成功",
"cacheCleared": "快取清空成功",
"cacheClearFailed": "清空快取失敗:\n{{error}}",
"failed": "清空文件失敗:\n{{message}}",
"error": "清空文件失敗:\n{{error}}"
},
"deleteDocuments": {
"button": "刪除",
"tooltip": "刪除選取的文件",
"title": "刪除文件",
"description": "此操作將永久刪除選取的文件",
"warning": "警告:此操作將永久刪除選取的文件,無法復原!",
"confirm": "確定要刪除 {{count}} 個選取的文件嗎?",
"confirmPrompt": "請輸入 yes 確認操作",
"confirmPlaceholder": "輸入 yes 以確認",
"confirmButton": "確定",
"deleteFileOption": "同時刪除上傳檔案",
"deleteFileTooltip": "選取此選項將同時刪除伺服器上對應的上傳檔案",
"deleteLLMCacheOption": "同時刪除實體關係擷取 LLM 快取",
"success": "文件刪除流水線啟動成功",
"failed": "刪除文件失敗:\n{{message}}",
"error": "刪除文件失敗:\n{{error}}",
"busy": "pipeline 被佔用,請稍後再試",
"notAllowed": "沒有操作權限"
},
"selectDocuments": {
"selectCurrentPage": "全選當前頁 ({{count}})",
"deselectAll": "取消全選 ({{count}})"
},
"uploadDocuments": {
"button": "上傳",
"tooltip": "上傳文件",
"title": "上傳文件",
"description": "拖曳檔案至此處或點擊瀏覽",
"single": {
"uploading": "正在上傳 {{name}}{{percent}}%",
"success": "上傳成功:\n{{name}} 上傳完成",
"failed": "上傳失敗:\n{{name}}\n{{message}}",
"error": "上傳失敗:\n{{name}}\n{{error}}"
},
"batch": {
"uploading": "正在上傳檔案...",
"success": "檔案上傳完成",
"error": "部分檔案上傳失敗"
},
"generalError": "上傳失敗\n{{error}}",
"fileTypes": "支援的檔案類型:TXT, MD, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
"fileUploader": {
"singleFileLimit": "一次只能上傳一個檔案",
"maxFilesLimit": "最多只能上傳 {{count}} 個檔案",
"fileRejected": "檔案 {{name}} 被拒絕",
"unsupportedType": "不支援的檔案類型",
"fileTooLarge": "檔案過大,最大允許 {{maxSize}}",
"dropHere": "將檔案拖放至此處",
"dragAndDrop": "拖放檔案至此處,或點擊選擇檔案",
"removeFile": "移除檔案",
"uploadDescription": "您可以上傳{{isMultiple ? '多個' : count}}個檔案(每個檔案最大{{maxSize}}",
"duplicateFile": "檔案名稱與伺服器上的快取重複"
}
},
"documentManager": {
"title": "文件管理",
"scanButton": "掃描/重試",
"scanTooltip": "掃描處理輸入目錄中的文件,同時重新處理所有失敗的文件",
"refreshTooltip": "重設文件清單",
"pipelineStatusButton": "管線狀態",
"pipelineStatusTooltip": "查看文件處理管線狀態",
"uploadedTitle": "已上傳文件",
"uploadedDescription": "已上傳文件清單及其狀態",
"emptyTitle": "無文件",
"emptyDescription": "尚未上傳任何文件",
"columns": {
"id": "ID",
"fileName": "檔案名稱",
"summary": "摘要",
"status": "狀態",
"length": "長度",
"chunks": "分塊",
"created": "建立時間",
"updated": "更新時間",
"metadata": "元資料",
"select": "選擇"
},
"status": {
"all": "全部",
"completed": "已完成",
"preprocessed": "預處理",
"processing": "處理中",
"pending": "等待中",
"failed": "失敗"
},
"errors": {
"loadFailed": "載入文件失敗\n{{error}}",
"scanFailed": "掃描文件失敗\n{{error}}",
"scanProgressFailed": "取得掃描進度失敗\n{{error}}"
},
"fileNameLabel": "檔案名稱",
"showButton": "顯示",
"hideButton": "隱藏",
"showFileNameTooltip": "顯示檔案名稱",
"hideFileNameTooltip": "隱藏檔案名稱"
},
"pipelineStatus": {
"title": "流水線狀態",
"busy": "流水線忙碌",
"requestPending": "待處理請求",
"cancellationRequested": "取消請求",
"jobName": "作業名稱",
"startTime": "開始時間",
"progress": "進度",
"unit": "批",
"pipelineMessages": "流水線消息",
"cancelButton": "中斷",
"cancelTooltip": "中斷流水線處理",
"cancelConfirmTitle": "確認中斷流水線",
"cancelConfirmDescription": "此操作將中斷正在進行的流水線處理。確定要繼續嗎?",
"cancelConfirmButton": "確認中斷",
"cancelInProgress": "取消請求進行中...",
"pipelineNotRunning": "流水線未運行",
"cancelSuccess": "流水線中斷請求已發送",
"cancelFailed": "中斷流水線失敗\n{{error}}",
"cancelNotBusy": "流水線未運行,無需中斷",
"errors": {
"fetchFailed": "獲取流水線狀態失敗\n{{error}}"
}
}
},
"graphPanel": {
"dataIsTruncated": "圖資料已截斷至最大回傳節點數",
"statusDialog": {
"title": "LightRAG 伺服器設定",
"description": "查看目前系統狀態和連線資訊"
},
"legend": "圖例",
"nodeTypes": {
"person": "人物角色",
"category": "分類",
"geo": "地理名稱",
"location": "位置",
"organization": "組織機構",
"event": "事件",
"equipment": "設備",
"weapon": "武器",
"animal": "動物",
"unknown": "未知",
"object": "物品",
"group": "群組",
"technology": "技術",
"product": "產品",
"document": "文檔",
"content": "內容",
"data": "資料",
"artifact": "人工製品",
"concept": "概念",
"naturalobject": "自然物品",
"method": "方法",
"creature": "生物神怪",
"plant": "植物",
"disease": "疾病",
"drug": "藥物",
"food": "食物",
"other": "其他"
},
"sideBar": {
"settings": {
"settings": "設定",
"healthCheck": "健康檢查",
"showPropertyPanel": "顯示屬性面板",
"showSearchBar": "顯示搜尋列",
"showNodeLabel": "顯示節點標籤",
"nodeDraggable": "節點可拖曳",
"showEdgeLabel": "顯示 Edge 標籤",
"hideUnselectedEdges": "隱藏未選取的 Edge",
"edgeEvents": "Edge 事件",
"maxQueryDepth": "最大查詢深度",
"maxNodes": "最大回傳節點數",
"maxLayoutIterations": "最大版面配置迭代次數",
"resetToDefault": "重設為預設值",
"edgeSizeRange": "Edge 粗細範圍",
"depth": "深度",
"max": "最大值",
"degree": "鄰邊",
"apiKey": "API key",
"enterYourAPIkey": "輸入您的 API key",
"save": "儲存",
"refreshLayout": "重新整理版面配置"
},
"zoomControl": {
"zoomIn": "放大",
"zoomOut": "縮小",
"resetZoom": "重設縮放",
"rotateCamera": "順時針旋轉圖形",
"rotateCameraCounterClockwise": "逆時針旋轉圖形"
},
"layoutsControl": {
"startAnimation": "繼續版面配置動畫",
"stopAnimation": "停止版面配置動畫",
"layoutGraph": "圖形版面配置",
"layouts": {
"Circular": "環形",
"Circlepack": "圓形打包",
"Random": "隨機",
"Noverlaps": "無重疊",
"Force Directed": "力導向",
"Force Atlas": "力圖"
}
},
"fullScreenControl": {
"fullScreen": "全螢幕",
"windowed": "視窗"
},
"legendControl": {
"toggleLegend": "切換圖例顯示"
}
},
"statusIndicator": {
"connected": "已連線",
"disconnected": "未連線"
},
"statusCard": {
"unavailable": "狀態資訊不可用",
"serverInfo": "伺服器資訊",
"workingDirectory": "工作目錄",
"inputDirectory": "輸入目錄",
"maxParallelInsert": "並行處理文档",
"summarySettings": "摘要設定",
"llmConfig": "LLM 設定",
"llmBinding": "LLM 綁定",
"llmBindingHost": "LLM 端點",
"llmModel": "LLM 模型",
"embeddingConfig": "嵌入設定",
"embeddingBinding": "嵌入綁定",
"embeddingBindingHost": "嵌入端點",
"embeddingModel": "嵌入模型",
"storageConfig": "儲存設定",
"kvStorage": "KV 儲存",
"docStatusStorage": "文件狀態儲存",
"graphStorage": "圖形儲存",
"vectorStorage": "向量儲存",
"workspace": "工作空間",
"maxGraphNodes": "最大圖形節點數",
"rerankerConfig": "重排序設定",
"rerankerBindingHost": "重排序端點",
"rerankerModel": "重排序模型",
"lockStatus": "鎖定狀態",
"threshold": "閾值"
},
"propertiesView": {
"editProperty": "編輯{{property}}",
"editPropertyDescription": "在下方文字區域編輯屬性值。",
"errors": {
"duplicateName": "節點名稱已存在",
"updateFailed": "更新節點失敗",
"tryAgainLater": "請稍後重試",
"updateSuccessButMergeFailed": "屬性已更新,但合併失敗:{{error}}",
"mergeFailed": "合併失敗:{{error}}"
},
"success": {
"entityUpdated": "節點更新成功",
"relationUpdated": "關係更新成功",
"entityMerged": "節點合併成功"
},
"mergeOptionLabel": "遇到重名時自動合併",
"mergeOptionDescription": "勾選後,重新命名為既有名稱時會自動將當前節點合併過去,不再報錯。",
"mergeDialog": {
"title": "節點已合併",
"description": "\"{{source}}\" 已合併到 \"{{target}}\"。",
"refreshHint": "請重新整理圖譜以取得最新結構。",
"keepCurrentStart": "重新整理並保留目前的起始節點",
"useMergedStart": "重新整理並以合併後的節點為起始節點",
"refreshing": "正在重新整理圖譜..."
},
"node": {
"title": "節點",
"id": "ID",
"labels": "標籤",
"degree": "度數",
"properties": "屬性",
"relationships": "關係(子圖內)",
"expandNode": "展開節點",
"pruneNode": "修剪節點",
"deleteAllNodesError": "拒絕刪除圖中的所有節點",
"nodesRemoved": "已刪除 {{count}} 個節點,包括孤立節點",
"noNewNodes": "沒有發現可以展開的節點",
"propertyNames": {
"description": "描述",
"entity_id": "名稱",
"entity_type": "類型",
"source_id": "C-ID",
"Neighbour": "鄰接",
"file_path": "檔案",
"keywords": "Keys",
"weight": "權重"
}
},
"edge": {
"title": "關係",
"id": "ID",
"type": "類型",
"source": "來源節點",
"target": "目標節點",
"properties": "屬性"
}
},
"search": {
"placeholder": "頁面內搜尋節點...",
"message": "還有 {{count}} 個"
},
"graphLabels": {
"selectTooltip": "獲取節點(標籤)子圖",
"noLabels": "未找到匹配的節點",
"label": "搜尋節點名稱",
"placeholder": "搜尋節點名稱...",
"andOthers": "還有 {{count}} 個",
"refreshGlobalTooltip": "重新整理全圖資料和重置搜尋歷史",
"refreshCurrentLabelTooltip": "重新整理目前頁面圖形資料",
"refreshingTooltip": "正在重新整理資料..."
},
"emptyGraph": "無數據(請重載圖形數據)"
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "複製到剪貼簿",
"copyError": "複製文字到剪貼簿失敗",
"copyEmpty": "沒有內容可複製",
"copySuccess": "內容已複製到剪貼簿",
"copySuccessLegacy": "內容已複製(傳統方法)",
"copySuccessManual": "內容已複製(手動方法)",
"copyFailed": "複製內容失敗",
"copyManualInstruction": "請手動選取並複製文字",
"thinking": "正在思考...",
"thinkingTime": "思考用時 {{time}} 秒",
"thinkingInProgress": "思考進行中..."
},
"retrieval": {
"startPrompt": "輸入查詢開始檢索",
"clear": "清空",
"send": "送出",
"placeholder": "輸入查詢內容 (支援模式前綴:/<Query Mode>)",
"error": "錯誤:取得回應失敗",
"queryModeError": "僅支援以下查詢模式:{{modes}}",
"queryModePrefixInvalid": "無效的查詢模式前綴。請使用:/<模式> [空格] 查詢內容"
},
"querySettings": {
"parametersTitle": "參數",
"parametersDescription": "設定查詢參數",
"queryMode": "查詢模式",
"queryModeTooltip": "選擇檢索策略:\n• Naive:傳統文字塊向量檢索\n• Local:側重實體檢索\n• Global:側重關係檢索\n• HybridLocal+Global\n• MixLocal+Global+Naive\n• Bypass:跳過檢索,把歷史會話與當前問題送LLM",
"queryModeOptions": {
"naive": "Naive",
"local": "Local",
"global": "Global",
"hybrid": "Hybrid",
"mix": "Mix",
"bypass": "Bypass"
},
"responseFormat": "回應格式",
"responseFormatTooltip": "定義回應格式。例如:\n• 多段落\n• 單段落\n• 重點",
"responseFormatOptions": {
"multipleParagraphs": "多段落",
"singleParagraph": "單段落",
"bulletPoints": "重點"
},
"topK": "知識圖譜 Top K",
"topKTooltip": "實體關係檢索數量,適用於非 naive 模式。",
"topKPlaceholder": "輸入 top_k 值",
"chunkTopK": "文本區塊 Top K",
"chunkTopKTooltip": "文本區塊檢索數量,適用於所有模式。",
"chunkTopKPlaceholder": "輸入文本區塊 chunk_top_k 值",
"historyTurns": "歷史輪次",
"historyTurnsTooltip": "回應上下文中考慮的完整對話輪次(使用者-助手對)數量",
"historyTurnsPlaceholder": "歷史輪次數",
"onlyNeedContext": "僅需上下文",
"onlyNeedContextTooltip": "如果為True,僅回傳檢索到的上下文而不產生回應",
"onlyNeedPrompt": "僅需提示",
"onlyNeedPromptTooltip": "如果為True,僅回傳產生的提示而不產生回應",
"streamResponse": "串流回應",
"streamResponseTooltip": "如果為True,啟用即時串流輸出回應",
"userPrompt": "附加輸出提示詞",
"userPromptTooltip": "向LLM提供額外的響應要求(與查詢內容無關,僅用於處理輸出)。",
"userPromptPlaceholder": "輸入自定義提示詞(可選)",
"enableRerank": "啟用重排",
"enableRerankTooltip": "為檢索到的文本塊啟用重排。如果為True但未配置重排模型,將發出警告。默認為True。",
"maxEntityTokens": "實體令牌數上限",
"maxEntityTokensTooltip": "統一令牌控制系統中分配給實體上下文的最大令牌數",
"maxRelationTokens": "關係令牌數上限",
"maxRelationTokensTooltip": "統一令牌控制系統中分配給關係上下文的最大令牌數",
"maxTotalTokens": "總令牌數上限",
"maxTotalTokensTooltip": "整個查詢上下文的最大總令牌預算(實體+關係+文檔塊+系統提示)"
}
},
"apiSite": {
"loading": "正在載入 API 文件..."
},
"apiKeyAlert": {
"title": "需要 API key",
"description": "請輸入您的 API key 以存取服務",
"placeholder": "請輸入 API key",
"save": "儲存"
},
"pagination": {
"showing": "顯示第 {{start}} 到 {{end}} 筆,共 {{total}} 筆記錄",
"page": "頁",
"pageSize": "每頁顯示",
"firstPage": "第一頁",
"prevPage": "上一頁",
"nextPage": "下一頁",
"lastPage": "最後一頁"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import AppRouter from './AppRouter'
import './i18n.ts';
import 'katex/dist/katex.min.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AppRouter />
</StrictMode>
)
@@ -0,0 +1,85 @@
import { NavigateFunction } from 'react-router-dom';
import { useAuthStore, useBackendState } from '@/stores/state';
import { useGraphStore } from '@/stores/graph';
import { useSettingsStore } from '@/stores/settings';
class NavigationService {
private navigate: NavigateFunction | null = null;
setNavigate(navigate: NavigateFunction) {
this.navigate = navigate;
}
/**
* Reset all application state to ensure a clean environment.
* This function should be called when:
* 1. User logs out
* 2. Authentication token expires
* 3. Direct access to login page
*
* @param preserveHistory If true, chat history will be preserved. Default is false.
*/
resetAllApplicationState(preserveHistory = false) {
console.log('Resetting all application state...');
// Reset graph state
const graphStore = useGraphStore.getState();
const sigma = graphStore.sigmaInstance;
graphStore.reset();
graphStore.setGraphDataFetchAttempted(false);
graphStore.setLabelsFetchAttempted(false);
graphStore.setSigmaInstance(null);
graphStore.setIsFetching(false); // Reset isFetching state to prevent data loading issues
// Reset backend state
useBackendState.getState().clear();
// Reset retrieval history message only if preserveHistory is false
if (!preserveHistory) {
useSettingsStore.getState().setRetrievalHistory([]);
}
// Clear authentication state
sessionStorage.clear();
if (sigma) {
sigma.getGraph().clear();
sigma.kill();
useGraphStore.getState().setSigmaInstance(null);
}
}
/**
* Navigate to login page and reset application state
*/
navigateToLogin() {
if (!this.navigate) {
console.error('Navigation function not set');
return;
}
// Store current username before logout for comparison during next login
const currentUsername = useAuthStore.getState().username;
if (currentUsername) {
localStorage.setItem('LIGHTRAG-PREVIOUS-USER', currentUsername);
}
// Reset application state but preserve history
// History will be cleared on next login if the user changes
this.resetAllApplicationState(true);
useAuthStore.getState().logout();
this.navigate('/login');
}
navigateToHome() {
if (!this.navigate) {
console.error('Navigation function not set');
return;
}
this.navigate('/');
}
}
export const navigationService = new NavigationService();
+382
View File
@@ -0,0 +1,382 @@
import { create } from 'zustand'
import { createSelectors } from '@/lib/utils'
import { DirectedGraph } from 'graphology'
import MiniSearch from 'minisearch'
import { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor'
export type RawNodeType = {
// for NetworkX: id is identical to properties['entity_id']
// for Neo4j: id is unique identifier for each node
id: string
labels: string[]
properties: Record<string, any>
size: number
x: number
y: number
color: string
degree: number
}
export type RawEdgeType = {
// for NetworkX: id is "source-target"
// for Neo4j: id is unique identifier for each edge
id: string
source: string
target: string
type?: string
properties: Record<string, any>
// dynamicId: key for sigmaGraph
dynamicId: string
}
/**
* Interface for tracking edges that need updating when a node ID changes
*/
interface EdgeToUpdate {
originalDynamicId: string
newEdgeId: string
edgeIndex: number
}
export class RawGraph {
nodes: RawNodeType[] = []
edges: RawEdgeType[] = []
// nodeIDMap: map node id to index in nodes array (SigmaGraph has nodeId as key)
nodeIdMap: Record<string, number> = {}
// edgeIDMap: map edge id to index in edges array (SigmaGraph not use id as key)
edgeIdMap: Record<string, number> = {}
// edgeDynamicIdMap: map edge dynamic id to index in edges array (SigmaGraph has DynamicId as key)
edgeDynamicIdMap: Record<string, number> = {}
getNode = (nodeId: string) => {
const nodeIndex = this.nodeIdMap[nodeId]
if (nodeIndex !== undefined) {
return this.nodes[nodeIndex]
}
return undefined
}
getEdge = (edgeId: string, dynamicId: boolean = true) => {
const edgeIndex = dynamicId ? this.edgeDynamicIdMap[edgeId] : this.edgeIdMap[edgeId]
if (edgeIndex !== undefined) {
return this.edges[edgeIndex]
}
return undefined
}
buildDynamicMap = () => {
this.edgeDynamicIdMap = {}
for (let i = 0; i < this.edges.length; i++) {
const edge = this.edges[i]
this.edgeDynamicIdMap[edge.dynamicId] = i
}
}
}
interface GraphState {
selectedNode: string | null
focusedNode: string | null
selectedEdge: string | null
focusedEdge: string | null
rawGraph: RawGraph | null
sigmaGraph: DirectedGraph | null
sigmaInstance: any | null
searchEngine: MiniSearch | null
moveToSelectedNode: boolean
isFetching: boolean
graphIsEmpty: boolean
lastSuccessfulQueryLabel: string
typeColorMap: Map<string, string>
// Global flags to track data fetching attempts
graphDataFetchAttempted: boolean
labelsFetchAttempted: boolean
setSigmaInstance: (instance: any) => void
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void
setFocusedNode: (nodeId: string | null) => void
setSelectedEdge: (edgeId: string | null) => void
setFocusedEdge: (edgeId: string | null) => void
clearSelection: () => void
reset: () => void
setMoveToSelectedNode: (moveToSelectedNode: boolean) => void
setGraphIsEmpty: (isEmpty: boolean) => void
setLastSuccessfulQueryLabel: (label: string) => void
setRawGraph: (rawGraph: RawGraph | null) => void
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
setIsFetching: (isFetching: boolean) => void
// Legend color mapping methods
setTypeColorMap: (typeColorMap: Map<string, string>) => void
// Search engine methods
setSearchEngine: (engine: MiniSearch | null) => void
resetSearchEngine: () => void
// Methods to set global flags
setGraphDataFetchAttempted: (attempted: boolean) => void
setLabelsFetchAttempted: (attempted: boolean) => void
// Event trigger methods for node operations
triggerNodeExpand: (nodeId: string | null) => void
triggerNodePrune: (nodeId: string | null) => void
// Node operation state
nodeToExpand: string | null
nodeToPrune: string | null
// Version counter to trigger data refresh
graphDataVersion: number
incrementGraphDataVersion: () => void
// Methods for updating graph elements and UI state together
updateNodeAndSelect: (nodeId: string, entityId: string, propertyName: string, newValue: string) => Promise<void>
updateEdgeAndSelect: (edgeId: string, dynamicId: string, sourceId: string, targetId: string, propertyName: string, newValue: string) => Promise<void>
}
const useGraphStoreBase = create<GraphState>()((set, get) => ({
selectedNode: null,
focusedNode: null,
selectedEdge: null,
focusedEdge: null,
moveToSelectedNode: false,
isFetching: false,
graphIsEmpty: false,
lastSuccessfulQueryLabel: '', // Initialize as empty to ensure fetchAllDatabaseLabels runs on first query
// Initialize global flags
graphDataFetchAttempted: false,
labelsFetchAttempted: false,
rawGraph: null,
sigmaGraph: null,
sigmaInstance: null,
typeColorMap: new Map<string, string>(),
searchEngine: null,
setGraphIsEmpty: (isEmpty: boolean) => set({ graphIsEmpty: isEmpty }),
setLastSuccessfulQueryLabel: (label: string) => set({ lastSuccessfulQueryLabel: label }),
setIsFetching: (isFetching: boolean) => set({ isFetching }),
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) =>
set({ selectedNode: nodeId, moveToSelectedNode }),
setFocusedNode: (nodeId: string | null) => set({ focusedNode: nodeId }),
setSelectedEdge: (edgeId: string | null) => set({ selectedEdge: edgeId }),
setFocusedEdge: (edgeId: string | null) => set({ focusedEdge: edgeId }),
clearSelection: () =>
set({
selectedNode: null,
focusedNode: null,
selectedEdge: null,
focusedEdge: null
}),
reset: () => {
set({
selectedNode: null,
focusedNode: null,
selectedEdge: null,
focusedEdge: null,
rawGraph: null,
sigmaGraph: null, // to avoid other components from acccessing graph objects
searchEngine: null,
moveToSelectedNode: false,
graphIsEmpty: false
});
},
setRawGraph: (rawGraph: RawGraph | null) =>
set({
rawGraph
}),
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => {
// Replace graph instance, no need to keep WebGL context
set({ sigmaGraph });
},
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode }),
setSigmaInstance: (instance: any) => set({ sigmaInstance: instance }),
setTypeColorMap: (typeColorMap: Map<string, string>) => set({ typeColorMap }),
setSearchEngine: (engine: MiniSearch | null) => set({ searchEngine: engine }),
resetSearchEngine: () => set({ searchEngine: null }),
// Methods to set global flags
setGraphDataFetchAttempted: (attempted: boolean) => set({ graphDataFetchAttempted: attempted }),
setLabelsFetchAttempted: (attempted: boolean) => set({ labelsFetchAttempted: attempted }),
// Node operation state
nodeToExpand: null,
nodeToPrune: null,
// Event trigger methods for node operations
triggerNodeExpand: (nodeId: string | null) => set({ nodeToExpand: nodeId }),
triggerNodePrune: (nodeId: string | null) => set({ nodeToPrune: nodeId }),
// Version counter implementation
graphDataVersion: 0,
incrementGraphDataVersion: () => set((state) => ({ graphDataVersion: state.graphDataVersion + 1 })),
// Methods for updating graph elements and UI state together
updateNodeAndSelect: async (nodeId: string, entityId: string, propertyName: string, newValue: string) => {
// Get current state
const state = get()
const { sigmaGraph, rawGraph } = state
// Validate graph state
if (!sigmaGraph || !rawGraph || !sigmaGraph.hasNode(nodeId)) {
return
}
try {
const nodeAttributes = sigmaGraph.getNodeAttributes(nodeId)
console.log('updateNodeAndSelect', nodeId, entityId, propertyName, newValue)
// For entity_id changes (node renaming) with raw graph storage
if ((nodeId === entityId) && (propertyName === 'entity_id')) {
// Create new node with updated ID but same attributes
sigmaGraph.addNode(newValue, { ...nodeAttributes, label: newValue })
const edgesToUpdate: EdgeToUpdate[] = []
// Process all edges connected to this node
sigmaGraph.forEachEdge(nodeId, (edge, attributes, source, target) => {
const otherNode = source === nodeId ? target : source
const isOutgoing = source === nodeId
// Get original edge dynamic ID for later reference
const originalEdgeDynamicId = edge
const edgeIndexInRawGraph = rawGraph.edgeDynamicIdMap[originalEdgeDynamicId]
// Create new edge with updated node reference
const newEdgeId = sigmaGraph.addEdge(
isOutgoing ? newValue : otherNode,
isOutgoing ? otherNode : newValue,
attributes
)
// Track edges that need updating in the raw graph
if (edgeIndexInRawGraph !== undefined) {
edgesToUpdate.push({
originalDynamicId: originalEdgeDynamicId,
newEdgeId: newEdgeId,
edgeIndex: edgeIndexInRawGraph
})
}
// Remove the old edge
sigmaGraph.dropEdge(edge)
})
// Remove the old node after all edges are processed
sigmaGraph.dropNode(nodeId)
// Update node reference in raw graph data
const nodeIndex = rawGraph.nodeIdMap[nodeId]
if (nodeIndex !== undefined) {
rawGraph.nodes[nodeIndex].id = newValue
rawGraph.nodes[nodeIndex].labels = [newValue]
rawGraph.nodes[nodeIndex].properties.entity_id = newValue
delete rawGraph.nodeIdMap[nodeId]
rawGraph.nodeIdMap[newValue] = nodeIndex
}
// Update all edge references in raw graph data
edgesToUpdate.forEach(({ originalDynamicId, newEdgeId, edgeIndex }) => {
if (rawGraph.edges[edgeIndex]) {
// Update source/target references
if (rawGraph.edges[edgeIndex].source === nodeId) {
rawGraph.edges[edgeIndex].source = newValue
}
if (rawGraph.edges[edgeIndex].target === nodeId) {
rawGraph.edges[edgeIndex].target = newValue
}
// Update dynamic ID mappings
rawGraph.edges[edgeIndex].dynamicId = newEdgeId
delete rawGraph.edgeDynamicIdMap[originalDynamicId]
rawGraph.edgeDynamicIdMap[newEdgeId] = edgeIndex
}
})
// Update selected node in store
set({ selectedNode: newValue, moveToSelectedNode: true })
} else {
// For non-NetworkX nodes or non-entity_id changes
const nodeIndex = rawGraph.nodeIdMap[String(nodeId)]
if (nodeIndex !== undefined) {
const nodeRef = rawGraph.nodes[nodeIndex]
nodeRef.properties[propertyName] = newValue
if (propertyName === 'entity_id') {
nodeRef.labels = [newValue]
sigmaGraph.setNodeAttribute(String(nodeId), 'label', newValue)
}
if (propertyName === 'entity_type') {
const { color, map, updated } = resolveNodeColor(newValue, state.typeColorMap)
const resolvedColor = color || DEFAULT_NODE_COLOR
nodeRef.color = resolvedColor
sigmaGraph.setNodeAttribute(String(nodeId), 'color', resolvedColor)
if (updated) {
set({ typeColorMap: map })
}
}
}
// Trigger a re-render by incrementing the version counter
set((state) => ({ graphDataVersion: state.graphDataVersion + 1 }))
}
} catch (error) {
console.error('Error updating node in graph:', error)
throw new Error('Failed to update node in graph')
}
},
updateEdgeAndSelect: async (edgeId: string, dynamicId: string, sourceId: string, targetId: string, propertyName: string, newValue: string) => {
// Get current state
const state = get()
const { sigmaGraph, rawGraph } = state
// Validate graph state
if (!sigmaGraph || !rawGraph) {
return
}
try {
const edgeIndex = rawGraph.edgeIdMap[String(edgeId)]
if (edgeIndex !== undefined && rawGraph.edges[edgeIndex]) {
rawGraph.edges[edgeIndex].properties[propertyName] = newValue
if(dynamicId !== undefined && propertyName === 'keywords') {
sigmaGraph.setEdgeAttribute(dynamicId, 'label', newValue)
}
}
// Trigger a re-render by incrementing the version counter
set((state) => ({ graphDataVersion: state.graphDataVersion + 1 }))
// Update selected edge in store to ensure UI reflects changes
set({ selectedEdge: dynamicId })
} catch (error) {
console.error(`Error updating edge ${sourceId}->${targetId} in graph:`, error)
throw new Error('Failed to update edge in graph')
}
}
}))
const useGraphStore = createSelectors(useGraphStoreBase)
export { useGraphStore }
@@ -0,0 +1,352 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { createSelectors } from '@/lib/utils'
import { defaultQueryLabel } from '@/lib/constants'
import { Message, QueryRequest } from '@/api/lightrag'
type Theme = 'dark' | 'light' | 'system'
type Language = 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW'
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api'
interface SettingsState {
// Document manager settings
showFileName: boolean
setShowFileName: (show: boolean) => void
documentsPageSize: number
setDocumentsPageSize: (size: number) => void
// User prompt history
userPromptHistory: string[]
addUserPromptToHistory: (prompt: string) => void
setUserPromptHistory: (history: string[]) => void
// Graph viewer settings
showPropertyPanel: boolean
showNodeSearchBar: boolean
showLegend: boolean
setShowLegend: (show: boolean) => void
showNodeLabel: boolean
enableNodeDrag: boolean
showEdgeLabel: boolean
enableHideUnselectedEdges: boolean
enableEdgeEvents: boolean
minEdgeSize: number
setMinEdgeSize: (size: number) => void
maxEdgeSize: number
setMaxEdgeSize: (size: number) => void
graphQueryMaxDepth: number
setGraphQueryMaxDepth: (depth: number) => void
graphMaxNodes: number
setGraphMaxNodes: (nodes: number, triggerRefresh?: boolean) => void
backendMaxGraphNodes: number | null
setBackendMaxGraphNodes: (maxNodes: number | null) => void
graphLayoutMaxIterations: number
setGraphLayoutMaxIterations: (iterations: number) => void
// Retrieval settings
queryLabel: string
setQueryLabel: (queryLabel: string) => void
retrievalHistory: Message[]
setRetrievalHistory: (history: Message[]) => void
querySettings: Omit<QueryRequest, 'query'>
updateQuerySettings: (settings: Partial<QueryRequest>) => void
// Auth settings
apiKey: string | null
setApiKey: (key: string | null) => void
// App settings
theme: Theme
setTheme: (theme: Theme) => void
language: Language
setLanguage: (lang: Language) => void
enableHealthCheck: boolean
setEnableHealthCheck: (enable: boolean) => void
currentTab: Tab
setCurrentTab: (tab: Tab) => void
// Search label dropdown refresh trigger (non-persistent, runtime only)
searchLabelDropdownRefreshTrigger: number
triggerSearchLabelDropdownRefresh: () => void
}
const useSettingsStoreBase = create<SettingsState>()(
persist(
(set) => ({
theme: 'system',
language: 'en',
showPropertyPanel: true,
showNodeSearchBar: true,
showLegend: false,
showNodeLabel: true,
enableNodeDrag: true,
showEdgeLabel: false,
enableHideUnselectedEdges: true,
enableEdgeEvents: false,
minEdgeSize: 1,
maxEdgeSize: 1,
graphQueryMaxDepth: 3,
graphMaxNodes: 1000,
backendMaxGraphNodes: null,
graphLayoutMaxIterations: 15,
queryLabel: defaultQueryLabel,
enableHealthCheck: true,
apiKey: null,
currentTab: 'documents',
showFileName: false,
documentsPageSize: 10,
retrievalHistory: [],
userPromptHistory: [],
querySettings: {
mode: 'global',
top_k: 40,
chunk_top_k: 20,
max_entity_tokens: 6000,
max_relation_tokens: 8000,
max_total_tokens: 30000,
only_need_context: false,
only_need_prompt: false,
stream: true,
history_turns: 0,
user_prompt: '',
enable_rerank: true
},
setTheme: (theme: Theme) => set({ theme }),
setLanguage: (language: Language) => {
set({ language })
},
setGraphLayoutMaxIterations: (iterations: number) =>
set({
graphLayoutMaxIterations: iterations
}),
setQueryLabel: (queryLabel: string) =>
set({
queryLabel
}),
setGraphQueryMaxDepth: (depth: number) => set({ graphQueryMaxDepth: depth }),
setGraphMaxNodes: (nodes: number, triggerRefresh: boolean = false) => {
const state = useSettingsStore.getState();
if (state.graphMaxNodes === nodes) {
return;
}
if (triggerRefresh) {
const currentLabel = state.queryLabel;
// Atomically update both the node count and the query label to trigger a refresh.
set({ graphMaxNodes: nodes, queryLabel: '' });
// Restore the label after a short delay.
setTimeout(() => {
set({ queryLabel: currentLabel });
}, 300);
} else {
set({ graphMaxNodes: nodes });
}
},
setBackendMaxGraphNodes: (maxNodes: number | null) => set({ backendMaxGraphNodes: maxNodes }),
setMinEdgeSize: (size: number) => set({ minEdgeSize: size }),
setMaxEdgeSize: (size: number) => set({ maxEdgeSize: size }),
setEnableHealthCheck: (enable: boolean) => set({ enableHealthCheck: enable }),
setApiKey: (apiKey: string | null) => set({ apiKey }),
setCurrentTab: (tab: Tab) => set({ currentTab: tab }),
setRetrievalHistory: (history: Message[]) => set({ retrievalHistory: history }),
updateQuerySettings: (settings: Partial<QueryRequest>) => {
// Filter out history_turns to prevent changes, always keep it as 0
const filteredSettings = { ...settings }
delete filteredSettings.history_turns
set((state) => ({
querySettings: { ...state.querySettings, ...filteredSettings, history_turns: 0 }
}))
},
setShowFileName: (show: boolean) => set({ showFileName: show }),
setShowLegend: (show: boolean) => set({ showLegend: show }),
setDocumentsPageSize: (size: number) => set({ documentsPageSize: size }),
// User prompt history methods
addUserPromptToHistory: (prompt: string) => {
if (!prompt.trim()) return
set((state) => {
const newHistory = [...state.userPromptHistory]
// Remove existing occurrence if found
const existingIndex = newHistory.indexOf(prompt)
if (existingIndex !== -1) {
newHistory.splice(existingIndex, 1)
}
// Add to beginning
newHistory.unshift(prompt)
// Keep only last 12 items
if (newHistory.length > 12) {
newHistory.splice(12)
}
return { userPromptHistory: newHistory }
})
},
setUserPromptHistory: (history: string[]) => set({ userPromptHistory: history }),
// Search label dropdown refresh trigger (not persisted)
searchLabelDropdownRefreshTrigger: 0,
triggerSearchLabelDropdownRefresh: () =>
set((state) => ({
searchLabelDropdownRefreshTrigger: state.searchLabelDropdownRefreshTrigger + 1
}))
}),
{
name: 'settings-storage',
storage: createJSONStorage(() => localStorage),
version: 19,
migrate: (state: any, version: number) => {
if (version < 2) {
state.showEdgeLabel = false
}
if (version < 3) {
state.queryLabel = defaultQueryLabel
}
if (version < 4) {
state.showPropertyPanel = true
state.showNodeSearchBar = true
state.showNodeLabel = true
state.enableHealthCheck = true
state.apiKey = null
}
if (version < 5) {
state.currentTab = 'documents'
}
if (version < 6) {
state.querySettings = {
mode: 'global',
response_type: 'Multiple Paragraphs',
top_k: 10,
max_token_for_text_unit: 4000,
max_token_for_global_context: 4000,
max_token_for_local_context: 4000,
only_need_context: false,
only_need_prompt: false,
stream: true,
history_turns: 0,
hl_keywords: [],
ll_keywords: []
}
state.retrievalHistory = []
}
if (version < 7) {
state.graphQueryMaxDepth = 3
state.graphLayoutMaxIterations = 15
}
if (version < 8) {
state.graphMinDegree = 0
state.language = 'en'
}
if (version < 9) {
state.showFileName = false
}
if (version < 10) {
delete state.graphMinDegree // 删除废弃参数
state.graphMaxNodes = 1000 // 添加新参数
}
if (version < 11) {
state.minEdgeSize = 1
state.maxEdgeSize = 1
}
if (version < 12) {
// Clear retrieval history to avoid compatibility issues with MessageWithError type
state.retrievalHistory = []
}
if (version < 13) {
// Add user_prompt field for older versions
if (state.querySettings) {
state.querySettings.user_prompt = ''
}
}
if (version < 14) {
// Add backendMaxGraphNodes field for older versions
state.backendMaxGraphNodes = null
}
if (version < 15) {
// Add new querySettings
state.querySettings = {
...state.querySettings,
mode: 'mix',
response_type: 'Multiple Paragraphs',
top_k: 40,
chunk_top_k: 10,
max_entity_tokens: 10000,
max_relation_tokens: 10000,
max_total_tokens: 32000,
enable_rerank: true,
history_turns: 0,
}
}
if (version < 16) {
// Add documentsPageSize field for older versions
state.documentsPageSize = 10
}
if (version < 17) {
// Force history_turns to 0 for all users
if (state.querySettings) {
state.querySettings.history_turns = 0
}
}
if (version < 18) {
// Add userPromptHistory field for older versions
state.userPromptHistory = []
}
if (version < 19) {
// Remove deprecated response_type parameter
if (state.querySettings) {
delete state.querySettings.response_type
}
}
return state
}
}
)
)
const useSettingsStore = createSelectors(useSettingsStoreBase)
export { useSettingsStore, type Theme }
+316
View File
@@ -0,0 +1,316 @@
import { create } from 'zustand'
import { createSelectors } from '@/lib/utils'
import { checkHealth, LightragStatus } from '@/api/lightrag'
import { useSettingsStore } from './settings'
import { healthCheckInterval } from '@/lib/constants'
interface BackendState {
health: boolean
message: string | null
messageTitle: string | null
status: LightragStatus | null
lastCheckTime: number
pipelineBusy: boolean
healthCheckIntervalId: ReturnType<typeof setInterval> | null
healthCheckFunction: (() => void) | null
healthCheckIntervalValue: number
check: () => Promise<boolean>
clear: () => void
setErrorMessage: (message: string, messageTitle: string) => void
setPipelineBusy: (busy: boolean) => void
setHealthCheckFunction: (fn: () => void) => void
resetHealthCheckTimer: () => void
resetHealthCheckTimerDelayed: (delayMs: number) => void
clearHealthCheckTimer: () => void
}
interface AuthState {
isAuthenticated: boolean;
isGuestMode: boolean; // Add guest mode flag
coreVersion: string | null;
apiVersion: string | null;
username: string | null; // login username
webuiTitle: string | null; // Custom title
webuiDescription: string | null; // Title description
login: (token: string, isGuest?: boolean, coreVersion?: string | null, apiVersion?: string | null, webuiTitle?: string | null, webuiDescription?: string | null) => void;
logout: () => void;
setVersion: (coreVersion: string | null, apiVersion: string | null) => void;
setCustomTitle: (webuiTitle: string | null, webuiDescription: string | null) => void;
}
const useBackendStateStoreBase = create<BackendState>()((set, get) => ({
health: true,
message: null,
messageTitle: null,
lastCheckTime: Date.now(),
status: null,
pipelineBusy: false,
healthCheckIntervalId: null,
healthCheckFunction: null,
healthCheckIntervalValue: healthCheckInterval * 1000, // Use constant from lib/constants
check: async () => {
const health = await checkHealth()
if (health.status === 'healthy') {
// Update version information if health check returns it
if (health.core_version || health.api_version) {
useAuthStore.getState().setVersion(
health.core_version || null,
health.api_version || null
);
}
// Update custom title information if health check returns it
if ('webui_title' in health || 'webui_description' in health) {
useAuthStore.getState().setCustomTitle(
'webui_title' in health ? (health.webui_title ?? null) : null,
'webui_description' in health ? (health.webui_description ?? null) : null
);
}
// Extract and store backend max graph nodes limit
if (health.configuration?.max_graph_nodes) {
const maxNodes = parseInt(health.configuration.max_graph_nodes, 10)
if (!isNaN(maxNodes) && maxNodes > 0) {
const currentBackendMaxNodes = useSettingsStore.getState().backendMaxGraphNodes
// Only update if the backend limit has actually changed
if (currentBackendMaxNodes !== maxNodes) {
useSettingsStore.getState().setBackendMaxGraphNodes(maxNodes)
// Auto-adjust current graphMaxNodes if it exceeds the new backend limit
const currentMaxNodes = useSettingsStore.getState().graphMaxNodes
if (currentMaxNodes > maxNodes) {
useSettingsStore.getState().setGraphMaxNodes(maxNodes, true)
}
}
}
}
set({
health: true,
message: null,
messageTitle: null,
lastCheckTime: Date.now(),
status: health,
pipelineBusy: health.pipeline_busy
})
return true
}
set({
health: false,
message: health.message,
messageTitle: 'Backend Health Check Error!',
lastCheckTime: Date.now(),
status: null
})
return false
},
clear: () => {
set({ health: true, message: null, messageTitle: null })
},
setErrorMessage: (message: string, messageTitle: string) => {
set({ health: false, message, messageTitle })
},
setPipelineBusy: (busy: boolean) => {
set({ pipelineBusy: busy })
},
setHealthCheckFunction: (fn: () => void) => {
set({ healthCheckFunction: fn })
},
resetHealthCheckTimer: () => {
const { healthCheckIntervalId, healthCheckFunction, healthCheckIntervalValue } = get()
if (healthCheckIntervalId) {
clearInterval(healthCheckIntervalId)
}
if (healthCheckFunction) {
healthCheckFunction() // run health check immediately
const newIntervalId = setInterval(healthCheckFunction, healthCheckIntervalValue)
set({ healthCheckIntervalId: newIntervalId })
}
},
resetHealthCheckTimerDelayed: (delayMs: number) => {
setTimeout(() => {
get().resetHealthCheckTimer()
}, delayMs)
},
clearHealthCheckTimer: () => {
const { healthCheckIntervalId } = get()
if (healthCheckIntervalId) {
clearInterval(healthCheckIntervalId)
set({ healthCheckIntervalId: null })
}
}
}))
const useBackendState = createSelectors(useBackendStateStoreBase)
export { useBackendState }
const parseTokenPayload = (token: string): { sub?: string; role?: string } => {
try {
// JWT tokens are in the format: header.payload.signature
const parts = token.split('.');
if (parts.length !== 3) return {};
const payload = JSON.parse(atob(parts[1]));
return payload;
} catch (e) {
console.error('Error parsing token payload:', e);
return {};
}
};
const getUsernameFromToken = (token: string): string | null => {
const payload = parseTokenPayload(token);
return payload.sub || null;
};
const isGuestToken = (token: string): boolean => {
const payload = parseTokenPayload(token);
return payload.role === 'guest';
};
const initAuthState = (): { isAuthenticated: boolean; isGuestMode: boolean; coreVersion: string | null; apiVersion: string | null; username: string | null; webuiTitle: string | null; webuiDescription: string | null } => {
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
const coreVersion = localStorage.getItem('LIGHTRAG-CORE-VERSION');
const apiVersion = localStorage.getItem('LIGHTRAG-API-VERSION');
const webuiTitle = localStorage.getItem('LIGHTRAG-WEBUI-TITLE');
const webuiDescription = localStorage.getItem('LIGHTRAG-WEBUI-DESCRIPTION');
const username = token ? getUsernameFromToken(token) : null;
if (!token) {
return {
isAuthenticated: false,
isGuestMode: false,
coreVersion: coreVersion,
apiVersion: apiVersion,
username: null,
webuiTitle: webuiTitle,
webuiDescription: webuiDescription,
};
}
return {
isAuthenticated: true,
isGuestMode: isGuestToken(token),
coreVersion: coreVersion,
apiVersion: apiVersion,
username: username,
webuiTitle: webuiTitle,
webuiDescription: webuiDescription,
};
};
export const useAuthStore = create<AuthState>(set => {
// Get initial state from localStorage
const initialState = initAuthState();
return {
isAuthenticated: initialState.isAuthenticated,
isGuestMode: initialState.isGuestMode,
coreVersion: initialState.coreVersion,
apiVersion: initialState.apiVersion,
username: initialState.username,
webuiTitle: initialState.webuiTitle,
webuiDescription: initialState.webuiDescription,
login: (token, isGuest = false, coreVersion = null, apiVersion = null, webuiTitle = null, webuiDescription = null) => {
localStorage.setItem('LIGHTRAG-API-TOKEN', token);
if (coreVersion) {
localStorage.setItem('LIGHTRAG-CORE-VERSION', coreVersion);
}
if (apiVersion) {
localStorage.setItem('LIGHTRAG-API-VERSION', apiVersion);
}
if (webuiTitle) {
localStorage.setItem('LIGHTRAG-WEBUI-TITLE', webuiTitle);
} else {
localStorage.removeItem('LIGHTRAG-WEBUI-TITLE');
}
if (webuiDescription) {
localStorage.setItem('LIGHTRAG-WEBUI-DESCRIPTION', webuiDescription);
} else {
localStorage.removeItem('LIGHTRAG-WEBUI-DESCRIPTION');
}
const username = getUsernameFromToken(token);
set({
isAuthenticated: true,
isGuestMode: isGuest,
username: username,
coreVersion: coreVersion,
apiVersion: apiVersion,
webuiTitle: webuiTitle,
webuiDescription: webuiDescription,
});
},
logout: () => {
localStorage.removeItem('LIGHTRAG-API-TOKEN');
const coreVersion = localStorage.getItem('LIGHTRAG-CORE-VERSION');
const apiVersion = localStorage.getItem('LIGHTRAG-API-VERSION');
const webuiTitle = localStorage.getItem('LIGHTRAG-WEBUI-TITLE');
const webuiDescription = localStorage.getItem('LIGHTRAG-WEBUI-DESCRIPTION');
set({
isAuthenticated: false,
isGuestMode: false,
username: null,
coreVersion: coreVersion,
apiVersion: apiVersion,
webuiTitle: webuiTitle,
webuiDescription: webuiDescription,
});
},
setVersion: (coreVersion, apiVersion) => {
// Update localStorage
if (coreVersion) {
localStorage.setItem('LIGHTRAG-CORE-VERSION', coreVersion);
}
if (apiVersion) {
localStorage.setItem('LIGHTRAG-API-VERSION', apiVersion);
}
// Update state
set({
coreVersion: coreVersion,
apiVersion: apiVersion
});
},
setCustomTitle: (webuiTitle, webuiDescription) => {
// Update localStorage
if (webuiTitle) {
localStorage.setItem('LIGHTRAG-WEBUI-TITLE', webuiTitle);
} else {
localStorage.removeItem('LIGHTRAG-WEBUI-TITLE');
}
if (webuiDescription) {
localStorage.setItem('LIGHTRAG-WEBUI-DESCRIPTION', webuiDescription);
} else {
localStorage.removeItem('LIGHTRAG-WEBUI-DESCRIPTION');
}
// Update state
set({
webuiTitle: webuiTitle,
webuiDescription: webuiDescription
});
}
};
});
+1
View File
@@ -0,0 +1 @@
declare module 'katex/contrib/mhchem';
@@ -0,0 +1,259 @@
import { searchHistoryMaxItems, searchHistoryVersion } from '@/lib/constants'
/**
* SearchHistoryManager - Manages search history persistence in localStorage
*
* This utility class handles:
* - Storing and retrieving search history from localStorage
* - Managing history size limits
* - Sorting by access time and frequency
* - Version compatibility
*/
export interface SearchHistoryItem {
label: string // Label name
lastAccessed: number // Last access timestamp
accessCount: number // Access count for sorting optimization
}
export interface SearchHistoryData {
items: SearchHistoryItem[]
version: string // Data version for compatibility
workspace?: string // Workspace isolation (if needed)
}
export class SearchHistoryManager {
private static readonly STORAGE_KEY = 'lightrag_search_history'
private static readonly MAX_HISTORY = searchHistoryMaxItems
private static readonly VERSION = searchHistoryVersion
/**
* Get search history from localStorage
* @returns Array of search history items sorted by last accessed time (descending)
*/
static getHistory(): SearchHistoryItem[] {
try {
const data = localStorage.getItem(this.STORAGE_KEY)
if (!data) return []
const parsed: SearchHistoryData = JSON.parse(data)
// Version compatibility check
if (parsed.version !== this.VERSION) {
console.warn(`Search history version mismatch. Expected ${this.VERSION}, got ${parsed.version}. Clearing history.`)
this.clearHistory()
return []
}
// Ensure items is an array
if (!Array.isArray(parsed.items)) {
console.warn('Invalid search history format. Clearing history.')
this.clearHistory()
return []
}
// Sort by last accessed time (descending) then by access count (descending)
return parsed.items.sort((a, b) => {
if (b.lastAccessed !== a.lastAccessed) {
return b.lastAccessed - a.lastAccessed
}
return (b.accessCount || 0) - (a.accessCount || 0)
})
} catch (error) {
console.error('Error reading search history:', error)
this.clearHistory()
return []
}
}
/**
* Add a label to search history (or update if exists)
* @param label Label to add to history
*/
static addToHistory(label: string): void {
if (!label || typeof label !== 'string' || label.trim() === '') {
return
}
try {
const history = this.getHistory()
const now = Date.now()
const trimmedLabel = label.trim()
// Find existing item
const existingIndex = history.findIndex(item => item.label === trimmedLabel)
if (existingIndex >= 0) {
// Update existing item
const existingItem = history[existingIndex]
existingItem.lastAccessed = now
existingItem.accessCount = (existingItem.accessCount || 0) + 1
// Move to front (will be sorted properly when saved)
history.splice(existingIndex, 1)
history.unshift(existingItem)
} else {
// Add new item to the beginning
history.unshift({
label: trimmedLabel,
lastAccessed: now,
accessCount: 1
})
}
// Limit history size
if (history.length > this.MAX_HISTORY) {
history.splice(this.MAX_HISTORY)
}
// Save to localStorage
const data: SearchHistoryData = {
items: history,
version: this.VERSION
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
} catch (error) {
console.error('Error saving search history:', error)
}
}
/**
* Clear all search history
*/
static clearHistory(): void {
try {
localStorage.removeItem(this.STORAGE_KEY)
} catch (error) {
console.error('Error clearing search history:', error)
}
}
/**
* Initialize history with default popular labels if empty
* @param popularLabels Array of popular labels to use as defaults
*/
static async initializeWithDefaults(popularLabels: string[]): Promise<void> {
const history = this.getHistory()
if (history.length === 0 && popularLabels.length > 0) {
try {
const now = Date.now()
const defaultItems: SearchHistoryItem[] = popularLabels.map((label, index) => ({
label: label.trim(),
lastAccessed: now - index, // Ensure proper ordering
accessCount: 0 // Mark as default/popular items
}))
const data: SearchHistoryData = {
items: defaultItems,
version: this.VERSION
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
} catch (error) {
console.error('Error initializing search history with defaults:', error)
}
}
}
/**
* Get recent searches (items with accessCount > 0)
* @param limit Maximum number of recent searches to return
* @returns Array of recent search items
*/
static getRecentSearches(limit: number = 10): SearchHistoryItem[] {
const history = this.getHistory()
return history
.filter(item => item.accessCount > 0)
.slice(0, limit)
}
/**
* Get popular recommendations (items with accessCount = 0, i.e., defaults)
* @param limit Maximum number of recommendations to return
* @returns Array of popular recommendation items
*/
static getPopularRecommendations(limit?: number): SearchHistoryItem[] {
const history = this.getHistory()
const recommendations = history.filter(item => item.accessCount === 0)
return limit ? recommendations.slice(0, limit) : recommendations
}
/**
* Get all history items as simple string array
* @param limit Maximum number of items to return
* @returns Array of label strings
*/
static getHistoryLabels(limit?: number): string[] {
const history = this.getHistory()
const labels = history.map(item => item.label)
return limit ? labels.slice(0, limit) : labels
}
/**
* Check if a label exists in history
* @param label Label to check
* @returns True if label exists in history
*/
static hasLabel(label: string): boolean {
if (!label || typeof label !== 'string') return false
const history = this.getHistory()
return history.some(item => item.label === label.trim())
}
/**
* Remove a specific label from history
* @param label Label to remove
*/
static removeLabel(label: string): void {
if (!label || typeof label !== 'string') return
try {
const history = this.getHistory()
const trimmedLabel = label.trim()
const filteredHistory = history.filter(item => item.label !== trimmedLabel)
if (filteredHistory.length !== history.length) {
const data: SearchHistoryData = {
items: filteredHistory,
version: this.VERSION
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
}
} catch (error) {
console.error('Error removing label from search history:', error)
}
}
/**
* Get storage statistics
* @returns Object with history statistics
*/
static getStats(): {
totalItems: number
recentSearches: number
popularRecommendations: number
storageSize: number
} {
const history = this.getHistory()
const recentCount = history.filter(item => item.accessCount > 0).length
const popularCount = history.filter(item => item.accessCount === 0).length
let storageSize = 0
try {
const data = localStorage.getItem(this.STORAGE_KEY)
storageSize = data ? data.length : 0
} catch {
// Ignore error
}
return {
totalItems: history.length,
recentSearches: recentCount,
popularRecommendations: popularCount,
storageSize
}
}
}
@@ -0,0 +1,198 @@
/**
* Robust clipboard utility with multiple fallback strategies
* Handles various browser environments and security contexts
*/
export interface CopyResult {
success: boolean;
method: 'clipboard-api' | 'execCommand' | 'manual-select' | 'fallback';
error?: string;
}
/**
* Copy text to clipboard with multiple fallback strategies
* @param text - Text to copy to clipboard
* @returns Promise<CopyResult> - Result object with success status and method used
*/
export async function copyToClipboard(text: string): Promise<CopyResult> {
if (!text || text.trim() === '') {
return {
success: false,
method: 'fallback',
error: 'No text provided'
};
}
// Strategy 1: Modern Clipboard API (preferred)
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return {
success: true,
method: 'clipboard-api'
};
} catch (error) {
console.warn('Clipboard API failed:', error);
// Continue to fallback methods
}
}
// Strategy 2: Legacy execCommand (for older browsers)
try {
const result = await copyWithExecCommand(text);
if (result.success) {
return result;
}
} catch (error) {
console.warn('execCommand failed:', error);
// Continue to fallback methods
}
// Strategy 3: Manual text selection (most compatible)
try {
const result = await copyWithManualSelection(text);
if (result.success) {
return result;
}
} catch (error) {
console.warn('Manual selection failed:', error);
}
// Strategy 4: Complete fallback - return error
return {
success: false,
method: 'fallback',
error: 'All copy methods failed. Please copy the text manually.'
};
}
/**
* Copy using legacy execCommand method
*/
async function copyWithExecCommand(text: string): Promise<CopyResult> {
return new Promise((resolve) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
textarea.setAttribute('readonly', '');
document.body.appendChild(textarea);
try {
textarea.select();
textarea.setSelectionRange(0, text.length);
const successful = document.execCommand('copy');
if (successful) {
resolve({
success: true,
method: 'execCommand'
});
} else {
resolve({
success: false,
method: 'execCommand',
error: 'execCommand returned false'
});
}
} catch (error) {
resolve({
success: false,
method: 'execCommand',
error: error instanceof Error ? error.message : 'execCommand failed'
});
} finally {
document.body.removeChild(textarea);
}
});
}
/**
* Copy using manual text selection method
*/
async function copyWithManualSelection(text: string): Promise<CopyResult> {
return new Promise((resolve) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
textarea.setAttribute('readonly', '');
textarea.setAttribute('tabindex', '-1');
document.body.appendChild(textarea);
try {
// Focus and select the text
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, text.length);
// Try to trigger copy event
const copyEvent = new ClipboardEvent('copy', {
clipboardData: new DataTransfer()
});
if (copyEvent.clipboardData) {
copyEvent.clipboardData.setData('text/plain', text);
document.dispatchEvent(copyEvent);
resolve({
success: true,
method: 'manual-select'
});
} else {
// Fallback: keep text selected for manual copy
resolve({
success: false,
method: 'manual-select',
error: 'Manual selection prepared, but automatic copy failed'
});
}
} catch (error) {
resolve({
success: false,
method: 'manual-select',
error: error instanceof Error ? error.message : 'Manual selection failed'
});
} finally {
// Clean up after a short delay to allow copy operation
setTimeout(() => {
if (document.body.contains(textarea)) {
document.body.removeChild(textarea);
}
}, 100);
}
});
}
/**
* Check if clipboard functionality is available
*/
export function isClipboardSupported(): boolean {
return !!(
(navigator.clipboard && typeof navigator.clipboard.writeText === 'function') ||
typeof document !== 'undefined'
);
}
/**
* Get the best available clipboard method
*/
export function getBestClipboardMethod(): 'clipboard-api' | 'execCommand' | 'manual-select' | 'none' {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
return 'clipboard-api';
}
if (typeof document !== 'undefined') {
return 'execCommand';
}
return 'none';
}
@@ -0,0 +1,228 @@
const DEFAULT_NODE_COLOR = '#5D6D7E'
const TYPE_SYNONYMS: Record<string, string> = {
unknown: 'unknown',
: 'unknown',
other: 'other',
: 'other',
concept: 'concept',
object: 'concept',
type: 'concept',
category: 'concept',
model: 'concept',
project: 'concept',
condition: 'concept',
rule: 'concept',
regulation: 'concept',
article: 'concept',
law: 'concept',
legalclause: 'concept',
policy: 'concept',
disease: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'policy',
: 'concept',
method: 'method',
process: 'method',
: 'method',
: 'method',
artifact: 'artifact',
technology: 'artifact',
tech: 'artifact',
product: 'artifact',
equipment: 'artifact',
device: 'artifact',
stuff: 'artifact',
component: 'artifact',
material: 'artifact',
chemical: 'artifact',
drug: 'artifact',
medicine: 'artifact',
food: 'artifact',
weapon: 'artifact',
arms: 'artifact',
: 'artifact',
: 'artifact',
: 'technology',
: 'technology',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
naturalobject: 'naturalobject',
natural: 'naturalobject',
phenomena: 'naturalobject',
substance: 'naturalobject',
plant: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
data: 'data',
figure: 'data',
value: 'data',
: 'data',
: 'data',
: 'data',
content: 'content',
book: 'content',
video: 'content',
: 'content',
: 'content',
: 'content',
: 'content',
organization: 'organization',
org: 'organization',
company: 'organization',
: 'organization',
: 'organization',
: 'organization',
: 'organization',
event: 'event',
: 'event',
activity: 'event',
: 'event',
person: 'person',
people: 'person',
human: 'person',
role: 'person',
: 'person',
: 'person',
: 'person',
: 'person',
creature: 'creature',
animal: 'creature',
beings: 'creature',
being: 'creature',
alien: 'creature',
ghost: 'creature',
: 'creature',
: 'creature',
: 'creature',
: 'creature',
: 'creature',
location: 'location',
geography: 'location',
geo: 'location',
place: 'location',
address: 'location',
: 'location',
: 'location',
: 'location',
: 'location',
: 'location'
}
const NODE_TYPE_COLORS: Record<string, string> = {
person: '#4169E1',
creature: '#bd7ebe',
organization: '#00cc00',
location: '#cf6d17',
event: '#00bfa0',
concept: '#e3493b',
method: '#b71c1c',
content: '#0f558a',
data: '#0000ff',
artifact: '#4421af',
naturalobject: '#b2e061',
other: '#f4d371',
unknown: '#b0b0b0'
}
const EXTENDED_COLORS = [
'#84a3e1',
'#5a2c6d',
'#2F4F4F',
'#003366',
'#9b3a31',
'#00CED1',
'#b300b3',
'#0f705d',
'#ff99cc',
'#6ef7b3',
'#cd071e'
]
const PREDEFINED_COLOR_SET = new Set(Object.values(NODE_TYPE_COLORS))
interface ResolveNodeColorResult {
color: string
map: Map<string, string>
updated: boolean
}
export const resolveNodeColor = (
nodeType: string | undefined,
currentMap: Map<string, string> | undefined
): ResolveNodeColorResult => {
const typeColorMap = currentMap ?? new Map<string, string>()
const normalizedType = nodeType ? nodeType.toLowerCase() : 'unknown'
const standardType = TYPE_SYNONYMS[normalizedType]
const cacheKey = standardType || normalizedType
if (typeColorMap.has(cacheKey)) {
return {
color: typeColorMap.get(cacheKey) || DEFAULT_NODE_COLOR,
map: typeColorMap,
updated: false
}
}
if (standardType) {
const color = NODE_TYPE_COLORS[standardType] || DEFAULT_NODE_COLOR
const newMap = new Map(typeColorMap)
newMap.set(standardType, color)
return {
color,
map: newMap,
updated: true
}
}
const usedExtendedColors = new Set(
Array.from(typeColorMap.values()).filter((color) => !PREDEFINED_COLOR_SET.has(color))
)
const unusedColor = EXTENDED_COLORS.find((color) => !usedExtendedColors.has(color))
const color = unusedColor || DEFAULT_NODE_COLOR
const newMap = new Map(typeColorMap)
newMap.set(normalizedType, color)
return {
color,
map: newMap,
updated: true
}
}
export { DEFAULT_NODE_COLOR }
@@ -0,0 +1,65 @@
import { visit } from 'unist-util-visit'
import type { Plugin } from 'unified'
import type { Root, Text } from 'mdast'
// Simple footnote plugin for remark - only renders inline citations
export const remarkFootnotes: Plugin<[], Root> = () => {
return (tree: Root) => {
// Find footnote references and replace them with inline citations
visit(tree, 'text', (node: Text, index, parent) => {
if (!parent || typeof index !== 'number') return
const text = node.value
const footnoteRegex = /\[\^([^\]]+)\]/g
let match
const replacements: any[] = []
let lastIndex = 0
while ((match = footnoteRegex.exec(text)) !== null) {
const [fullMatch, id] = match
const startIndex = match.index!
// Add text before footnote
if (startIndex > lastIndex) {
replacements.push({
type: 'text',
value: text.slice(lastIndex, startIndex)
})
}
// Check if there's another footnote immediately following this one
const nextIndex = startIndex + fullMatch.length
const remainingText = text.slice(nextIndex)
const hasConsecutiveFootnote = /^\[\^[^\]]+\]/.test(remainingText)
// Add footnote reference as HTML with placeholder link
const footnoteHtml = `<sup><a href="#footnote-${id}" class="footnote-ref">${id}</a></sup>`
// Add spacing if there's a consecutive footnote
const htmlWithSpacing = hasConsecutiveFootnote
? footnoteHtml + '&nbsp;'
: footnoteHtml
replacements.push({
type: 'html',
value: htmlWithSpacing
})
lastIndex = startIndex + fullMatch.length
}
// Add remaining text
if (lastIndex < text.length) {
replacements.push({
type: 'text',
value: text.slice(lastIndex)
})
}
// Replace the text node if we found footnotes
if (replacements.length > 1) {
parent.children.splice(index, 1, ...replacements)
}
})
}
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_PROXY: string
readonly VITE_API_ENDPOINTS: string
readonly VITE_BACKEND_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}