chore: init monorepo snapshot
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,95 @@
|
||||
import '@/lib/extensions'; // Import all global extensions
|
||||
import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { navigationService } from '@/services/navigation'
|
||||
import { Toaster } from 'sonner'
|
||||
import App from './App'
|
||||
import LoginPage from '@/features/LoginPage'
|
||||
import ThemeProvider from '@/components/ThemeProvider'
|
||||
|
||||
const AppContent = () => {
|
||||
const [initializing, setInitializing] = useState(true)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Set navigate function for navigation service
|
||||
useEffect(() => {
|
||||
navigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
// Token validity check
|
||||
useEffect(() => {
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('LIGHTRAG-API-TOKEN')
|
||||
|
||||
if (token && isAuthenticated) {
|
||||
setInitializing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error)
|
||||
if (!isAuthenticated) {
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
} finally {
|
||||
setInitializing(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth()
|
||||
|
||||
return () => {
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
// Redirect effect for protected routes
|
||||
useEffect(() => {
|
||||
if (!initializing && !isAuthenticated) {
|
||||
const currentPath = window.location.hash.slice(1);
|
||||
if (currentPath !== '/login') {
|
||||
console.log('Not authenticated, redirecting to login');
|
||||
navigate('/login');
|
||||
}
|
||||
}
|
||||
}, [initializing, isAuthenticated, navigate]);
|
||||
|
||||
// Show nothing while initializing
|
||||
if (initializing) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={isAuthenticated ? <App /> : null}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
const AppRouter = () => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Router>
|
||||
<AppContent />
|
||||
<Toaster
|
||||
position="bottom-center"
|
||||
theme="system"
|
||||
closeButton
|
||||
richColors
|
||||
/>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppRouter
|
||||
@@ -0,0 +1,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
|
||||
})
|
||||
|
||||
// Interceptor:hanle 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 accept,use 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 sigma,and 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
|
||||
@@ -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
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "الصفحة الأخيرة"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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• Hybrid:Local+Global\n• Mix:Local+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• Hybrid:Local+Global\n• Mix:Local+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": "最後一頁"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -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 + ' '
|
||||
: 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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user