chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import ThemeProvider from '@/components/ThemeProvider'
|
||||
import TabVisibilityProvider from '@/contexts/TabVisibilityProvider'
|
||||
import ApiKeyAlert from '@/components/ApiKeyAlert'
|
||||
import StatusIndicator from '@/components/status/StatusIndicator'
|
||||
import { SiteInfo, webuiPrefix } from '@/lib/constants'
|
||||
import { useBackendState, useAuthStore } from '@/stores/state'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { getAuthStatus } from '@/api/lightrag'
|
||||
import SiteHeader from '@/features/SiteHeader'
|
||||
import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'
|
||||
import { ZapIcon } from 'lucide-react'
|
||||
|
||||
import GraphViewer from '@/features/GraphViewer'
|
||||
import DocumentManager from '@/features/DocumentManager'
|
||||
import RetrievalView from '@/features/RetrievalView'
|
||||
import ApiSite from '@/features/ApiSite'
|
||||
|
||||
import { Tabs, TabsContent } from '@/components/ui/Tabs'
|
||||
|
||||
function App() {
|
||||
const message = useBackendState.use.message()
|
||||
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
||||
const currentTab = useSettingsStore.use.currentTab()
|
||||
const [apiKeyAlertOpen, setApiKeyAlertOpen] = useState(false)
|
||||
const [initializing, setInitializing] = useState(true) // Add initializing state
|
||||
const versionCheckRef = useRef(false); // Prevent duplicate calls in Vite dev mode
|
||||
const healthCheckInitializedRef = useRef(false); // Prevent duplicate health checks in Vite dev mode
|
||||
|
||||
const handleApiKeyAlertOpenChange = useCallback((open: boolean) => {
|
||||
setApiKeyAlertOpen(open)
|
||||
if (!open) {
|
||||
useBackendState.getState().clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Track component mount status with useRef
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// Set up mount/unmount status tracking
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// Handle page reload/unload
|
||||
const handleBeforeUnload = () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Health check - can be disabled
|
||||
useEffect(() => {
|
||||
// Health check function
|
||||
const performHealthCheck = async () => {
|
||||
try {
|
||||
// Only perform health check if component is still mounted
|
||||
if (isMountedRef.current) {
|
||||
await useBackendState.getState().check();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Set health check function in the store
|
||||
useBackendState.getState().setHealthCheckFunction(performHealthCheck);
|
||||
|
||||
if (!enableHealthCheck || apiKeyAlertOpen) {
|
||||
useBackendState.getState().clearHealthCheckTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// On first mount or when enableHealthCheck becomes true and apiKeyAlertOpen is false,
|
||||
// perform an immediate health check and start the timer
|
||||
if (!healthCheckInitializedRef.current) {
|
||||
healthCheckInitializedRef.current = true;
|
||||
}
|
||||
|
||||
// Start/reset the health check timer using the store
|
||||
useBackendState.getState().resetHealthCheckTimer();
|
||||
|
||||
// Component unmount cleanup
|
||||
return () => {
|
||||
useBackendState.getState().clearHealthCheckTimer();
|
||||
};
|
||||
}, [enableHealthCheck, apiKeyAlertOpen]);
|
||||
|
||||
// Version check - independent and executed only once
|
||||
useEffect(() => {
|
||||
const checkVersion = async () => {
|
||||
// Prevent duplicate calls in Vite dev mode
|
||||
if (versionCheckRef.current) return;
|
||||
versionCheckRef.current = true;
|
||||
|
||||
// Check if version info was already obtained in login page
|
||||
const versionCheckedFromLogin = sessionStorage.getItem('VERSION_CHECKED_FROM_LOGIN') === 'true';
|
||||
if (versionCheckedFromLogin) {
|
||||
setInitializing(false); // Skip initialization if already checked
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setInitializing(true); // Start initialization
|
||||
|
||||
// Get version info
|
||||
const token = localStorage.getItem('LIGHTRAG-API-TOKEN');
|
||||
const status = await getAuthStatus();
|
||||
|
||||
// If auth is not configured and a new token is returned, use the new token
|
||||
if (!status.auth_configured && status.access_token) {
|
||||
useAuthStore.getState().login(
|
||||
status.access_token, // Use the new token
|
||||
true, // Guest mode
|
||||
status.core_version,
|
||||
status.api_version,
|
||||
status.webui_title || null,
|
||||
status.webui_description || null
|
||||
);
|
||||
} else if (token && (status.core_version || status.api_version || status.webui_title || status.webui_description)) {
|
||||
// Otherwise use the old token (if it exists)
|
||||
const isGuestMode = status.auth_mode === 'disabled' || useAuthStore.getState().isGuestMode;
|
||||
useAuthStore.getState().login(
|
||||
token,
|
||||
isGuestMode,
|
||||
status.core_version,
|
||||
status.api_version,
|
||||
status.webui_title || null,
|
||||
status.webui_description || null
|
||||
);
|
||||
}
|
||||
|
||||
// Set flag to indicate version info has been checked
|
||||
sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');
|
||||
} catch (error) {
|
||||
console.error('Failed to get version info:', error);
|
||||
} finally {
|
||||
// Ensure initializing is set to false even if there's an error
|
||||
setInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute version check
|
||||
checkVersion();
|
||||
}, []); // Empty dependency array ensures it only runs once on mount
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string) => useSettingsStore.getState().setCurrentTab(tab as any),
|
||||
[]
|
||||
)
|
||||
|
||||
// React to backend message changes during render rather than via useEffect
|
||||
// (avoids cascading renders flagged by react-hooks/set-state-in-effect)
|
||||
const [previousMessage, setPreviousMessage] = useState(message)
|
||||
if (message !== previousMessage) {
|
||||
setPreviousMessage(message)
|
||||
if (message && (message.includes(InvalidApiKeyError) || message.includes(RequireApiKeError))) {
|
||||
setApiKeyAlertOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TabVisibilityProvider>
|
||||
{initializing ? (
|
||||
// Loading state while initializing with simplified header
|
||||
<div className="flex h-screen w-screen flex-col">
|
||||
{/* Simplified header during initialization - matches SiteHeader structure */}
|
||||
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
|
||||
<div className="min-w-[200px] w-auto flex items-center">
|
||||
<a href={webuiPrefix} className="flex items-center gap-2">
|
||||
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
|
||||
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Empty middle section to maintain layout */}
|
||||
<div className="flex h-10 flex-1 items-center justify-center">
|
||||
</div>
|
||||
|
||||
{/* Empty right section to maintain layout */}
|
||||
<nav className="w-[200px] flex items-center justify-end">
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Loading indicator in content area */}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto"></div>
|
||||
<p>Initializing...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Main content after initialization
|
||||
<main className="flex h-screen w-screen overflow-hidden">
|
||||
<Tabs
|
||||
defaultValue={currentTab}
|
||||
className="!m-0 flex grow flex-col !p-0 overflow-hidden"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<SiteHeader />
|
||||
<div className="relative grow">
|
||||
<TabsContent value="documents" className="absolute top-0 right-0 bottom-0 left-0 overflow-auto">
|
||||
<DocumentManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="knowledge-graph" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<GraphViewer />
|
||||
</TabsContent>
|
||||
<TabsContent value="retrieval" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<RetrievalView />
|
||||
</TabsContent>
|
||||
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<ApiSite />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
{enableHealthCheck && <StatusIndicator />}
|
||||
<ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} />
|
||||
</main>
|
||||
)}
|
||||
</TabVisibilityProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,95 @@
|
||||
import '@/lib/extensions'; // Import all global extensions
|
||||
import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { navigationService } from '@/services/navigation'
|
||||
import { Toaster } from 'sonner'
|
||||
import App from './App'
|
||||
import LoginPage from '@/features/LoginPage'
|
||||
import ThemeProvider from '@/components/ThemeProvider'
|
||||
|
||||
const AppContent = () => {
|
||||
const [initializing, setInitializing] = useState(true)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Set navigate function for navigation service
|
||||
useEffect(() => {
|
||||
navigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
// Token validity check
|
||||
useEffect(() => {
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('LIGHTRAG-API-TOKEN')
|
||||
|
||||
if (token && isAuthenticated) {
|
||||
setInitializing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error)
|
||||
if (!isAuthenticated) {
|
||||
useAuthStore.getState().logout()
|
||||
}
|
||||
} finally {
|
||||
setInitializing(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth()
|
||||
|
||||
return () => {
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
// Redirect effect for protected routes
|
||||
useEffect(() => {
|
||||
if (!initializing && !isAuthenticated) {
|
||||
const currentPath = window.location.hash.slice(1);
|
||||
if (currentPath !== '/login') {
|
||||
console.log('Not authenticated, redirecting to login');
|
||||
navigate('/login');
|
||||
}
|
||||
}
|
||||
}, [initializing, isAuthenticated, navigate]);
|
||||
|
||||
// Show nothing while initializing
|
||||
if (initializing) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={isAuthenticated ? <App /> : null}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
const AppRouter = () => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Router>
|
||||
<AppContent />
|
||||
<Toaster
|
||||
position="bottom-center"
|
||||
theme="system"
|
||||
closeButton
|
||||
richColors
|
||||
/>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppRouter
|
||||
@@ -0,0 +1,515 @@
|
||||
import { afterEach, beforeAll, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock dependencies BEFORE importing the module under test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const storageData = new Map<string, string>()
|
||||
const storageMock = {
|
||||
getItem: (key: string) => storageData.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storageData.set(key, value) },
|
||||
removeItem: (key: string) => { storageData.delete(key) },
|
||||
clear: () => { storageData.clear() },
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: storageMock,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: storageMock,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Mock zustand stores — both return a vanilla store-like object with getState()
|
||||
let storeApiKey: string | null = null
|
||||
let storeIsGuestMode = false
|
||||
const fakeSettingsStore = { getState: () => ({ apiKey: storeApiKey }) }
|
||||
const fakeAuthStore = {
|
||||
getState: () => ({
|
||||
isGuestMode: storeIsGuestMode,
|
||||
login: () => {},
|
||||
setTokenRenewal: () => {},
|
||||
}),
|
||||
}
|
||||
|
||||
mock.module('@/stores/settings', () => ({ useSettingsStore: fakeSettingsStore }))
|
||||
mock.module('@/stores/state', () => ({ useAuthStore: fakeAuthStore }))
|
||||
mock.module('@/services/navigation', () => ({
|
||||
navigationService: { navigateToLogin: () => {} },
|
||||
}))
|
||||
mock.module('@/lib/utils', () => ({
|
||||
errorMessage: (error: any) =>
|
||||
error instanceof Error ? error.message : `${error}`,
|
||||
}))
|
||||
mock.module('@/lib/constants', () => ({
|
||||
backendBaseUrl: 'http://localhost:9621',
|
||||
popularLabelsDefaultLimit: 300,
|
||||
searchLabelsDefaultLimit: 50,
|
||||
}))
|
||||
|
||||
// Mock axios — the module calls axios.create() at top level and
|
||||
// axios.get() in silentRefreshGuestToken
|
||||
mock.module('axios', () => {
|
||||
const instance = {
|
||||
get: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
access_token: 'mock-guest-token',
|
||||
auth_configured: false,
|
||||
core_version: '1.0',
|
||||
api_version: '1.0',
|
||||
},
|
||||
headers: {},
|
||||
}),
|
||||
post: () => Promise.resolve({ data: {}, headers: {} }),
|
||||
interceptors: {
|
||||
request: { use: () => {} },
|
||||
response: { use: () => {} },
|
||||
},
|
||||
}
|
||||
// The default export from axios is the main axios function, which also has
|
||||
// .create, .get, .post, etc. as static methods.
|
||||
const axiosFn: any = () => Promise.resolve({ data: {}, headers: {} })
|
||||
axiosFn.create = () => instance
|
||||
axiosFn.get = instance.get
|
||||
axiosFn.post = instance.post
|
||||
axiosFn.interceptors = instance.interceptors
|
||||
return { default: axiosFn, __esModule: true }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a minimal QueryRequest payload. */
|
||||
const makeQueryRequest = (overrides = {}) => ({
|
||||
query: 'test query',
|
||||
mode: 'mix' as const,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Create a ReadableStream from an array of Uint8Array chunks. Bun's Response
|
||||
* constructor accepts a ReadableStream directly.
|
||||
*/
|
||||
function makeNdjsonResponse(lines: string[], status = 200): Response {
|
||||
const encoder = new TextEncoder()
|
||||
const body = lines.map((l) => encoder.encode(l + '\n'))
|
||||
// Build a simple async iterable readable stream
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of body) {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { status })
|
||||
}
|
||||
|
||||
/** Build a non-streaming error response (text body). */
|
||||
function makeTextResponse(body: string, status: number): Response {
|
||||
return new Response(body, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a mocked implementation onto globalThis.fetch. Bun's `mock()` returns
|
||||
* a `Mock` that lacks the DOM `fetch.preconnect` static, so the assignment is
|
||||
* cast through `unknown` to satisfy the `typeof fetch` type.
|
||||
*/
|
||||
function installFetchMock(
|
||||
impl: (url: string, init?: RequestInit) => Response | Promise<Response>
|
||||
): void {
|
||||
globalThis.fetch = mock(impl) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let apiModule: typeof import('./lightrag')
|
||||
|
||||
beforeAll(async () => {
|
||||
apiModule = await import('./lightrag')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
storageData.clear()
|
||||
storeApiKey = null
|
||||
storeIsGuestMode = false
|
||||
})
|
||||
|
||||
describe('queryTextStream — normal path', () => {
|
||||
test('parses NDJSON response chunks and calls onChunk', async () => {
|
||||
const chunks: string[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
installFetchMock(() =>
|
||||
makeNdjsonResponse([
|
||||
'{"response": "Hello"}',
|
||||
'{"response": " World"}',
|
||||
'{"response": "!"}',
|
||||
])
|
||||
)
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
(e) => errors.push(e)
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['Hello', ' World', '!'])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('forwards error lines to onError', async () => {
|
||||
const chunks: string[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
installFetchMock(() =>
|
||||
makeNdjsonResponse([
|
||||
'{"response": "ok"}',
|
||||
'{"error": "Something went wrong"}',
|
||||
'{"response": "more"}',
|
||||
])
|
||||
)
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
(e) => errors.push(e)
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['ok', 'more'])
|
||||
expect(errors).toEqual(['Something went wrong'])
|
||||
})
|
||||
|
||||
test('skips malformed JSON lines', async () => {
|
||||
const chunks: string[] = []
|
||||
|
||||
installFetchMock(() =>
|
||||
makeNdjsonResponse([
|
||||
'not valid json',
|
||||
'{"response": "valid"}',
|
||||
'{broken',
|
||||
])
|
||||
)
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
() => {}
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['valid'])
|
||||
})
|
||||
|
||||
test('handles multi-byte characters split across chunks', async () => {
|
||||
const chunks: string[] = []
|
||||
const encoder = new TextEncoder()
|
||||
// "Hello 😀🌍" — split the emoji bytes across chunks
|
||||
const fullJson = '{"response": "Hello 😀🌍"}\n'
|
||||
const fullBytes = encoder.encode(fullJson)
|
||||
// Split at an arbitrary point inside the emoji sequence
|
||||
const splitAt = 28
|
||||
const part1 = fullBytes.slice(0, splitAt)
|
||||
const part2 = fullBytes.slice(splitAt)
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(part1)
|
||||
controller.enqueue(part2)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
installFetchMock(() => new Response(stream, { status: 200 }))
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
() => {}
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['Hello 😀🌍'])
|
||||
})
|
||||
|
||||
test('calls onError when final buffer is unparseable (truncated stream)', async () => {
|
||||
// Simulate a stream cut off mid-line with no trailing newline.
|
||||
// The residual buffer after the stream ends will be an incomplete JSON
|
||||
// object that can't be parsed.
|
||||
const encoder = new TextEncoder()
|
||||
const jsonLine = '{"response": "ok"}\n'
|
||||
const truncatedLine = '{"response": "incom'
|
||||
const body = new Uint8Array([
|
||||
...encoder.encode(jsonLine),
|
||||
...encoder.encode(truncatedLine),
|
||||
])
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(body)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
installFetchMock(() => new Response(stream, { status: 200 }))
|
||||
|
||||
const chunks: string[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
(e) => errors.push(e)
|
||||
)
|
||||
|
||||
expect(chunks).toEqual(['ok'])
|
||||
expect(errors).toEqual([
|
||||
'Response stream ended with incomplete data — the response may be truncated.',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTextStream — abort / stop button', () => {
|
||||
test('exits silently on user abort (signal.aborted)', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
installFetchMock(() => {
|
||||
// fetch should reject since the signal is already aborted
|
||||
return Promise.reject(new DOMException('Aborted', 'AbortError'))
|
||||
})
|
||||
|
||||
let errorCalled = false
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => { errorCalled = true },
|
||||
controller.signal
|
||||
)
|
||||
|
||||
expect(errorCalled).toBe(false)
|
||||
})
|
||||
|
||||
test('exits silently when AbortError is thrown without a signal', async () => {
|
||||
installFetchMock(() =>
|
||||
Promise.reject(new DOMException('Aborted', 'AbortError'))
|
||||
)
|
||||
|
||||
let errorCalled = false
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => { errorCalled = true }
|
||||
)
|
||||
|
||||
expect(errorCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTextStream — HTTP errors', () => {
|
||||
const errorCases = [
|
||||
{
|
||||
status: 403,
|
||||
expectedMessage:
|
||||
'You do not have permission to access this resource (403 Forbidden)',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
expectedMessage: 'The requested resource does not exist (404 Not Found)',
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
expectedMessage:
|
||||
'Too many requests, please try again later (429 Too Many Requests)',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
expectedMessage:
|
||||
'Server error, please try again later (500)',
|
||||
},
|
||||
{
|
||||
status: 502,
|
||||
expectedMessage:
|
||||
'Server error, please try again later (502)',
|
||||
},
|
||||
{
|
||||
status: 503,
|
||||
expectedMessage:
|
||||
'Server error, please try again later (503)',
|
||||
},
|
||||
]
|
||||
|
||||
for (const { status, expectedMessage } of errorCases) {
|
||||
test(`shows friendly message for HTTP ${status}`, async () => {
|
||||
installFetchMock(() =>
|
||||
makeTextResponse('{"error":"details"}', status)
|
||||
)
|
||||
|
||||
let capturedError = ''
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
(e) => { capturedError = e }
|
||||
)
|
||||
|
||||
expect(capturedError).toBe(expectedMessage)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('queryTextStream — network errors', () => {
|
||||
test('shows network error for Failed to fetch', async () => {
|
||||
installFetchMock(() =>
|
||||
Promise.reject(new TypeError('Failed to fetch'))
|
||||
)
|
||||
|
||||
let capturedError = ''
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
(e) => { capturedError = e }
|
||||
)
|
||||
|
||||
expect(capturedError).toBe(
|
||||
'Network connection error, please check your internet connection'
|
||||
)
|
||||
})
|
||||
|
||||
test('shows network error for NetworkError', async () => {
|
||||
installFetchMock(() =>
|
||||
Promise.reject(new Error('NetworkError: connection refused'))
|
||||
)
|
||||
|
||||
let capturedError = ''
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
(e) => { capturedError = e }
|
||||
)
|
||||
|
||||
expect(capturedError).toBe(
|
||||
'Network connection error, please check your internet connection'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTextStream — auth headers', () => {
|
||||
test('includes Bearer token when stored', async () => {
|
||||
storageData.set('LIGHTRAG-API-TOKEN', 'test-jwt-token')
|
||||
|
||||
let capturedHeaders: HeadersInit | undefined
|
||||
installFetchMock((_url: string, init?: RequestInit) => {
|
||||
capturedHeaders = init?.headers
|
||||
return makeNdjsonResponse(['{"response": "ok"}'])
|
||||
})
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
|
||||
// The module builds headers as a plain object literal, so the captured
|
||||
// RequestInit.headers is a Record we can assert against directly.
|
||||
const sentHeaders = capturedHeaders as Record<string, string>
|
||||
expect(sentHeaders).toBeDefined()
|
||||
expect(sentHeaders['Authorization']).toBe('Bearer test-jwt-token')
|
||||
expect(sentHeaders['Accept']).toBe('application/x-ndjson')
|
||||
expect(sentHeaders['Content-Type']).toBe('application/json')
|
||||
})
|
||||
|
||||
test('omits Bearer token when none is stored', async () => {
|
||||
let capturedHeaders: HeadersInit | undefined
|
||||
installFetchMock((_url: string, init?: RequestInit) => {
|
||||
capturedHeaders = init?.headers
|
||||
return makeNdjsonResponse(['{"response": "ok"}'])
|
||||
})
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
|
||||
const sentHeaders = capturedHeaders as Record<string, string>
|
||||
expect(sentHeaders['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('calls /query/stream endpoint', async () => {
|
||||
let capturedUrl = ''
|
||||
installFetchMock((url: string) => {
|
||||
capturedUrl = url
|
||||
return makeNdjsonResponse(['{"response": "ok"}'])
|
||||
})
|
||||
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
|
||||
expect(capturedUrl).toBe('http://localhost:9621/query/stream')
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTextStream — guest-token 401 retry', () => {
|
||||
test('retries with refreshed guest token on 401', async () => {
|
||||
storageData.set('LIGHTRAG-API-TOKEN', 'expired-guest-token')
|
||||
storeIsGuestMode = true
|
||||
|
||||
let callCount = 0
|
||||
|
||||
installFetchMock(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return makeTextResponse('{"error":"unauthorized"}', 401)
|
||||
}
|
||||
return makeNdjsonResponse(['{"response": "retry ok"}'])
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
(c) => chunks.push(c),
|
||||
() => {}
|
||||
)
|
||||
|
||||
expect(callCount).toBe(2)
|
||||
expect(chunks).toEqual(['retry ok'])
|
||||
})
|
||||
|
||||
test('classifies a non-auth HTTP error on the retried stream (e.g. 429)', async () => {
|
||||
storageData.set('LIGHTRAG-API-TOKEN', 'expired-guest-token')
|
||||
storeIsGuestMode = true
|
||||
|
||||
let callCount = 0
|
||||
|
||||
installFetchMock(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return makeTextResponse('{"error":"unauthorized"}', 401)
|
||||
}
|
||||
// Refresh succeeded, but the retried request is rate-limited.
|
||||
return makeTextResponse('{"error":"rate limited"}', 429)
|
||||
})
|
||||
|
||||
let capturedError = ''
|
||||
await apiModule.queryTextStream(
|
||||
makeQueryRequest(),
|
||||
() => {},
|
||||
(e) => {
|
||||
capturedError = e
|
||||
}
|
||||
)
|
||||
|
||||
// The retry's 429 must be classified like any other HTTP error, NOT
|
||||
// reported as an auth-refresh failure.
|
||||
expect(callCount).toBe(2)
|
||||
expect(capturedError).toBe(
|
||||
'Too many requests, please try again later (429 Too Many Requests)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
|
||||
|
||||
type DocumentsRequest = {
|
||||
status_filter?: 'pending' | 'processing' | 'preprocessed' | 'processed' | 'failed' | null
|
||||
page: number
|
||||
page_size: number
|
||||
sort_field: 'created_at' | 'updated_at' | 'id' | 'file_path'
|
||||
sort_direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
type LightragApiModule = typeof import('./lightrag')
|
||||
|
||||
const storageMock = () => {
|
||||
const data = new Map<string, string>()
|
||||
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
data.set(key, value)
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
data.delete(key)
|
||||
},
|
||||
clear: () => {
|
||||
data.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let apiModule: LightragApiModule
|
||||
|
||||
beforeAll(async () => {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: storageMock(),
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: storageMock(),
|
||||
configurable: true
|
||||
})
|
||||
|
||||
apiModule = await import('./lightrag')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
apiModule.__resetPaginatedDocumentRequestsForTests()
|
||||
})
|
||||
|
||||
describe('getDocumentsPaginated', () => {
|
||||
test('issues a fresh request after aborting a timed-out in-flight request', async () => {
|
||||
const request: DocumentsRequest = {
|
||||
status_filter: null,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
sort_field: 'updated_at',
|
||||
sort_direction: 'desc'
|
||||
}
|
||||
|
||||
let callCount = 0
|
||||
const resolvers: Array<(value: any) => void> = []
|
||||
|
||||
apiModule.__setPaginatedDocumentsPostForTests((_request, controller) => {
|
||||
callCount += 1
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
resolvers.push(resolve)
|
||||
controller.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(new DOMException('Aborted', 'AbortError')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const firstRequest = apiModule.getDocumentsPaginated(request)
|
||||
const secondRequest = apiModule.getDocumentsPaginated(request)
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
apiModule.abortDocumentsPaginated(request)
|
||||
const [firstResult, secondResult] = await Promise.allSettled([
|
||||
firstRequest,
|
||||
secondRequest
|
||||
])
|
||||
expect(firstResult.status).toBe('rejected')
|
||||
expect(secondResult.status).toBe('rejected')
|
||||
|
||||
const thirdRequest = apiModule.getDocumentsPaginated(request)
|
||||
expect(callCount).toBe(2)
|
||||
|
||||
resolvers[1]({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
|
||||
await expect(thirdRequest).resolves.toEqual({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
test('times out hanging requests and allows a fresh retry', async () => {
|
||||
const request: DocumentsRequest = {
|
||||
status_filter: null,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
sort_field: 'updated_at',
|
||||
sort_direction: 'desc'
|
||||
}
|
||||
|
||||
let callCount = 0
|
||||
const resolvers: Array<(value: any) => void> = []
|
||||
|
||||
apiModule.__setPaginatedDocumentsPostForTests((_request, controller) => {
|
||||
callCount += 1
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
resolvers.push(resolve)
|
||||
controller.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(new DOMException('Aborted', 'AbortError')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
apiModule.getDocumentsPaginatedWithTimeout(request, 1)
|
||||
).rejects.toThrow('Document fetch timeout')
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
const retryRequest = apiModule.getDocumentsPaginated(request)
|
||||
expect(callCount).toBe(2)
|
||||
|
||||
resolvers[1]({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
|
||||
await expect(retryRequest).resolves.toEqual({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
test('does not abort a shared request when only one timeout subscriber expires', async () => {
|
||||
const request: DocumentsRequest = {
|
||||
status_filter: null,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
sort_field: 'updated_at',
|
||||
sort_direction: 'desc'
|
||||
}
|
||||
|
||||
let callCount = 0
|
||||
let resolveSharedRequest: ((value: any) => void) | undefined
|
||||
let abortCount = 0
|
||||
|
||||
apiModule.__setPaginatedDocumentsPostForTests((_request, controller) => {
|
||||
callCount += 1
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
resolveSharedRequest = resolve
|
||||
controller.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
abortCount += 1
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const shortTimeoutRequest = apiModule.getDocumentsPaginatedWithTimeout(request, 1)
|
||||
const longTimeoutRequest = apiModule.getDocumentsPaginatedWithTimeout(request, 100)
|
||||
|
||||
await expect(shortTimeoutRequest).rejects.toThrow('Document fetch timeout')
|
||||
|
||||
expect(callCount).toBe(1)
|
||||
expect(abortCount).toBe(0)
|
||||
|
||||
resolveSharedRequest?.({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
|
||||
await expect(longTimeoutRequest).resolves.toEqual({
|
||||
documents: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_count: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false
|
||||
},
|
||||
status_counts: { all: 0 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUserAbortError', () => {
|
||||
// Regression: the Stop button must suppress query cancellation everywhere it
|
||||
// surfaces — both the main stream catch and the guest-token retry catch (which
|
||||
// otherwise redirects an aborting guest to the login page). Both sites share
|
||||
// this predicate, so locking down its behavior guards both fixes.
|
||||
test('treats an aborted signal as a user abort regardless of the error', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
expect(apiModule.isUserAbortError(controller.signal, new Error('boom'))).toBe(true)
|
||||
})
|
||||
|
||||
test('treats an AbortError as a user abort even when the signal is absent', () => {
|
||||
const abortError = new DOMException('Aborted', 'AbortError')
|
||||
expect(apiModule.isUserAbortError(undefined, abortError)).toBe(true)
|
||||
})
|
||||
|
||||
test('does not treat a real failure on a live signal as a user abort', () => {
|
||||
const controller = new AbortController()
|
||||
expect(apiModule.isUserAbortError(controller.signal, new Error('network down'))).toBe(false)
|
||||
expect(apiModule.isUserAbortError(undefined, new Error('network down'))).toBe(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/AlertDialog'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { InvalidApiKeyError, RequireApiKeError } from '@/api/lightrag'
|
||||
|
||||
interface ApiKeyAlertProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ApiKeyAlert = ({ open: opened, onOpenChange: setOpened }: ApiKeyAlertProps) => {
|
||||
const { t } = useTranslation()
|
||||
const apiKey = useSettingsStore.use.apiKey()
|
||||
const [tempApiKey, setTempApiKey] = useState<string>(apiKey || '')
|
||||
const message = useBackendState.use.message()
|
||||
|
||||
// Sync draft input with latest store value whenever the dialog opens or apiKey changes
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setTempApiKey(apiKey || ''), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [apiKey, opened])
|
||||
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
if (message.includes(InvalidApiKeyError) || message.includes(RequireApiKeError)) {
|
||||
setOpened(true)
|
||||
}
|
||||
}
|
||||
}, [message, setOpened])
|
||||
|
||||
const setApiKey = useCallback(() => {
|
||||
useSettingsStore.setState({ apiKey: tempApiKey || null })
|
||||
setOpened(false)
|
||||
}, [tempApiKey, setOpened])
|
||||
|
||||
const handleTempApiKeyChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTempApiKey(e.target.value)
|
||||
},
|
||||
[setTempApiKey]
|
||||
)
|
||||
|
||||
return (
|
||||
<AlertDialog open={opened} onOpenChange={setOpened}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('apiKeyAlert.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('apiKeyAlert.description')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<form className="flex gap-2" onSubmit={(e) => e.preventDefault()}>
|
||||
<Input
|
||||
type="password"
|
||||
value={tempApiKey}
|
||||
onChange={handleTempApiKeyChange}
|
||||
placeholder={t('apiKeyAlert.placeholder')}
|
||||
className="max-h-full w-full min-w-0"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Button onClick={setApiKey} variant="outline" size="sm">
|
||||
{t('apiKeyAlert.save')}
|
||||
</Button>
|
||||
</form>
|
||||
{message && (
|
||||
<div className="text-sm text-red-500">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiKeyAlert
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { PaletteIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AppSettingsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AppSettings({ className }: AppSettingsProps) {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const language = useSettingsStore.use.language()
|
||||
const setLanguage = useSettingsStore.use.setLanguage()
|
||||
|
||||
const theme = useSettingsStore.use.theme()
|
||||
const setTheme = useSettingsStore.use.setTheme()
|
||||
|
||||
const handleLanguageChange = useCallback((value: string) => {
|
||||
setLanguage(value as 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW' | 'ru' | 'ja' | 'de' | 'uk' | 'ko' | 'vi')
|
||||
}, [setLanguage])
|
||||
|
||||
const handleThemeChange = useCallback((value: string) => {
|
||||
setTheme(value as 'light' | 'dark' | 'system')
|
||||
}, [setTheme])
|
||||
|
||||
return (
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className={cn('h-9 w-9', className)}>
|
||||
<PaletteIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="end" className="w-56">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{t('settings.language')}</label>
|
||||
<Select value={language} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="zh">中文</SelectItem>
|
||||
<SelectItem value="fr">Français</SelectItem>
|
||||
<SelectItem value="ar">العربية</SelectItem>
|
||||
<SelectItem value="zh_TW">繁體中文</SelectItem>
|
||||
<SelectItem value="ru">Русский</SelectItem>
|
||||
<SelectItem value="ja">日本語</SelectItem>
|
||||
<SelectItem value="de">Deutsch</SelectItem>
|
||||
<SelectItem value="uk">Українська</SelectItem>
|
||||
<SelectItem value="ko">한국어</SelectItem>
|
||||
<SelectItem value="vi">Tiếng Việt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{t('settings.theme')}</label>
|
||||
<Select value={theme} onValueChange={handleThemeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">{t('settings.light')}</SelectItem>
|
||||
<SelectItem value="dark">{t('settings.dark')}</SelectItem>
|
||||
<SelectItem value="system">{t('settings.system')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import { useCallback } from 'react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
/**
|
||||
* Component that toggles the language between English and Chinese.
|
||||
*/
|
||||
export default function LanguageToggle() {
|
||||
const { i18n } = useTranslation()
|
||||
const currentLanguage = i18n.language
|
||||
const setLanguage = useSettingsStore.use.setLanguage()
|
||||
|
||||
const setEnglish = useCallback(() => {
|
||||
i18n.changeLanguage('en')
|
||||
setLanguage('en')
|
||||
}, [i18n, setLanguage])
|
||||
|
||||
const setChinese = useCallback(() => {
|
||||
i18n.changeLanguage('zh')
|
||||
setLanguage('zh')
|
||||
}, [i18n, setLanguage])
|
||||
|
||||
if (currentLanguage === 'zh') {
|
||||
return (
|
||||
<Button
|
||||
onClick={setEnglish}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="Switch to English"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
中
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={setChinese}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="切换到中文"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
EN
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from 'react'
|
||||
import App from '@/App'
|
||||
import '@/i18n'
|
||||
|
||||
export const Root = () => (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, useEffect } from 'react'
|
||||
import { Theme, useSettingsStore } from '@/stores/settings'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
/**
|
||||
* Component that provides the theme state and setter function to its children.
|
||||
*/
|
||||
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const theme = useSettingsStore.use.theme()
|
||||
const setTheme = useSettingsStore.use.setTheme()
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(e.matches ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
root.classList.add(mediaQuery.matches ? 'dark' : 'light')
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
} else {
|
||||
root.classList.add(theme)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { ThemeProviderContext }
|
||||
@@ -0,0 +1,41 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import useTheme from '@/hooks/useTheme'
|
||||
import { MoonIcon, SunIcon } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Component that toggles the theme between light and dark.
|
||||
*/
|
||||
export default function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const setLight = useCallback(() => setTheme('light'), [setTheme])
|
||||
const setDark = useCallback(() => setTheme('dark'), [setTheme])
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (theme === 'dark') {
|
||||
return (
|
||||
<Button
|
||||
onClick={setLight}
|
||||
variant={controlButtonVariant}
|
||||
tooltip={t('header.themeToggle.switchToLight')}
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
<MoonIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={setDark}
|
||||
variant={controlButtonVariant}
|
||||
tooltip={t('header.themeToggle.switchToDark')}
|
||||
size="icon"
|
||||
side="bottom"
|
||||
>
|
||||
<SunIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter
|
||||
} from '@/components/ui/Dialog'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import { toast } from 'sonner'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { clearDocuments, clearCache } from '@/api/lightrag'
|
||||
|
||||
import { EraserIcon, AlertTriangleIcon, Loader2Icon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Simple Label component
|
||||
const Label = ({
|
||||
htmlFor,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.LabelHTMLAttributes<HTMLLabelElement>) => (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
interface ClearDocumentsDialogProps {
|
||||
onDocumentsCleared?: () => Promise<void>
|
||||
}
|
||||
|
||||
export default function ClearDocumentsDialog({ onDocumentsCleared }: ClearDocumentsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [clearCacheOption, setClearCacheOption] = useState(false)
|
||||
const [isClearing, setIsClearing] = useState(false)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isConfirmEnabled = confirmText.toLowerCase() === 'yes'
|
||||
|
||||
// Timeout constant (30 seconds)
|
||||
const CLEAR_TIMEOUT = 30000
|
||||
|
||||
// Reset state when dialog closes - handled in onOpenChange to avoid setState in effect
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
if (!newOpen) {
|
||||
setConfirmText('')
|
||||
setClearCacheOption(false)
|
||||
setIsClearing(false)
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cleanup when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear timeout timer when component unmounts
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
if (!isConfirmEnabled || isClearing) return
|
||||
|
||||
setIsClearing(true)
|
||||
|
||||
// Set timeout protection
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (isClearing) {
|
||||
toast.error(t('documentPanel.clearDocuments.timeout'))
|
||||
setIsClearing(false)
|
||||
setConfirmText('') // Reset confirmation text after timeout
|
||||
}
|
||||
}, CLEAR_TIMEOUT)
|
||||
|
||||
try {
|
||||
const result = await clearDocuments()
|
||||
|
||||
if (result.status !== 'success') {
|
||||
toast.error(t('documentPanel.clearDocuments.failed', { message: result.message }))
|
||||
setConfirmText('')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(t('documentPanel.clearDocuments.success'))
|
||||
|
||||
if (clearCacheOption) {
|
||||
try {
|
||||
await clearCache()
|
||||
toast.success(t('documentPanel.clearDocuments.cacheCleared'))
|
||||
} catch (cacheErr) {
|
||||
toast.error(t('documentPanel.clearDocuments.cacheClearFailed', { error: errorMessage(cacheErr) }))
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh document list if provided
|
||||
if (onDocumentsCleared) {
|
||||
onDocumentsCleared().catch(console.error)
|
||||
}
|
||||
|
||||
// Close dialog after all operations succeed
|
||||
handleOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error(t('documentPanel.clearDocuments.error', { error: errorMessage(err) }))
|
||||
setConfirmText('')
|
||||
} finally {
|
||||
// Clear timeout timer
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
setIsClearing(false)
|
||||
}
|
||||
}, [isConfirmEnabled, isClearing, clearCacheOption, handleOpenChange, t, onDocumentsCleared, CLEAR_TIMEOUT])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" side="bottom" tooltip={t('documentPanel.clearDocuments.tooltip')} size="sm">
|
||||
<EraserIcon/> {t('documentPanel.clearDocuments.button')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-500 dark:text-red-400 font-bold">
|
||||
<AlertTriangleIcon className="h-5 w-5" />
|
||||
{t('documentPanel.clearDocuments.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="pt-2">
|
||||
{t('documentPanel.clearDocuments.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-red-500 dark:text-red-400 font-semibold mb-4">
|
||||
{t('documentPanel.clearDocuments.warning')}
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
{t('documentPanel.clearDocuments.confirm')}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-text" className="text-sm font-medium">
|
||||
{t('documentPanel.clearDocuments.confirmPrompt')}
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-text"
|
||||
value={confirmText}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmText(e.target.value)}
|
||||
placeholder={t('documentPanel.clearDocuments.confirmPlaceholder')}
|
||||
className="w-full"
|
||||
disabled={isClearing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="clear-cache"
|
||||
checked={clearCacheOption}
|
||||
onCheckedChange={(checked: boolean | 'indeterminate') => setClearCacheOption(checked === true)}
|
||||
disabled={isClearing}
|
||||
/>
|
||||
<Label htmlFor="clear-cache" className="text-sm font-medium cursor-pointer">
|
||||
{t('documentPanel.clearDocuments.clearCache')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClear}
|
||||
disabled={!isConfirmEnabled || isClearing}
|
||||
>
|
||||
{isClearing ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('documentPanel.clearDocuments.clearing')}
|
||||
</>
|
||||
) : (
|
||||
t('documentPanel.clearDocuments.confirmButton')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter
|
||||
} from '@/components/ui/Dialog'
|
||||
import Input from '@/components/ui/Input'
|
||||
import { toast } from 'sonner'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { deleteDocuments } from '@/api/lightrag'
|
||||
|
||||
import { TrashIcon, AlertTriangleIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Simple Label component
|
||||
const Label = ({
|
||||
htmlFor,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.LabelHTMLAttributes<HTMLLabelElement>) => (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
interface DeleteDocumentsDialogProps {
|
||||
selectedDocIds: string[]
|
||||
onDocumentsDeleted?: () => Promise<void>
|
||||
}
|
||||
|
||||
export default function DeleteDocumentsDialog({ selectedDocIds, onDocumentsDeleted }: DeleteDocumentsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [deleteFile, setDeleteFile] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [deleteLLMCache, setDeleteLLMCache] = useState(false)
|
||||
const isConfirmEnabled = confirmText.toLowerCase() === 'yes' && !isDeleting
|
||||
|
||||
// Reset state when dialog closes - handled in onOpenChange to avoid setState in effect
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
if (!newOpen) {
|
||||
setConfirmText('')
|
||||
setDeleteFile(false)
|
||||
setDeleteLLMCache(false)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!isConfirmEnabled || selectedDocIds.length === 0) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const result = await deleteDocuments(selectedDocIds, deleteFile, deleteLLMCache)
|
||||
|
||||
if (result.status === 'deletion_started') {
|
||||
toast.success(t('documentPanel.deleteDocuments.success', { count: selectedDocIds.length }))
|
||||
} else if (result.status === 'busy') {
|
||||
toast.error(t('documentPanel.deleteDocuments.busy'))
|
||||
setConfirmText('')
|
||||
setIsDeleting(false)
|
||||
return
|
||||
} else if (result.status === 'not_allowed') {
|
||||
toast.error(t('documentPanel.deleteDocuments.notAllowed'))
|
||||
setConfirmText('')
|
||||
setIsDeleting(false)
|
||||
return
|
||||
} else {
|
||||
toast.error(t('documentPanel.deleteDocuments.failed', { message: result.message }))
|
||||
setConfirmText('')
|
||||
setIsDeleting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh document list if provided
|
||||
if (onDocumentsDeleted) {
|
||||
onDocumentsDeleted().catch(console.error)
|
||||
}
|
||||
|
||||
// Close dialog after successful operation
|
||||
handleOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error(t('documentPanel.deleteDocuments.error', { error: errorMessage(err) }))
|
||||
setConfirmText('')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [isConfirmEnabled, selectedDocIds, deleteFile, deleteLLMCache, handleOpenChange, t, onDocumentsDeleted])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
side="bottom"
|
||||
tooltip={t('documentPanel.deleteDocuments.tooltip', { count: selectedDocIds.length })}
|
||||
size="sm"
|
||||
>
|
||||
<TrashIcon/> {t('documentPanel.deleteDocuments.button')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-500 dark:text-red-400 font-bold">
|
||||
<AlertTriangleIcon className="h-5 w-5" />
|
||||
{t('documentPanel.deleteDocuments.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="pt-2">
|
||||
{t('documentPanel.deleteDocuments.description', { count: selectedDocIds.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-red-500 dark:text-red-400 font-semibold mb-4">
|
||||
{t('documentPanel.deleteDocuments.warning')}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
{t('documentPanel.deleteDocuments.confirm', { count: selectedDocIds.length })}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-text" className="text-sm font-medium">
|
||||
{t('documentPanel.deleteDocuments.confirmPrompt')}
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-text"
|
||||
value={confirmText}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmText(e.target.value)}
|
||||
placeholder={t('documentPanel.deleteDocuments.confirmPlaceholder')}
|
||||
className="w-full"
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="delete-file"
|
||||
checked={deleteFile}
|
||||
onChange={(e) => setDeleteFile(e.target.checked)}
|
||||
disabled={isDeleting}
|
||||
className="h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
|
||||
/>
|
||||
<Label htmlFor="delete-file" className="text-sm font-medium cursor-pointer">
|
||||
{t('documentPanel.deleteDocuments.deleteFileOption')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="delete-llm-cache"
|
||||
checked={deleteLLMCache}
|
||||
onChange={(e) => setDeleteLLMCache(e.target.checked)}
|
||||
disabled={isDeleting}
|
||||
className="h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded"
|
||||
/>
|
||||
<Label htmlFor="delete-llm-cache" className="text-sm font-medium cursor-pointer">
|
||||
{t('documentPanel.deleteDocuments.deleteLLMCacheOption')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={!isConfirmEnabled}
|
||||
>
|
||||
{isDeleting ? t('documentPanel.deleteDocuments.deleting') : t('documentPanel.deleteDocuments.confirmButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { AlignLeft, AlignCenter, AlignRight } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
} from '@/components/ui/Dialog'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { getPipelineStatus, cancelPipeline, PipelineStatusResponse } from '@/api/lightrag'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DialogPosition = 'left' | 'center' | 'right'
|
||||
|
||||
interface PipelineStatusDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export default function PipelineStatusDialog({
|
||||
open,
|
||||
onOpenChange
|
||||
}: PipelineStatusDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [status, setStatus] = useState<PipelineStatusResponse | null>(null)
|
||||
const [position, setPosition] = useState<DialogPosition>('center')
|
||||
const [isUserScrolled, setIsUserScrolled] = useState(false)
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
|
||||
const historyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Reset UI state whenever the controlling open prop changes.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Resetting local dialog UI when the controlling prop changes is intentional.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPosition('center')
|
||||
setIsUserScrolled(false)
|
||||
return
|
||||
}
|
||||
|
||||
setShowCancelConfirm(false)
|
||||
}, [open])
|
||||
|
||||
// Handle scroll position
|
||||
useEffect(() => {
|
||||
const container = historyRef.current
|
||||
if (!container || isUserScrolled) return
|
||||
|
||||
container.scrollTop = container.scrollHeight
|
||||
}, [status?.history_messages, isUserScrolled])
|
||||
|
||||
const handleScroll = () => {
|
||||
const container = historyRef.current
|
||||
if (!container) return
|
||||
|
||||
const isAtBottom = Math.abs(
|
||||
(container.scrollHeight - container.scrollTop) - container.clientHeight
|
||||
) < 1
|
||||
|
||||
if (isAtBottom) {
|
||||
setIsUserScrolled(false)
|
||||
} else {
|
||||
setIsUserScrolled(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh status every 2 seconds
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const data = await getPipelineStatus()
|
||||
setStatus(data)
|
||||
} catch (err) {
|
||||
toast.error(t('documentPanel.pipelineStatus.errors.fetchFailed', { error: errorMessage(err) }))
|
||||
}
|
||||
}
|
||||
|
||||
fetchStatus()
|
||||
const interval = setInterval(fetchStatus, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [open, t])
|
||||
|
||||
// Handle cancel pipeline confirmation
|
||||
const handleConfirmCancel = async () => {
|
||||
setShowCancelConfirm(false)
|
||||
try {
|
||||
const result = await cancelPipeline()
|
||||
if (result.status === 'cancellation_requested') {
|
||||
toast.success(t('documentPanel.pipelineStatus.cancelSuccess'))
|
||||
} else if (result.status === 'not_busy') {
|
||||
toast.info(t('documentPanel.pipelineStatus.cancelNotBusy'))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('documentPanel.pipelineStatus.cancelFailed', { error: errorMessage(err) }))
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if cancel button should be enabled
|
||||
const canCancel = status?.busy === true && !status?.cancellation_requested
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'sm:max-w-[800px] transition-all duration-200 fixed',
|
||||
position === 'left' && '!left-[25%] !translate-x-[-50%] !mx-4',
|
||||
position === 'center' && '!left-1/2 !-translate-x-1/2',
|
||||
position === 'right' && '!left-[75%] !translate-x-[-50%] !mx-4'
|
||||
)}
|
||||
>
|
||||
<DialogDescription className="sr-only">
|
||||
{status?.job_name
|
||||
? `${t('documentPanel.pipelineStatus.jobName')}: ${status.job_name}, ${t('documentPanel.pipelineStatus.progress')}: ${status.cur_batch}/${status.batchs}`
|
||||
: t('documentPanel.pipelineStatus.noActiveJob')
|
||||
}
|
||||
</DialogDescription>
|
||||
<DialogHeader className="flex flex-row items-center">
|
||||
<DialogTitle className="flex-1">
|
||||
{t('documentPanel.pipelineStatus.title')}
|
||||
</DialogTitle>
|
||||
|
||||
{/* Position control buttons */}
|
||||
<div className="flex items-center gap-2 mr-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6',
|
||||
position === 'left' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'
|
||||
)}
|
||||
onClick={() => setPosition('left')}
|
||||
>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6',
|
||||
position === 'center' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'
|
||||
)}
|
||||
onClick={() => setPosition('center')}
|
||||
>
|
||||
<AlignCenter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6',
|
||||
position === 'right' && 'bg-zinc-200 text-zinc-800 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600'
|
||||
)}
|
||||
onClick={() => setPosition('right')}
|
||||
>
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Status Content */}
|
||||
<div className="space-y-4 pt-4">
|
||||
{/* Pipeline Status - with cancel button */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
{/* Left side: Status indicators */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.busy')}:</div>
|
||||
<div className={`h-2 w-2 rounded-full ${status?.busy ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.requestPending')}:</div>
|
||||
<div className={`h-2 w-2 rounded-full ${status?.request_pending ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
</div>
|
||||
{/* Only show cancellation status when it's requested */}
|
||||
{status?.cancellation_requested && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.cancellationRequested')}:</div>
|
||||
<div className="h-2 w-2 rounded-full bg-red-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Cancel button - only show when pipeline is busy */}
|
||||
{status?.busy && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!canCancel}
|
||||
onClick={() => setShowCancelConfirm(true)}
|
||||
title={
|
||||
status?.cancellation_requested
|
||||
? t('documentPanel.pipelineStatus.cancelInProgress')
|
||||
: t('documentPanel.pipelineStatus.cancelTooltip')
|
||||
}
|
||||
>
|
||||
{t('documentPanel.pipelineStatus.cancelButton')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job Information */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<div>{t('documentPanel.pipelineStatus.jobName')}: {status?.job_name || '-'}</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('documentPanel.pipelineStatus.startTime')}: {status?.job_start
|
||||
? new Date(status.job_start).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric'
|
||||
})
|
||||
: '-'}</span>
|
||||
<span>{t('documentPanel.pipelineStatus.progress')}: {status ? `${status.cur_batch}/${status.batchs} ${t('documentPanel.pipelineStatus.unit')}` : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History Messages */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t('documentPanel.pipelineStatus.pipelineMessages')}:</div>
|
||||
<div
|
||||
ref={historyRef}
|
||||
onScroll={handleScroll}
|
||||
className="font-mono text-xs rounded-md bg-zinc-800 text-zinc-100 p-3 overflow-y-auto overflow-x-hidden min-h-[7.5em] max-h-[40vh]"
|
||||
>
|
||||
{status?.history_messages?.length ? (
|
||||
status.history_messages.map((msg, idx) => (
|
||||
<div key={idx} className="whitespace-pre-wrap break-all">{msg}</div>
|
||||
))
|
||||
) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Cancel Confirmation Dialog */}
|
||||
<Dialog open={showCancelConfirm} onOpenChange={setShowCancelConfirm}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('documentPanel.pipelineStatus.cancelConfirmTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('documentPanel.pipelineStatus.cancelConfirmDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCancelConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmCancel}
|
||||
>
|
||||
{t('documentPanel.pipelineStatus.cancelConfirmButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { FileRejection } from 'react-dropzone'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/Dialog'
|
||||
import FileUploader from '@/components/ui/FileUploader'
|
||||
import { toast } from 'sonner'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { uploadDocument } from '@/api/lightrag'
|
||||
|
||||
import { UploadIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UploadDocumentsDialogProps {
|
||||
onDocumentsUploaded?: () => Promise<void>
|
||||
/**
|
||||
* Fired once per batch as soon as the first file is accepted by the server.
|
||||
* Lets the parent start its activity probe as early as possible (rather
|
||||
* than waiting for the whole sequential batch to finish).
|
||||
*/
|
||||
onUploadBatchAccepted?: () => void
|
||||
}
|
||||
|
||||
export default function UploadDocumentsDialog({
|
||||
onDocumentsUploaded,
|
||||
onUploadBatchAccepted
|
||||
}: UploadDocumentsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [progresses, setProgresses] = useState<Record<string, number>>({})
|
||||
const [fileErrors, setFileErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const handleRejectedFiles = useCallback(
|
||||
(rejectedFiles: FileRejection[]) => {
|
||||
// Process rejected files and add them to fileErrors
|
||||
rejectedFiles.forEach(({ file, errors }) => {
|
||||
// Get the first error message
|
||||
let errorMsg = errors[0]?.message || t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name })
|
||||
|
||||
// Simplify error message for unsupported file types
|
||||
if (errorMsg.includes('file-invalid-type')) {
|
||||
errorMsg = t('documentPanel.uploadDocuments.fileUploader.unsupportedType')
|
||||
}
|
||||
|
||||
// Set progress to 100% to display error message
|
||||
setProgresses((pre) => ({
|
||||
...pre,
|
||||
[file.name]: 100
|
||||
}))
|
||||
|
||||
// Add error message to fileErrors
|
||||
setFileErrors(prev => ({
|
||||
...prev,
|
||||
[file.name]: errorMsg
|
||||
}))
|
||||
})
|
||||
},
|
||||
[setProgresses, setFileErrors, t]
|
||||
)
|
||||
|
||||
const handleDocumentsUpload = useCallback(
|
||||
async (filesToUpload: File[]) => {
|
||||
setIsUploading(true)
|
||||
let hasSuccessfulUpload = false
|
||||
|
||||
// Only clear errors for files that are being uploaded, keep errors for rejected files
|
||||
setFileErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
filesToUpload.forEach(file => {
|
||||
delete newErrors[file.name];
|
||||
});
|
||||
return newErrors;
|
||||
});
|
||||
|
||||
// Show uploading toast
|
||||
const toastId = toast.loading(t('documentPanel.uploadDocuments.batch.uploading'))
|
||||
|
||||
try {
|
||||
// Track errors locally to ensure we have the final state
|
||||
const uploadErrors: Record<string, string> = {}
|
||||
let batchProbeTriggered = false
|
||||
|
||||
// Create a collator that supports Chinese sorting
|
||||
const collator = new Intl.Collator(['zh-CN', 'en'], {
|
||||
sensitivity: 'accent', // consider basic characters, accents, and case
|
||||
numeric: true // enable numeric sorting, e.g., "File 10" will be after "File 2"
|
||||
});
|
||||
const sortedFiles = [...filesToUpload].sort((a, b) =>
|
||||
collator.compare(a.name, b.name)
|
||||
);
|
||||
|
||||
// Upload files in sequence, not parallel
|
||||
for (const file of sortedFiles) {
|
||||
try {
|
||||
// Initialize upload progress
|
||||
setProgresses((pre) => ({
|
||||
...pre,
|
||||
[file.name]: 0
|
||||
}))
|
||||
|
||||
const result = await uploadDocument(file, (percentCompleted: number) => {
|
||||
console.debug(t('documentPanel.uploadDocuments.single.uploading', { name: file.name, percent: percentCompleted }))
|
||||
setProgresses((pre) => ({
|
||||
...pre,
|
||||
[file.name]: percentCompleted
|
||||
}))
|
||||
})
|
||||
|
||||
if (result.status !== 'success') {
|
||||
uploadErrors[file.name] = result.message
|
||||
setFileErrors(prev => ({
|
||||
...prev,
|
||||
[file.name]: result.message
|
||||
}))
|
||||
} else {
|
||||
// Mark that we had at least one successful upload
|
||||
hasSuccessfulUpload = true
|
||||
if (!batchProbeTriggered) {
|
||||
batchProbeTriggered = true
|
||||
onUploadBatchAccepted?.()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Upload failed for ${file.name}:`, err)
|
||||
|
||||
// Handle HTTP errors, including 400 errors
|
||||
let errorMsg = errorMessage(err)
|
||||
const duplicateFileMsg = t('documentPanel.uploadDocuments.fileUploader.duplicateFile')
|
||||
|
||||
// If it's an axios error with response data, try to extract more detailed error info
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosError = err as { response?: { status: number, data?: { detail?: string } } }
|
||||
const status = axiosError.response?.status
|
||||
const detail = axiosError.response?.data?.detail
|
||||
if (status === 409) {
|
||||
// Server now rejects same-name uploads with HTTP 409 instead of
|
||||
// returning a 200 ``status="duplicated"`` payload. Map the most
|
||||
// common cases (existing record / file in INPUT dir) back to the
|
||||
// dedicated "duplicate file" UI affordance, and surface other
|
||||
// 409 reasons (pipeline busy / scanning) verbatim from the
|
||||
// server detail so users can tell why they were rejected.
|
||||
if (
|
||||
typeof detail === 'string' &&
|
||||
(/already contains/i.test(detail) || /Status:/i.test(detail))
|
||||
) {
|
||||
errorMsg = duplicateFileMsg
|
||||
} else {
|
||||
errorMsg = detail || errorMsg
|
||||
}
|
||||
} else if (status === 400) {
|
||||
errorMsg = detail || errorMsg
|
||||
}
|
||||
|
||||
// Set progress to 100% to display error message
|
||||
setProgresses((pre) => ({
|
||||
...pre,
|
||||
[file.name]: 100
|
||||
}))
|
||||
}
|
||||
|
||||
// Record error message in both local tracking and state
|
||||
uploadErrors[file.name] = errorMsg
|
||||
setFileErrors(prev => ({
|
||||
...prev,
|
||||
[file.name]: errorMsg
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any files failed to upload using our local tracking
|
||||
const hasErrors = Object.keys(uploadErrors).length > 0
|
||||
|
||||
// Update toast status
|
||||
if (hasErrors) {
|
||||
toast.error(t('documentPanel.uploadDocuments.batch.error'), { id: toastId })
|
||||
} else {
|
||||
toast.success(t('documentPanel.uploadDocuments.batch.success'), { id: toastId })
|
||||
}
|
||||
|
||||
// Only update if at least one file was uploaded successfully
|
||||
if (hasSuccessfulUpload) {
|
||||
// Refresh document list
|
||||
if (onDocumentsUploaded) {
|
||||
onDocumentsUploaded().catch(err => {
|
||||
console.error('Error refreshing documents:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unexpected error during upload:', err)
|
||||
toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }), { id: toastId })
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
},
|
||||
[setIsUploading, setProgresses, setFileErrors, t, onDocumentsUploaded, onUploadBatchAccepted]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (isUploading) {
|
||||
return
|
||||
}
|
||||
if (!open) {
|
||||
setProgresses({})
|
||||
setFileErrors({})
|
||||
}
|
||||
setOpen(open)
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" side="bottom" tooltip={t('documentPanel.uploadDocuments.tooltip')} size="sm">
|
||||
<UploadIcon /> {t('documentPanel.uploadDocuments.button')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('documentPanel.uploadDocuments.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('documentPanel.uploadDocuments.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FileUploader
|
||||
maxFileCount={Infinity}
|
||||
maxSize={200 * 1024 * 1024}
|
||||
description={t('documentPanel.uploadDocuments.fileTypes')}
|
||||
onUpload={handleDocumentsUpload}
|
||||
onReject={handleRejectedFiles}
|
||||
progresses={progresses}
|
||||
fileErrors={fileErrors}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
|
||||
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
|
||||
import PropertyEditDialog from './PropertyEditDialog'
|
||||
import MergeDialog from './MergeDialog'
|
||||
|
||||
const createErrorWithCause = (message: string, cause: unknown): Error => {
|
||||
const error = new Error(message) as Error & { cause?: unknown }
|
||||
error.cause = cause
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the EditablePropertyRow component props
|
||||
*/
|
||||
interface EditablePropertyRowProps {
|
||||
name: string // Property name to display and edit
|
||||
value: any // Initial value of the property
|
||||
onClick?: () => void // Optional click handler for the property value
|
||||
nodeId?: string // ID of the node (for node type)
|
||||
entityId?: string // ID of the entity (for node type)
|
||||
edgeId?: string // ID of the edge (for edge type)
|
||||
dynamicId?: string
|
||||
entityType?: 'node' | 'edge' // Type of graph entity
|
||||
sourceId?: string // Source node ID (for edge type)
|
||||
targetId?: string // Target node ID (for edge type)
|
||||
onValueChange?: (newValue: any) => void // Optional callback when value changes
|
||||
isEditable?: boolean // Whether this property can be edited
|
||||
tooltip?: string // Optional tooltip to display on hover
|
||||
pipelineBusy?: boolean // When true, hide edit entry & disable save (pipeline writing)
|
||||
}
|
||||
|
||||
/**
|
||||
* EditablePropertyRow component that supports editing property values
|
||||
* This component is used in the graph properties panel to display and edit entity properties
|
||||
*/
|
||||
const EditablePropertyRow = ({
|
||||
name,
|
||||
value: initialValue,
|
||||
onClick,
|
||||
nodeId,
|
||||
edgeId,
|
||||
entityId,
|
||||
dynamicId,
|
||||
entityType,
|
||||
sourceId,
|
||||
targetId,
|
||||
onValueChange,
|
||||
isEditable = false,
|
||||
tooltip,
|
||||
pipelineBusy = false
|
||||
}: EditablePropertyRowProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [currentValue, setCurrentValue] = useState(initialValue)
|
||||
const [draftValue, setDraftValue] = useState(String(initialValue))
|
||||
const [draftAllowMerge, setDraftAllowMerge] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [mergeDialogOpen, setMergeDialogOpen] = useState(false)
|
||||
const [mergeDialogInfo, setMergeDialogInfo] = useState<{
|
||||
targetEntity: string
|
||||
sourceEntity: string
|
||||
} | null>(null)
|
||||
|
||||
// Sync currentValue when the incoming initialValue prop changes.
|
||||
// Uses a render-time previous-value comparison instead of useEffect to avoid
|
||||
// cascading renders flagged by react-hooks/set-state-in-effect.
|
||||
const [previousInitialValue, setPreviousInitialValue] = useState(initialValue)
|
||||
if (initialValue !== previousInitialValue) {
|
||||
setPreviousInitialValue(initialValue)
|
||||
setCurrentValue(initialValue)
|
||||
}
|
||||
|
||||
const handleEditClick = () => {
|
||||
if (pipelineBusy) return
|
||||
if (isEditable && !isEditing) {
|
||||
setDraftValue(String(currentValue))
|
||||
setDraftAllowMerge(false)
|
||||
setIsEditing(true)
|
||||
setErrorMessage(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false)
|
||||
setErrorMessage(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const value = draftValue.trim()
|
||||
const allowMerge = draftAllowMerge
|
||||
|
||||
if (value === '') {
|
||||
return
|
||||
}
|
||||
|
||||
if (isSubmitting || value === String(currentValue)) {
|
||||
setIsEditing(false)
|
||||
setErrorMessage(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
if (entityType === 'node' && entityId && nodeId) {
|
||||
let updatedData = { [name]: value }
|
||||
|
||||
if (name === 'entity_id') {
|
||||
if (!allowMerge) {
|
||||
const exists = await checkEntityNameExists(value)
|
||||
if (exists) {
|
||||
const errorMsg = t('graphPanel.propertiesView.errors.duplicateName')
|
||||
setErrorMessage(errorMsg)
|
||||
toast.error(errorMsg)
|
||||
return
|
||||
}
|
||||
}
|
||||
updatedData = { 'entity_name': value }
|
||||
}
|
||||
|
||||
const response = await updateEntity(entityId, updatedData, true, allowMerge)
|
||||
const operationSummary = response.operation_summary
|
||||
const operationStatus = operationSummary?.operation_status || 'complete_success'
|
||||
const finalValue = operationSummary?.final_entity ?? value
|
||||
|
||||
// Handle different operation statuses
|
||||
if (operationStatus === 'success') {
|
||||
if (operationSummary?.merged) {
|
||||
// Node was successfully merged into an existing entity
|
||||
setMergeDialogInfo({
|
||||
targetEntity: finalValue,
|
||||
sourceEntity: entityId,
|
||||
})
|
||||
setMergeDialogOpen(true)
|
||||
|
||||
// Remove old entity name from search history
|
||||
SearchHistoryManager.removeLabel(entityId)
|
||||
|
||||
// Note: Search Label update is deferred until user clicks refresh button in merge dialog
|
||||
|
||||
toast.success(t('graphPanel.propertiesView.success.entityMerged'))
|
||||
} else {
|
||||
// Node was updated/renamed normally
|
||||
try {
|
||||
const graphValue = name === 'entity_id' ? finalValue : value
|
||||
await useGraphStore
|
||||
.getState()
|
||||
.updateNodeAndSelect(nodeId, entityId, name, graphValue)
|
||||
} catch (error) {
|
||||
console.error('Error updating node in graph:', error)
|
||||
throw createErrorWithCause('Failed to update node in graph', error)
|
||||
}
|
||||
|
||||
// Update search history: remove old name, add new name
|
||||
if (name === 'entity_id') {
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
|
||||
SearchHistoryManager.removeLabel(entityId)
|
||||
SearchHistoryManager.addToHistory(finalValue)
|
||||
|
||||
// Trigger dropdown refresh to show updated search history
|
||||
useSettingsStore.getState().triggerSearchLabelDropdownRefresh()
|
||||
|
||||
// If current queryLabel is the old entity name, update to new name
|
||||
if (currentLabel === entityId) {
|
||||
useSettingsStore.getState().setQueryLabel(finalValue)
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
|
||||
}
|
||||
|
||||
// Update local state and notify parent component
|
||||
// For entity_id updates, use finalValue (which may be different due to merging)
|
||||
// For other properties, use the original value the user entered
|
||||
const valueToSet = name === 'entity_id' ? finalValue : value
|
||||
setCurrentValue(valueToSet)
|
||||
onValueChange?.(valueToSet)
|
||||
|
||||
} else if (operationStatus === 'partial_success') {
|
||||
// Partial success: update succeeded but merge failed
|
||||
// Do NOT update graph data to keep frontend in sync with backend
|
||||
const mergeError = operationSummary?.merge_error || 'Unknown error'
|
||||
|
||||
const errorMsg = t('graphPanel.propertiesView.errors.updateSuccessButMergeFailed', {
|
||||
error: mergeError
|
||||
})
|
||||
setErrorMessage(errorMsg)
|
||||
toast.error(errorMsg)
|
||||
// Do not update currentValue or call onValueChange
|
||||
return
|
||||
|
||||
} else {
|
||||
// Complete failure or unknown status
|
||||
// Check if this was a merge attempt or just a regular update
|
||||
if (operationSummary?.merge_status === 'failed') {
|
||||
// Merge operation was attempted but failed
|
||||
const mergeError = operationSummary?.merge_error || 'Unknown error'
|
||||
const errorMsg = t('graphPanel.propertiesView.errors.mergeFailed', {
|
||||
error: mergeError
|
||||
})
|
||||
setErrorMessage(errorMsg)
|
||||
toast.error(errorMsg)
|
||||
} else {
|
||||
// Regular update failed (no merge involved)
|
||||
const errorMsg = t('graphPanel.propertiesView.errors.updateFailed')
|
||||
setErrorMessage(errorMsg)
|
||||
toast.error(errorMsg)
|
||||
}
|
||||
// Do not update currentValue or call onValueChange
|
||||
return
|
||||
}
|
||||
} else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) {
|
||||
const updatedData = { [name]: value }
|
||||
await updateRelation(sourceId, targetId, updatedData)
|
||||
try {
|
||||
await useGraphStore.getState().updateEdgeAndSelect(edgeId, dynamicId, sourceId, targetId, name, value)
|
||||
} catch (error) {
|
||||
console.error(`Error updating edge ${sourceId}->${targetId} in graph:`, error)
|
||||
throw createErrorWithCause('Failed to update edge in graph', error)
|
||||
}
|
||||
toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
|
||||
setCurrentValue(value)
|
||||
onValueChange?.(value)
|
||||
}
|
||||
|
||||
setIsEditing(false)
|
||||
} catch (error) {
|
||||
console.error('Error updating property:', error)
|
||||
const errorMsg = error instanceof Error ? error.message : t('graphPanel.propertiesView.errors.updateFailed')
|
||||
setErrorMessage(errorMsg)
|
||||
toast.error(errorMsg)
|
||||
return
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMergeRefresh = (useMergedStart: boolean) => {
|
||||
const info = mergeDialogInfo
|
||||
const graphState = useGraphStore.getState()
|
||||
const settingsState = useSettingsStore.getState()
|
||||
const currentLabel = settingsState.queryLabel
|
||||
|
||||
// Clear graph state
|
||||
graphState.clearSelection()
|
||||
graphState.setGraphDataFetchAttempted(false)
|
||||
graphState.setLastSuccessfulQueryLabel('')
|
||||
|
||||
if (useMergedStart && info?.targetEntity) {
|
||||
// Use merged entity as new start point (might already be set in handleSave)
|
||||
settingsState.setQueryLabel(info.targetEntity)
|
||||
} else {
|
||||
// Keep current start point - refresh by resetting and restoring label
|
||||
// This handles the case where user wants to stay with current label
|
||||
settingsState.setQueryLabel('')
|
||||
setTimeout(() => {
|
||||
settingsState.setQueryLabel(currentLabel)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
// Force graph re-render and reset zoom/scale (same as refresh button behavior)
|
||||
graphState.incrementGraphDataVersion()
|
||||
|
||||
setMergeDialogOpen(false)
|
||||
setMergeDialogInfo(null)
|
||||
toast.info(t('graphPanel.propertiesView.mergeDialog.refreshing'))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<PropertyName name={name} />
|
||||
{!pipelineBusy && <EditIcon onClick={handleEditClick} />}:
|
||||
<PropertyValue
|
||||
value={currentValue}
|
||||
onClick={onClick}
|
||||
tooltip={tooltip || (typeof currentValue === 'string' ? currentValue : JSON.stringify(currentValue, null, 2))}
|
||||
/>
|
||||
<PropertyEditDialog
|
||||
isOpen={isEditing}
|
||||
onClose={handleCancel}
|
||||
onSave={handleSave}
|
||||
propertyName={name}
|
||||
value={draftValue}
|
||||
allowMerge={draftAllowMerge}
|
||||
onValueChange={setDraftValue}
|
||||
onAllowMergeChange={setDraftAllowMerge}
|
||||
isSubmitting={isSubmitting}
|
||||
errorMessage={errorMessage}
|
||||
disableSave={pipelineBusy}
|
||||
/>
|
||||
|
||||
<MergeDialog
|
||||
mergeDialogOpen={mergeDialogOpen}
|
||||
mergeDialogInfo={mergeDialogInfo}
|
||||
onOpenChange={(open) => {
|
||||
setMergeDialogOpen(open)
|
||||
if (!open) {
|
||||
setMergeDialogInfo(null)
|
||||
}
|
||||
}}
|
||||
onRefresh={handleMergeRefresh}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditablePropertyRow
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCamera, useSigma } from '@react-sigma/core'
|
||||
import { useEffect } from 'react'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
/**
|
||||
* Component that highlights a node and centers the camera on it.
|
||||
*/
|
||||
const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) => {
|
||||
const sigma = useSigma()
|
||||
const { gotoNode } = useCamera()
|
||||
|
||||
/**
|
||||
* When the selected item changes, highlighted the node and center the camera on it.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const graph = sigma.getGraph();
|
||||
|
||||
if (move) {
|
||||
if (node && graph.hasNode(node)) {
|
||||
try {
|
||||
graph.setNodeAttribute(node, 'highlighted', true);
|
||||
gotoNode(node);
|
||||
} catch (error) {
|
||||
console.error('Error focusing on node:', error);
|
||||
}
|
||||
} else {
|
||||
// If no node is selected but move is true, reset to default view
|
||||
sigma.setCustomBBox(null);
|
||||
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 });
|
||||
}
|
||||
useGraphStore.getState().setMoveToSelectedNode(false);
|
||||
} else if (node && graph.hasNode(node)) {
|
||||
try {
|
||||
graph.setNodeAttribute(node, 'highlighted', true);
|
||||
} catch (error) {
|
||||
console.error('Error highlighting node:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (node && graph.hasNode(node)) {
|
||||
try {
|
||||
graph.setNodeAttribute(node, 'highlighted', false);
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up node highlight:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [node, move, sigma, gotoNode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default FocusOnNode
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useFullScreen } from '@react-sigma/core'
|
||||
import { MaximizeIcon, MinimizeIcon } from 'lucide-react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Component that toggles full screen mode.
|
||||
*/
|
||||
const FullScreenControl = () => {
|
||||
const { isFullScreen, toggle } = useFullScreen()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFullScreen ? (
|
||||
<Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.windowed')} size="icon">
|
||||
<MinimizeIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.fullScreen')} size="icon">
|
||||
<MaximizeIcon />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullScreenControl
|
||||
@@ -0,0 +1,502 @@
|
||||
import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
||||
import { AbstractGraph } from 'graphology-types'
|
||||
import forceAtlas2 from 'graphology-layout-forceatlas2'
|
||||
import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { EdgeType, NodeType } from '@/hooks/useLightragGraph'
|
||||
import useIsDarkMode from '@/hooks/useIsDarkMode'
|
||||
import * as Constants from '@/lib/constants'
|
||||
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
|
||||
if (ev.type.startsWith('mouse')) {
|
||||
if ((ev as MouseEvent).buttons !== 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {
|
||||
const sigma = useSigma<NodeType, EdgeType>()
|
||||
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
|
||||
const setSettings = useSetSettings<NodeType, EdgeType>()
|
||||
|
||||
const isDarkTheme = useIsDarkMode()
|
||||
const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
|
||||
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
|
||||
const renderLabels = useSettingsStore.use.showNodeLabel()
|
||||
const minEdgeSize = useSettingsStore.use.minEdgeSize()
|
||||
const maxEdgeSize = useSettingsStore.use.maxEdgeSize()
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const selectedEdge = useGraphStore.use.selectedEdge()
|
||||
const focusedEdge = useGraphStore.use.focusedEdge()
|
||||
const sigmaGraph = useGraphStore.use.sigmaGraph()
|
||||
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
|
||||
|
||||
// Mirror GraphViewer's gating: above EDGE_PERF_LIMIT the sigma instance is
|
||||
// (re)built without the edge picking buffer, so edge events cannot fire even
|
||||
// though the user setting may be on. Use this — not the raw setting — for
|
||||
// event registration and the reducers, so we never run the costly edge-focus
|
||||
// path against a graph that can't actually pick edges.
|
||||
const effectiveEdgeEvents = enableEdgeEvents && graphEdgeCount <= Constants.EDGE_PERF_LIMIT
|
||||
|
||||
// The graph the initial FA2 layout has already been run for. Used to run the
|
||||
// layout once PER GRAPH, not once per sigma instance (a theme change rebuilds
|
||||
// the instance and re-runs the bind effect with the SAME graph).
|
||||
const laidOutGraphRef = useRef<unknown>(null)
|
||||
|
||||
// Last (sigma instance, curved decision) the edge-type effect applied. A
|
||||
// rebuild (theme toggle / edge-events gating) creates a fresh sigma whose
|
||||
// defaultEdgeType reverts to its construction default ('rect'), so we must
|
||||
// re-apply when the INSTANCE changed too — not only when the decision flips.
|
||||
const edgeTypeRef = useRef<{ sigma: unknown; curved: boolean } | null>(null)
|
||||
|
||||
/**
|
||||
* When component mounts or the graph changes
|
||||
* => bind graph to sigma and run the initial layout
|
||||
*
|
||||
* PERFORMANCE: the initial ForceAtlas2 layout runs in a WEB WORKER with
|
||||
* settings inferred from the graph size (inferSettings enables Barnes-Hut
|
||||
* above ~2k nodes). The previous implementation called the synchronous
|
||||
* `assign()` on the main thread with DEFAULT settings — i.e. without
|
||||
* Barnes-Hut, where every iteration is O(V²) pairwise repulsion. At 73k
|
||||
* nodes that is billions of operations per iteration, times 15 iterations,
|
||||
* on the UI thread: the tab froze for minutes after every load.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!(sigmaGraph && sigma)) return
|
||||
|
||||
try {
|
||||
if (typeof sigma.setGraph === 'function') {
|
||||
sigma.setGraph(sigmaGraph as unknown as AbstractGraph<NodeType, EdgeType>)
|
||||
console.log('Binding graph to sigma instance')
|
||||
} else {
|
||||
console.error('Sigma missing setGraph function')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting graph on sigma instance:', error)
|
||||
}
|
||||
|
||||
if (sigmaGraph.order === 0) return
|
||||
|
||||
// Run the initial layout once PER GRAPH. A theme toggle recreates the sigma
|
||||
// instance (settings change -> SigmaContainer rebuild) and re-runs this
|
||||
// effect with the SAME graph, which already carries settled positions —
|
||||
// re-running FA2 there would replay the relaxation animation on every theme
|
||||
// switch. Only (re)bind in that case; skip the layout.
|
||||
//
|
||||
// The ref is marked "laid out" only when the budget timer fires (layout
|
||||
// settled), NOT before start: if a rebuild interrupts the layout mid-run
|
||||
// (e.g. edge-events gating crosses the threshold during the first load),
|
||||
// the new instance re-runs FA2 from where it was instead of freezing on a
|
||||
// half-relaxed layout. Rebuilds after settling still match and skip.
|
||||
if (laidOutGraphRef.current === sigmaGraph) return
|
||||
|
||||
let layout: { start: () => void; stop: () => void; kill: () => void } | null = null
|
||||
try {
|
||||
layout = new FA2LayoutSupervisor(sigmaGraph as never, {
|
||||
settings: forceAtlas2.inferSettings(sigmaGraph.order)
|
||||
})
|
||||
layout.start()
|
||||
// Become the single layout owner. If a previous layout is somehow still
|
||||
// registered, this kills it so only one supervisor mutates coordinates.
|
||||
useGraphStore.getState().setActiveLayoutSupervisor(layout)
|
||||
console.log(`FA2 worker layout started (${sigmaGraph.order} nodes)`)
|
||||
} catch (error) {
|
||||
console.error('Error starting FA2 worker layout:', error)
|
||||
return
|
||||
}
|
||||
|
||||
// Time budget instead of an iteration count: the UI stays interactive
|
||||
// while the layout settles in the background, then we stop it.
|
||||
const budgetMs = Constants.workerBudgetMs(sigmaGraph.order)
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
layout?.stop()
|
||||
// Mark this graph as laid out only now (settled), so a rebuild during
|
||||
// the budget window re-runs the layout rather than skipping it.
|
||||
laidOutGraphRef.current = sigmaGraph
|
||||
console.log('FA2 worker layout stopped after budget')
|
||||
// Release the shared slot so the store invariant "activeLayoutSupervisor
|
||||
// != null => a layout is running" holds (the budget just stopped this
|
||||
// one); no-op for the slot if a manually selected layout already took over.
|
||||
useGraphStore.getState().releaseLayoutSupervisor(layout)
|
||||
// Clear any stale custom bbox (set by node dragging) and refresh so
|
||||
// the camera normalization fits the settled layout.
|
||||
sigma.setCustomBBox(null)
|
||||
sigma.refresh()
|
||||
} catch (error) {
|
||||
console.error('Error stopping FA2 worker layout:', error)
|
||||
}
|
||||
}, budgetMs)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
// Release the slot if we still own it (a manually selected worker layout
|
||||
// may have taken over and already killed `layout`); otherwise just kill
|
||||
// our own supervisor without disturbing the newer layout.
|
||||
useGraphStore.getState().releaseLayoutSupervisor(layout)
|
||||
}
|
||||
}, [sigma, sigmaGraph])
|
||||
|
||||
/**
|
||||
* Ensure the sigma instance is set in the store
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sigma) {
|
||||
const currentInstance = useGraphStore.getState().sigmaInstance
|
||||
// Update when the instance CHANGED, not only when it's unset. A theme
|
||||
// toggle, an effectiveEdgeEvents flip, or crossing the edge threshold
|
||||
// rebuilds the SigmaContainer (old instance killed, new one created); if
|
||||
// we only wrote on an empty store the killed instance would linger there
|
||||
// and consumers (e.g. expand reading sigmaInstance.getCamera()) would act
|
||||
// on a dead Sigma.
|
||||
if (currentInstance !== sigma) {
|
||||
console.log('Setting sigma instance from GraphControl')
|
||||
useGraphStore.getState().setSigmaInstance(sigma)
|
||||
}
|
||||
}
|
||||
}, [sigma])
|
||||
|
||||
/**
|
||||
* With hideEdgesOnMove, edges are skipped while the camera is moving. sigma's
|
||||
* built-in post-drag refresh (mouse handleUp) fires on a setTimeout(0), but a
|
||||
* drag with inertia is still animating then, so it renders WITHOUT edges and
|
||||
* nothing refreshes once movement settles — edges stay hidden until the next
|
||||
* interaction (the "edges vanish after a small pan and don't come back" bug;
|
||||
* a stage click doesn't help because it isn't a drag, but a hover re-renders).
|
||||
*
|
||||
* Watch the camera's `updated` event AND the mouse captor's `mouseup`, and
|
||||
* once things go quiet, refresh ONLY when sigma is truly idle. We mirror
|
||||
* sigma's exact `moving` condition (camera.isAnimated() || mouse.isMoving ||
|
||||
* draggedEvents || wheelDirection); if any is still set when the timer fires,
|
||||
* we re-arm and wait, so the refresh always lands on a frame where edges are
|
||||
* actually drawn. The mouseup trigger only fires when the release ends a drag
|
||||
* (draggedEvents > 0): a plain click never hides edges, so refreshing on it
|
||||
* would be a wasted full re-indexation. Between them, the camera path covers
|
||||
* every camera-animation-driven move (pan inertia, zoom, moveToSelectedNode)
|
||||
* and the mouseup path covers drags with no post-release animation, so any
|
||||
* selection-change render that lands on a "moving" frame is recovered by
|
||||
* whichever of the two is concurrently active.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!sigma) return
|
||||
const camera = sigma.getCamera()
|
||||
const mouse = sigma.getMouseCaptor()
|
||||
let timer: number | null = null
|
||||
const stillMoving = () =>
|
||||
camera.isAnimated() ||
|
||||
mouse.isMoving ||
|
||||
mouse.draggedEvents > 0 ||
|
||||
mouse.currentWheelDirection !== 0
|
||||
const refreshWhenIdle = () => {
|
||||
if (timer !== null) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null
|
||||
if (stillMoving()) {
|
||||
refreshWhenIdle() // not settled yet — keep waiting
|
||||
return
|
||||
}
|
||||
try {
|
||||
sigma.refresh()
|
||||
} catch {
|
||||
/* sigma instance already killed */
|
||||
}
|
||||
}, 80)
|
||||
}
|
||||
// Only let a *drag* release re-trigger the idle refresh. A plain click never
|
||||
// hides edges (no movement), so refreshing on it is pure wasted full
|
||||
// re-indexation. draggedEvents is still set at this point — sigma resets it
|
||||
// in its own post-mouseup setTimeout(0), which runs after this handler.
|
||||
const refreshOnDragEnd = () => {
|
||||
if (mouse.draggedEvents > 0) refreshWhenIdle()
|
||||
}
|
||||
camera.on('updated', refreshWhenIdle)
|
||||
mouse.on('mouseup', refreshOnDragEnd)
|
||||
return () => {
|
||||
if (timer !== null) window.clearTimeout(timer)
|
||||
camera.removeListener('updated', refreshWhenIdle)
|
||||
mouse.removeListener('mouseup', refreshOnDragEnd)
|
||||
}
|
||||
}, [sigma])
|
||||
|
||||
/**
|
||||
* Adapt edge geometry to graph size: curves for small graphs (nicer to read),
|
||||
* straight rectangles above EDGE_PERF_LIMIT (curve tessellation is costly).
|
||||
*
|
||||
* Edges carry no per-edge `type`, so switching `defaultEdgeType` + a full
|
||||
* `refresh()` (which re-adds edges through applyEdgeDefaults) re-selects the
|
||||
* program for the whole graph without touching attributes or rebuilding.
|
||||
*
|
||||
* The ref tracks BOTH the sigma instance and the decision: re-apply when the
|
||||
* instance changed (a rebuild resets defaultEdgeType to 'rect') OR when the
|
||||
* curved/straight decision flipped; skip otherwise so routine expand/prune
|
||||
* within one band don't trigger a full refresh.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!sigma) return
|
||||
const curved = graphEdgeCount > 0 && graphEdgeCount <= Constants.EDGE_PERF_LIMIT
|
||||
const prev = edgeTypeRef.current
|
||||
if (prev && prev.sigma === sigma && prev.curved === curved) return
|
||||
edgeTypeRef.current = { sigma, curved }
|
||||
setSettings({ defaultEdgeType: curved ? 'curvedNoArrow' : 'rect' })
|
||||
try {
|
||||
sigma.refresh()
|
||||
} catch {
|
||||
/* sigma instance already killed */
|
||||
}
|
||||
}, [sigma, graphEdgeCount, setSettings])
|
||||
|
||||
/**
|
||||
* When edge events become gated off (count crossed above EDGE_PERF_LIMIT),
|
||||
* drop any residual edge focus/selection so the UI (property panel, reducers)
|
||||
* doesn't keep a now-unpickable edge highlighted. Node selection is untouched.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (effectiveEdgeEvents) return
|
||||
const { selectedEdge, focusedEdge, setSelectedEdge, setFocusedEdge } = useGraphStore.getState()
|
||||
if (selectedEdge !== null) setSelectedEdge(null)
|
||||
if (focusedEdge !== null) setFocusedEdge(null)
|
||||
}, [effectiveEdgeEvents])
|
||||
|
||||
/**
|
||||
* When component mounts
|
||||
* => register events
|
||||
*/
|
||||
useEffect(() => {
|
||||
const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =
|
||||
useGraphStore.getState()
|
||||
|
||||
type NodeEvent = { node: string; event: { original: MouseEvent | TouchEvent } }
|
||||
type EdgeEvent = { edge: string; event: { original: MouseEvent | TouchEvent } }
|
||||
|
||||
const events: Record<string, any> = {
|
||||
enterNode: (event: NodeEvent) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
const graph = sigma.getGraph()
|
||||
if (graph.hasNode(event.node)) {
|
||||
setFocusedNode(event.node)
|
||||
}
|
||||
}
|
||||
},
|
||||
leaveNode: (event: NodeEvent) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedNode(null)
|
||||
}
|
||||
},
|
||||
clickNode: (event: NodeEvent) => {
|
||||
const graph = sigma.getGraph()
|
||||
if (graph.hasNode(event.node)) {
|
||||
setSelectedNode(event.node)
|
||||
setSelectedEdge(null)
|
||||
}
|
||||
},
|
||||
clickStage: () => clearSelection()
|
||||
}
|
||||
|
||||
if (effectiveEdgeEvents) {
|
||||
events.clickEdge = (event: EdgeEvent) => {
|
||||
setSelectedEdge(event.edge)
|
||||
setSelectedNode(null)
|
||||
}
|
||||
events.enterEdge = (event: EdgeEvent) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedEdge(event.edge)
|
||||
}
|
||||
}
|
||||
events.leaveEdge = (event: EdgeEvent) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedEdge(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerEvents(events)
|
||||
}, [registerEvents, effectiveEdgeEvents, sigma])
|
||||
|
||||
/**
|
||||
* When edge size settings change, recalculate edge sizes.
|
||||
* Batched via updateEachEdgeAttributes (one pass, one graphology event)
|
||||
* instead of one event-emitting setEdgeAttribute per edge.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sigma && sigmaGraph) {
|
||||
const graph = sigma.getGraph()
|
||||
|
||||
let minWeight = Number.MAX_SAFE_INTEGER
|
||||
let maxWeight = 0
|
||||
graph.forEachEdge((edge) => {
|
||||
const weight = graph.getEdgeAttribute(edge, 'originalWeight') || 1
|
||||
if (typeof weight === 'number') {
|
||||
minWeight = Math.min(minWeight, weight)
|
||||
maxWeight = Math.max(maxWeight, weight)
|
||||
}
|
||||
})
|
||||
|
||||
const weightRange = maxWeight - minWeight
|
||||
const sizeScale = maxEdgeSize - minEdgeSize
|
||||
graph.updateEachEdgeAttributes(
|
||||
(_edge, attr) => {
|
||||
if (weightRange > 0) {
|
||||
const weight = typeof attr.originalWeight === 'number' ? attr.originalWeight : 1
|
||||
attr.size = minEdgeSize + sizeScale * Math.pow((weight - minWeight) / weightRange, 0.5)
|
||||
} else {
|
||||
attr.size = minEdgeSize
|
||||
}
|
||||
return attr
|
||||
},
|
||||
{ attributes: ['size'] }
|
||||
)
|
||||
|
||||
sigma.refresh()
|
||||
}
|
||||
}, [sigma, sigmaGraph, minEdgeSize, maxEdgeSize])
|
||||
|
||||
/**
|
||||
* When focus/selection changes
|
||||
* => set the sigma reducers
|
||||
*
|
||||
* PERFORMANCE:
|
||||
* - When nothing is focused or selected, reducers are set to null: running
|
||||
* a JS callback (with an object spread) for all 73k nodes and all edges
|
||||
* on every refresh is pure overhead when there is nothing to highlight.
|
||||
* - The focused node's neighborhood is precomputed ONCE into a Set. The
|
||||
* previous code called graph.neighbors(focused).includes(node) INSIDE the
|
||||
* node reducer — allocating the full neighbor array and scanning it
|
||||
* linearly for every single node of the graph, per refresh.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
|
||||
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
|
||||
|
||||
const graph = sigma.getGraph()
|
||||
const graphOrder = graph ? graph.order : 0
|
||||
const effectiveRenderLabels = renderLabels && graphOrder <= Constants.LABEL_RENDER_LIMIT
|
||||
|
||||
const _focusedNode = focusedNode || selectedNode
|
||||
// Ignore any residual edge focus/selection when edge events are gated off
|
||||
// (e.g. an edge was selected on a small graph, then expand pushed it past
|
||||
// EDGE_PERF_LIMIT): otherwise the edge-focused reducer branch below would
|
||||
// still run the per-edge highlight pass on a large graph — exactly the cost
|
||||
// we disabled edge events to avoid.
|
||||
const _focusedEdge = effectiveEdgeEvents ? focusedEdge || selectedEdge : null
|
||||
|
||||
if (disableHoverEffect || (!_focusedNode && !_focusedEdge)) {
|
||||
setSettings({
|
||||
enableEdgeEvents: effectiveEdgeEvents,
|
||||
renderEdgeLabels,
|
||||
renderLabels: effectiveRenderLabels,
|
||||
nodeReducer: null,
|
||||
edgeReducer: null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Precompute the highlight context once, outside the reducers.
|
||||
const neighborSet = new Set<string>()
|
||||
let focusedNodeValid = false
|
||||
if (_focusedNode && graph.hasNode(_focusedNode)) {
|
||||
focusedNodeValid = true
|
||||
graph.forEachNeighbor(_focusedNode, (neighbor) => neighborSet.add(neighbor))
|
||||
}
|
||||
let focusedEdgeSource = ''
|
||||
let focusedEdgeTarget = ''
|
||||
let focusedEdgeValid = false
|
||||
if (!focusedNodeValid && _focusedEdge && graph.hasEdge(_focusedEdge)) {
|
||||
focusedEdgeValid = true
|
||||
focusedEdgeSource = graph.source(_focusedEdge)
|
||||
focusedEdgeTarget = graph.target(_focusedEdge)
|
||||
}
|
||||
|
||||
const edgeHighlightColor = isDarkTheme
|
||||
? Constants.edgeColorHighlightedDarkTheme
|
||||
: Constants.edgeColorHighlightedLightTheme
|
||||
|
||||
setSettings({
|
||||
enableEdgeEvents: effectiveEdgeEvents,
|
||||
renderEdgeLabels,
|
||||
renderLabels: effectiveRenderLabels,
|
||||
|
||||
nodeReducer: (node, data) => {
|
||||
const newData: NodeType & { labelColor?: string; borderColor?: string } = {
|
||||
...data,
|
||||
highlighted: false,
|
||||
labelColor
|
||||
}
|
||||
|
||||
if (focusedNodeValid) {
|
||||
if (node === _focusedNode || neighborSet.has(node)) {
|
||||
newData.highlighted = true
|
||||
if (node === selectedNode) {
|
||||
newData.borderColor = Constants.nodeBorderColorSelected
|
||||
}
|
||||
if (isDarkTheme) {
|
||||
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
|
||||
}
|
||||
} else {
|
||||
newData.color = Constants.nodeColorDisabled
|
||||
}
|
||||
} else if (focusedEdgeValid) {
|
||||
if (node === focusedEdgeSource || node === focusedEdgeTarget) {
|
||||
newData.highlighted = true
|
||||
newData.size = 3
|
||||
if (isDarkTheme) {
|
||||
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
|
||||
}
|
||||
} else {
|
||||
newData.color = Constants.nodeColorDisabled
|
||||
}
|
||||
}
|
||||
return newData
|
||||
},
|
||||
|
||||
edgeReducer: (edge, data) => {
|
||||
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
|
||||
|
||||
if (focusedNodeValid) {
|
||||
// No graph.extremities() here: it allocates an array per edge.
|
||||
const touchesFocused =
|
||||
graph.source(edge) === _focusedNode || graph.target(edge) === _focusedNode
|
||||
if (hideUnselectedEdges) {
|
||||
if (!touchesFocused) newData.hidden = true
|
||||
} else if (touchesFocused) {
|
||||
newData.color = edgeHighlightColor
|
||||
}
|
||||
} else if (focusedEdgeValid) {
|
||||
if (edge === selectedEdge) {
|
||||
newData.color = Constants.edgeColorSelected
|
||||
} else if (edge === _focusedEdge) {
|
||||
newData.color = edgeHighlightColor
|
||||
} else if (hideUnselectedEdges) {
|
||||
newData.hidden = true
|
||||
}
|
||||
}
|
||||
return newData
|
||||
}
|
||||
})
|
||||
}, [
|
||||
selectedNode,
|
||||
focusedNode,
|
||||
selectedEdge,
|
||||
focusedEdge,
|
||||
setSettings,
|
||||
sigma,
|
||||
sigmaGraph,
|
||||
disableHoverEffect,
|
||||
isDarkTheme,
|
||||
hideUnselectedEdges,
|
||||
effectiveEdgeEvents,
|
||||
renderEdgeLabels,
|
||||
renderLabels
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default GraphControl
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import {
|
||||
dropdownDisplayLimit,
|
||||
controlButtonVariant,
|
||||
popularLabelsDefaultLimit,
|
||||
searchLabelsDefaultLimit
|
||||
} from '@/lib/constants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
|
||||
import { getPopularLabels, searchLabels } from '@/api/lightrag'
|
||||
|
||||
const GraphLabels = () => {
|
||||
const { t } = useTranslation()
|
||||
const label = useSettingsStore.use.queryLabel()
|
||||
const dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger()
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [selectKey, setSelectKey] = useState(0)
|
||||
|
||||
// Pipeline state monitoring
|
||||
const pipelineBusy = useBackendState.use.pipelineBusy()
|
||||
const prevPipelineBusy = useRef<boolean | undefined>(undefined)
|
||||
const shouldRefreshPopularLabelsRef = useRef(false)
|
||||
|
||||
// Dynamic tooltip based on current label state
|
||||
const getRefreshTooltip = useCallback(() => {
|
||||
if (isRefreshing) {
|
||||
return t('graphPanel.graphLabels.refreshingTooltip')
|
||||
}
|
||||
|
||||
if (!label || label === '*') {
|
||||
return t('graphPanel.graphLabels.refreshGlobalTooltip')
|
||||
} else {
|
||||
return t('graphPanel.graphLabels.refreshCurrentLabelTooltip', { label })
|
||||
}
|
||||
}, [label, t, isRefreshing])
|
||||
|
||||
// Initialize search history on component mount
|
||||
useEffect(() => {
|
||||
const initializeHistory = async () => {
|
||||
const history = SearchHistoryManager.getHistory()
|
||||
|
||||
if (history.length === 0) {
|
||||
// If no history exists, fetch popular labels and initialize
|
||||
try {
|
||||
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
|
||||
await SearchHistoryManager.initializeWithDefaults(popularLabels)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize search history:', error)
|
||||
// No fallback needed, API is the source of truth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeHistory()
|
||||
}, [])
|
||||
|
||||
// Force AsyncSelect to re-render when label or dropdownRefreshTrigger changes.
|
||||
// Uses render-time previous-value comparison to avoid cascading renders from
|
||||
// setState-in-useEffect, while still bumping the key on external changes.
|
||||
const [previousLabel, setPreviousLabel] = useState(label)
|
||||
const [previousDropdownTrigger, setPreviousDropdownTrigger] = useState(dropdownRefreshTrigger)
|
||||
if (label !== previousLabel) {
|
||||
setPreviousLabel(label)
|
||||
setSelectKey(prev => prev + 1)
|
||||
}
|
||||
if (dropdownRefreshTrigger !== previousDropdownTrigger) {
|
||||
setPreviousDropdownTrigger(dropdownRefreshTrigger)
|
||||
if (dropdownRefreshTrigger > 0) {
|
||||
setSelectKey(prev => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor pipeline state changes: busy -> idle
|
||||
useEffect(() => {
|
||||
if (prevPipelineBusy.current === true && pipelineBusy === false) {
|
||||
console.log('Pipeline changed from busy to idle, marking for popular labels refresh')
|
||||
shouldRefreshPopularLabelsRef.current = true
|
||||
}
|
||||
prevPipelineBusy.current = pipelineBusy
|
||||
}, [pipelineBusy])
|
||||
|
||||
// Helper: Reload popular labels from backend
|
||||
const reloadPopularLabels = useCallback(async () => {
|
||||
if (!shouldRefreshPopularLabelsRef.current) return
|
||||
|
||||
console.log('Reloading popular labels (triggered by pipeline idle)')
|
||||
try {
|
||||
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
|
||||
SearchHistoryManager.clearHistory()
|
||||
|
||||
if (popularLabels.length === 0) {
|
||||
const fallbackLabels = ['entity', 'relationship', 'document', 'concept']
|
||||
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
|
||||
} else {
|
||||
await SearchHistoryManager.initializeWithDefaults(popularLabels)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reload popular labels:', error)
|
||||
const fallbackLabels = ['entity', 'relationship', 'document']
|
||||
SearchHistoryManager.clearHistory()
|
||||
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
|
||||
} finally {
|
||||
// Always clear the flag
|
||||
shouldRefreshPopularLabelsRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Helper: Bump dropdown data to trigger refresh
|
||||
const bumpDropdownData = useCallback(({ forceSelectKey = false } = {}) => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
if (forceSelectKey) {
|
||||
setSelectKey(prev => prev + 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (query?: string): Promise<string[]> => {
|
||||
let results: string[];
|
||||
if (!query || query.trim() === '' || query.trim() === '*') {
|
||||
// Empty query: return search history
|
||||
results = SearchHistoryManager.getHistoryLabels(dropdownDisplayLimit)
|
||||
} else {
|
||||
// Non-empty query: call backend search API
|
||||
try {
|
||||
const apiResults = await searchLabels(query.trim(), searchLabelsDefaultLimit)
|
||||
results = apiResults.length <= dropdownDisplayLimit
|
||||
? apiResults
|
||||
: [...apiResults.slice(0, dropdownDisplayLimit), '...']
|
||||
} catch (error) {
|
||||
console.error('Search API failed, falling back to local history search:', error)
|
||||
|
||||
// Fallback to local history search
|
||||
const history = SearchHistoryManager.getHistory()
|
||||
const queryLower = query.toLowerCase().trim()
|
||||
results = history
|
||||
.filter(item => item.label.toLowerCase().includes(queryLower))
|
||||
.map(item => item.label)
|
||||
.slice(0, dropdownDisplayLimit)
|
||||
}
|
||||
}
|
||||
// Always show '*' at the top, and remove duplicates
|
||||
const finalResults = ['*', ...results.filter(label => label !== '*')];
|
||||
return finalResults;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[refreshTrigger] // Intentionally added to trigger re-creation when data changes
|
||||
)
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true)
|
||||
|
||||
// Clear legend cache to ensure legend is re-generated on refresh
|
||||
useGraphStore.getState().setTypeColorMap(new Map<string, string>())
|
||||
|
||||
try {
|
||||
let currentLabel = label
|
||||
|
||||
// If queryLabel is empty, set it to '*'
|
||||
if (!currentLabel || currentLabel.trim() === '') {
|
||||
useSettingsStore.getState().setQueryLabel('*')
|
||||
currentLabel = '*'
|
||||
}
|
||||
|
||||
// Scenario 1: Manual refresh - reload popular labels if flag is set (regardless of current label)
|
||||
if (shouldRefreshPopularLabelsRef.current) {
|
||||
await reloadPopularLabels()
|
||||
bumpDropdownData({ forceSelectKey: true })
|
||||
}
|
||||
|
||||
if (currentLabel && currentLabel !== '*') {
|
||||
// Scenario 1: Has specific label, try to refresh current label
|
||||
console.log(`Refreshing current label: ${currentLabel}`)
|
||||
|
||||
// Reset graph data fetch status to trigger refresh
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
useGraphStore.getState().setLastSuccessfulQueryLabel('')
|
||||
|
||||
// Force data refresh for current label
|
||||
useGraphStore.getState().incrementGraphDataVersion()
|
||||
|
||||
// Note: If the current label has no data after refresh,
|
||||
// the fallback logic would be handled by the graph component itself
|
||||
// For now, we keep the current label and let the user see the result
|
||||
|
||||
} else {
|
||||
// Scenario 3: queryLabel is "*", refresh global data and popular labels
|
||||
console.log('Refreshing global data and popular labels')
|
||||
|
||||
try {
|
||||
// Re-fetch popular labels and update search history (if not already done)
|
||||
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
|
||||
SearchHistoryManager.clearHistory()
|
||||
|
||||
if (popularLabels.length === 0) {
|
||||
// If no popular labels, provide fallback defaults
|
||||
const fallbackLabels = ['entity', 'relationship', 'document', 'concept']
|
||||
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
|
||||
} else {
|
||||
await SearchHistoryManager.initializeWithDefaults(popularLabels)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reload popular labels:', error)
|
||||
// Provide fallback even if API fails
|
||||
const fallbackLabels = ['entity', 'relationship', 'document']
|
||||
SearchHistoryManager.clearHistory()
|
||||
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
|
||||
}
|
||||
|
||||
// Reset graph data fetch status
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
useGraphStore.getState().setLastSuccessfulQueryLabel('')
|
||||
|
||||
// Force global data refresh
|
||||
useGraphStore.getState().incrementGraphDataVersion()
|
||||
|
||||
// Ensure data update completes before triggering UI refresh
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// Trigger both refresh mechanisms to ensure dropdown updates
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
setSelectKey(prev => prev + 1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during refresh:', error)
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}, [label, reloadPopularLabels, bumpDropdownData])
|
||||
|
||||
// Handle dropdown before open - reload popular labels if needed
|
||||
const handleDropdownBeforeOpen = useCallback(async () => {
|
||||
const currentLabel = useSettingsStore.getState().queryLabel
|
||||
if (shouldRefreshPopularLabelsRef.current && (!currentLabel || currentLabel === '*')) {
|
||||
await reloadPopularLabels()
|
||||
bumpDropdownData()
|
||||
}
|
||||
}, [reloadPopularLabels, bumpDropdownData])
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{/* Always show refresh button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleRefresh}
|
||||
tooltip={getRefreshTooltip()}
|
||||
className="mr-2"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<div className="w-full min-w-[280px] max-w-[500px]">
|
||||
<AsyncSelect<string>
|
||||
key={selectKey} // Force re-render when data changes
|
||||
className="min-w-[300px]"
|
||||
triggerClassName="max-h-8 w-full overflow-hidden"
|
||||
searchInputClassName="max-h-8"
|
||||
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
||||
fetcher={fetchData}
|
||||
onBeforeOpen={handleDropdownBeforeOpen}
|
||||
renderOption={(item) => (
|
||||
<div className="truncate" title={item}>
|
||||
{item}
|
||||
</div>
|
||||
)}
|
||||
getOptionValue={(item) => item}
|
||||
getDisplayValue={(item) => (
|
||||
<div className="min-w-0 flex-1 truncate text-left" title={item}>
|
||||
{item}
|
||||
</div>
|
||||
)}
|
||||
notFound={<div className="py-6 text-center text-sm">{t('graphPanel.graphLabels.noLabels')}</div>}
|
||||
ariaLabel={t('graphPanel.graphLabels.label')}
|
||||
placeholder={t('graphPanel.graphLabels.placeholder')}
|
||||
searchPlaceholder={t('graphPanel.graphLabels.placeholder')}
|
||||
noResultsMessage={t('graphPanel.graphLabels.noLabels')}
|
||||
value={label !== null ? label : '*'}
|
||||
onChange={(newLabel) => {
|
||||
const currentLabel = useSettingsStore.getState().queryLabel;
|
||||
|
||||
// select the last item means query all
|
||||
if (newLabel === '...') {
|
||||
newLabel = '*';
|
||||
}
|
||||
|
||||
// Handle reselecting the same label
|
||||
if (newLabel === currentLabel && newLabel !== '*') {
|
||||
newLabel = '*';
|
||||
}
|
||||
|
||||
// Add selected label to search history (except for special cases)
|
||||
if (newLabel && newLabel !== '*' && newLabel !== '...' && newLabel.trim() !== '') {
|
||||
SearchHistoryManager.addToHistory(newLabel);
|
||||
}
|
||||
|
||||
// Reset graphDataFetchAttempted flag to ensure data fetch is triggered
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false);
|
||||
|
||||
// Update the label to trigger data loading
|
||||
useSettingsStore.getState().setQueryLabel(newLabel);
|
||||
|
||||
// Force graph re-render and reset zoom/scale (must be AFTER setQueryLabel)
|
||||
useGraphStore.getState().incrementGraphDataVersion();
|
||||
}}
|
||||
clearable={false} // Prevent clearing value on reselect
|
||||
debounceTime={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GraphLabels
|
||||
@@ -0,0 +1,232 @@
|
||||
import { FC, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
EdgeById,
|
||||
GraphSearchInputProps,
|
||||
GraphSearchContextProviderProps
|
||||
} from '@react-sigma/graph-search'
|
||||
import { AsyncSearch } from '@/components/ui/AsyncSearch'
|
||||
import { searchResultLimit } from '@/lib/constants'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import MiniSearch from 'minisearch'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Message item identifier for search results
|
||||
export const messageId = '__message_item'
|
||||
|
||||
// Search result option item interface
|
||||
export interface OptionItem {
|
||||
id: string
|
||||
type: 'nodes' | 'edges' | 'message'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const NodeOption = ({ id }: { id: string }) => {
|
||||
const graph = useGraphStore.use.sigmaGraph()
|
||||
|
||||
// Early return if no graph or node doesn't exist
|
||||
if (!graph?.hasNode(id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Safely get node attributes with fallbacks
|
||||
const label = graph.getNodeAttribute(id, 'label') || id
|
||||
const color = graph.getNodeAttribute(id, 'color') || '#666'
|
||||
const size = graph.getNodeAttribute(id, 'size') || 4
|
||||
|
||||
// Custom node display component that doesn't rely on @react-sigma/graph-search
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 text-sm">
|
||||
<div
|
||||
className="rounded-full flex-shrink-0"
|
||||
style={{
|
||||
width: Math.max(8, Math.min(size * 2, 16)),
|
||||
height: Math.max(8, Math.min(size * 2, 16)),
|
||||
backgroundColor: color
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionComponent(item: OptionItem) {
|
||||
return (
|
||||
<div>
|
||||
{item.type === 'nodes' && <NodeOption id={item.id} />}
|
||||
{item.type === 'edges' && <EdgeById id={item.id} />}
|
||||
{item.type === 'message' && <div>{item.message}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Component thats display the search input.
|
||||
*/
|
||||
export const GraphSearchInput = ({
|
||||
onChange,
|
||||
onFocus,
|
||||
value
|
||||
}: {
|
||||
onChange: GraphSearchInputProps['onChange']
|
||||
onFocus?: GraphSearchInputProps['onFocus']
|
||||
value?: GraphSearchInputProps['value']
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const graph = useGraphStore.use.sigmaGraph()
|
||||
const searchEngine = useGraphStore.use.searchEngine()
|
||||
|
||||
// Reset search engine when graph changes
|
||||
useEffect(() => {
|
||||
if (graph) {
|
||||
useGraphStore.getState().resetSearchEngine()
|
||||
}
|
||||
}, [graph]);
|
||||
|
||||
// Create search engine when needed
|
||||
useEffect(() => {
|
||||
// Skip if no graph, empty graph, or search engine already exists
|
||||
if (!graph || graph.nodes().length === 0 || searchEngine) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create new search engine
|
||||
const newSearchEngine = new MiniSearch({
|
||||
idField: 'id',
|
||||
fields: ['label'],
|
||||
searchOptions: {
|
||||
prefix: true,
|
||||
fuzzy: 0.2,
|
||||
boost: {
|
||||
label: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Add nodes to search engine with safety checks
|
||||
const documents = graph.nodes()
|
||||
.filter(id => graph.hasNode(id)) // Ensure node exists before accessing attributes
|
||||
.map((id: string) => ({
|
||||
id: id,
|
||||
label: graph.getNodeAttribute(id, 'label')
|
||||
}))
|
||||
|
||||
if (documents.length > 0) {
|
||||
newSearchEngine.addAll(documents)
|
||||
}
|
||||
|
||||
// Update search engine in store
|
||||
useGraphStore.getState().setSearchEngine(newSearchEngine)
|
||||
}, [graph, searchEngine])
|
||||
|
||||
/**
|
||||
* Loading the options while the user is typing.
|
||||
*/
|
||||
const loadOptions = useCallback(
|
||||
async (query?: string): Promise<OptionItem[]> => {
|
||||
if (onFocus) onFocus(null)
|
||||
|
||||
// Safety checks to prevent crashes
|
||||
if (!graph || !searchEngine) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Verify graph has nodes before proceeding
|
||||
if (graph.nodes().length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// If no query, return some nodes for user to select
|
||||
if (!query) {
|
||||
const nodeIds = graph.nodes()
|
||||
.filter(id => graph.hasNode(id))
|
||||
.slice(0, searchResultLimit)
|
||||
return nodeIds.map(id => ({
|
||||
id,
|
||||
type: 'nodes'
|
||||
}))
|
||||
}
|
||||
|
||||
// If has query, search nodes and verify they still exist
|
||||
let result: OptionItem[] = searchEngine.search(query)
|
||||
.filter((r: { id: string }) => graph.hasNode(r.id))
|
||||
.map((r: { id: string }) => ({
|
||||
id: r.id,
|
||||
type: 'nodes'
|
||||
}))
|
||||
|
||||
// Add middle-content matching if results are few
|
||||
// This enables matching content in the middle of text, not just from the beginning
|
||||
if (result.length < 5) {
|
||||
// Get already matched IDs to avoid duplicates
|
||||
const matchedIds = new Set(result.map(item => item.id))
|
||||
|
||||
// Perform middle-content matching on all nodes with safety checks
|
||||
const middleMatchResults = graph.nodes()
|
||||
.filter(id => {
|
||||
// Skip already matched nodes
|
||||
if (matchedIds.has(id)) return false
|
||||
|
||||
// Ensure node exists before accessing attributes
|
||||
if (!graph.hasNode(id)) return false
|
||||
|
||||
// Get node label safely
|
||||
const label = graph.getNodeAttribute(id, 'label')
|
||||
// Match if label contains query string but doesn't start with it
|
||||
return label &&
|
||||
typeof label === 'string' &&
|
||||
!label.toLowerCase().startsWith(query.toLowerCase()) &&
|
||||
label.toLowerCase().includes(query.toLowerCase())
|
||||
})
|
||||
.map(id => ({
|
||||
id,
|
||||
type: 'nodes' as const
|
||||
}))
|
||||
|
||||
// Merge results
|
||||
result = [...result, ...middleMatchResults]
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
return result.length <= searchResultLimit
|
||||
? result
|
||||
: [
|
||||
...result.slice(0, searchResultLimit),
|
||||
{
|
||||
type: 'message',
|
||||
id: messageId,
|
||||
message: t('graphPanel.search.message', { count: result.length - searchResultLimit })
|
||||
}
|
||||
]
|
||||
},
|
||||
[graph, searchEngine, onFocus, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<AsyncSearch
|
||||
className="bg-background/60 w-24 rounded-xl border-1 opacity-60 backdrop-blur-lg transition-all hover:w-fit hover:opacity-100 w-full"
|
||||
fetcher={loadOptions}
|
||||
renderOption={OptionComponent}
|
||||
getOptionValue={(item) => item.id}
|
||||
value={value && value.type !== 'message' ? value.id : null}
|
||||
onChange={(id) => {
|
||||
if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null)
|
||||
}}
|
||||
onFocus={(id) => {
|
||||
if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null)
|
||||
}}
|
||||
ariaLabel={t('graphPanel.search.placeholder')}
|
||||
placeholder={t('graphPanel.search.placeholder')}
|
||||
noResultsMessage={t('graphPanel.search.placeholder')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that display the search.
|
||||
*/
|
||||
const GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({ ...props }) => {
|
||||
return <GraphSearchInput {...props} />
|
||||
}
|
||||
|
||||
export default GraphSearch
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useSigma } from '@react-sigma/core'
|
||||
import { animateNodes } from 'sigma/utils'
|
||||
import { useLayoutCirclepack } from '@react-sigma/layout-circlepack'
|
||||
import { useLayoutCircular } from '@react-sigma/layout-circular'
|
||||
import { useLayoutRandom } from '@react-sigma/layout-random'
|
||||
import { LayoutHook } from '@react-sigma/layout-core'
|
||||
import forceAtlas2 from 'graphology-layout-forceatlas2'
|
||||
import FA2Supervisor from 'graphology-layout-forceatlas2/worker'
|
||||
import NoverlapSupervisor from 'graphology-layout-noverlap/worker'
|
||||
import ForceSupervisor from 'graphology-layout-force/worker'
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from 'react'
|
||||
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { controlButtonVariant, ANIMATE_NODE_LIMIT, workerBudgetMs } from '@/lib/constants'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type LayoutName =
|
||||
| 'Circular'
|
||||
| 'Circlepack'
|
||||
| 'Random'
|
||||
| 'Noverlaps'
|
||||
| 'Force Directed'
|
||||
| 'Force Atlas'
|
||||
|
||||
// The layouts that relax over time and run in a web worker.
|
||||
type WorkerLayoutName = 'Noverlaps' | 'Force Directed' | 'Force Atlas'
|
||||
const WORKER_LAYOUTS: ReadonlySet<string> = new Set<WorkerLayoutName>([
|
||||
'Noverlaps',
|
||||
'Force Directed',
|
||||
'Force Atlas'
|
||||
])
|
||||
|
||||
// All three graphology supervisors share this API.
|
||||
interface LayoutSupervisor {
|
||||
start: () => void
|
||||
stop: () => void
|
||||
kill: () => void
|
||||
isRunning: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a graphology layout supervisor bound to `graph`.
|
||||
*
|
||||
* IMPORTANT: this binds to the graph that is passed in (the live graph from
|
||||
* the store). The @react-sigma worker hooks (useWorkerLayoutForceAtlas2 etc.)
|
||||
* construct their supervisor with `sigma.getGraph()` captured in an effect
|
||||
* whose dependency list does NOT include the graph, so they stay bound to the
|
||||
* empty initial graph that exists at mount. The real graph is swapped in later
|
||||
* via `sigma.setGraph()` (in GraphControl), which does not re-run that effect
|
||||
* -- so starting those hooks ran the algorithm on a stale empty graph and the
|
||||
* visible graph never moved. Building the supervisor here, against the current
|
||||
* graph, is what actually makes these layouts work.
|
||||
*/
|
||||
const buildSupervisor = (name: WorkerLayoutName, graph: unknown): LayoutSupervisor | null => {
|
||||
const order = (graph as { order: number }).order
|
||||
switch (name) {
|
||||
case 'Force Atlas':
|
||||
return new FA2Supervisor(graph as never, {
|
||||
settings: forceAtlas2.inferSettings(order)
|
||||
}) as unknown as LayoutSupervisor
|
||||
case 'Force Directed':
|
||||
// Tuned for CONTINUOUS worker relaxation (not the old bounded-compute +
|
||||
// animateNodes tween): higher inertia (more damping) and a much smaller
|
||||
// maxMove keep the live simulation from overshooting/oscillating. The old
|
||||
// maxMove: 100 (~100x the node coordinate scale) caused violent bouncing
|
||||
// when raw simulation ticks were rendered directly.
|
||||
return new ForceSupervisor(graph as never, {
|
||||
settings: {
|
||||
attraction: 0.0003,
|
||||
repulsion: 0.02,
|
||||
gravity: 0.02,
|
||||
inertia: 0.8,
|
||||
maxMove: 5
|
||||
}
|
||||
}) as unknown as LayoutSupervisor
|
||||
case 'Noverlaps':
|
||||
// margin bumped 5 -> 10: a larger collision threshold makes Noverlap push
|
||||
// more "too close" nodes apart, so it visibly de-clutters instead of doing
|
||||
// almost nothing after the size-aware initial FA2 layout already spread
|
||||
// the graph.
|
||||
return new NoverlapSupervisor(graph as never, {
|
||||
settings: { margin: 10, expansion: 1.1, gridSize: 1, ratio: 1, speed: 3 }
|
||||
}) as unknown as LayoutSupervisor
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play/pause control for the worker-backed layouts.
|
||||
*
|
||||
* Self-contained: it builds and owns the supervisor (bound to the live store
|
||||
* graph), auto-runs when its layout is selected, runs for a size-scaled time
|
||||
* budget, then stops and re-normalizes the settled coordinates WITHOUT
|
||||
* touching the camera (the user's rotation/zoom/pan are preserved). The
|
||||
* previous implementation ran
|
||||
* the SYNCHRONOUS layout (mainLayout.positions()) every 200ms in a setInterval
|
||||
* -- for ForceAtlas2 without Barnes-Hut that is O(V^2) on the main thread,
|
||||
* five times a second.
|
||||
*/
|
||||
const WorkerLayoutControl = ({ layoutName }: { layoutName: WorkerLayoutName }) => {
|
||||
const sigma = useSigma()
|
||||
const { t } = useTranslation()
|
||||
// Rebind when the underlying graph is replaced (refresh / new label), so a
|
||||
// running layout never keeps a supervisor bound to the detached old graph.
|
||||
const sigmaGraph = useGraphStore.use.sigmaGraph()
|
||||
const [running, setRunning] = useState(false)
|
||||
const supervisorRef = useRef<LayoutSupervisor | null>(null)
|
||||
const stopTimerRef = useRef<number | null>(null)
|
||||
// The deferred supervisor.start() timer (see start()); tracked so a pause
|
||||
// within the defer window cancels the pending start instead of being undone.
|
||||
const startTimerRef = useRef<number | null>(null)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (stopTimerRef.current !== null) {
|
||||
window.clearTimeout(stopTimerRef.current)
|
||||
stopTimerRef.current = null
|
||||
}
|
||||
if (startTimerRef.current !== null) {
|
||||
window.clearTimeout(startTimerRef.current)
|
||||
startTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(
|
||||
// settleView=true is used when the layout finishes on its own (budget
|
||||
// expiry): it releases the custom bbox frozen by node dragging so the
|
||||
// settled coordinates re-normalize, then refreshes. It deliberately does
|
||||
// NOT reset the camera — the user's rotation/zoom/pan are preserved (an
|
||||
// animatedReset here threw the view back to the default every time a
|
||||
// layout finished). Manual pause passes false and leaves the view alone.
|
||||
(settleView: boolean) => {
|
||||
clearTimer()
|
||||
try {
|
||||
supervisorRef.current?.stop()
|
||||
} catch (error) {
|
||||
console.error('Error stopping layout:', error)
|
||||
}
|
||||
setRunning(false)
|
||||
// Release the shared slot (kills the stopped worker) so
|
||||
// "activeLayoutSupervisor != null" reliably means a layout is running.
|
||||
useGraphStore.getState().releaseLayoutSupervisor(supervisorRef.current)
|
||||
if (settleView) {
|
||||
try {
|
||||
sigma.setCustomBBox(null)
|
||||
sigma.refresh()
|
||||
} catch (error) {
|
||||
console.error('Error refreshing after layout:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
[clearTimer, sigma]
|
||||
)
|
||||
|
||||
const start = useCallback(() => {
|
||||
const graph = useGraphStore.getState().sigmaGraph
|
||||
if (!graph || graph.order === 0) return
|
||||
|
||||
// Cancel any pending start/stop timers from a previous cycle before
|
||||
// (re)building, so a stale budget timer can't stop this fresh run.
|
||||
clearTimer()
|
||||
|
||||
// (Re)build the supervisor bound to the CURRENT graph.
|
||||
try {
|
||||
supervisorRef.current?.kill()
|
||||
} catch {
|
||||
/* no live supervisor yet */
|
||||
}
|
||||
const supervisor = buildSupervisor(layoutName, graph)
|
||||
supervisorRef.current = supervisor
|
||||
if (!supervisor) return
|
||||
|
||||
// Become the single layout owner. This kills whatever was running before
|
||||
// (the initial FA2 from GraphControl, or a previously selected worker
|
||||
// layout) so two supervisors never mutate the same coordinates at once.
|
||||
useGraphStore.getState().setActiveLayoutSupervisor(supervisor)
|
||||
|
||||
// A custom bbox frozen by dragging breaks coordinate normalization and
|
||||
// makes a relaxing layout look like it collapses; clear it before running.
|
||||
try {
|
||||
sigma.setCustomBBox(null)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// No blocking overlay here: worker layouts are meant to stay interactive
|
||||
// while they settle. FA2/Noverlap run in real Web Workers; the play/pause
|
||||
// button (driven by `running`) is the live indicator, and the user can
|
||||
// pause at any time. The full-screen overlay is reserved for the
|
||||
// synchronous switch path in runLayout, which actually blocks the main
|
||||
// thread. This also matches the initial FA2 layout in GraphControl, which
|
||||
// deliberately does not set isLayoutComputing.
|
||||
//
|
||||
// We still DEFER supervisor.start() so the browser paints the Pause state
|
||||
// first. Force's runFrame() runs sync on the main thread; rAF won't help
|
||||
// because rAF callbacks fire BEFORE the next paint, so a small setTimeout
|
||||
// guarantees a paint lands first. FA2/Noverlap don't strictly need this
|
||||
// (their start() just posts to a real worker and returns), but the small
|
||||
// delay is irrelevant for them too.
|
||||
setRunning(true)
|
||||
startTimerRef.current = window.setTimeout(() => {
|
||||
startTimerRef.current = null
|
||||
// Bail if the user already switched layouts before we fired.
|
||||
if (supervisorRef.current !== supervisor) return
|
||||
try {
|
||||
supervisor.start()
|
||||
} catch (error) {
|
||||
console.error('Error starting layout:', error)
|
||||
setRunning(false)
|
||||
return
|
||||
}
|
||||
stopTimerRef.current = window.setTimeout(() => stop(true), workerBudgetMs(graph.order))
|
||||
}, 50)
|
||||
}, [layoutName, sigma, clearTimer, stop])
|
||||
|
||||
// Auto-run when this worker layout becomes active; clean up on
|
||||
// change/unmount. Keyed on layoutName + sigmaGraph: when the graph is
|
||||
// replaced (refresh / new label) we must tear down the supervisor bound to
|
||||
// the old graph and rebuild against the new one, otherwise `running` and the
|
||||
// budget timer keep pointing at a dead supervisor on a detached graph.
|
||||
// supervisorRef is updated INSIDE start() (which runs after this effect's
|
||||
// cleanup), so cleanup correctly kills the PREVIOUS supervisor, not the
|
||||
// incoming one.
|
||||
useEffect(() => {
|
||||
// Intentional: mounting this control means the layout was selected, so we
|
||||
// auto-run it (start() spins up the worker supervisor and flips `running`).
|
||||
// This is the "synchronize with an external system" case the rule exempts,
|
||||
// not an accidental render cascade.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
start()
|
||||
return () => {
|
||||
clearTimer()
|
||||
// Release the slot if we still own it; otherwise just kill our own
|
||||
// (previous) supervisor. start() for the incoming layout runs AFTER this
|
||||
// cleanup, so the slot still points at our supervisor here.
|
||||
useGraphStore.getState().releaseLayoutSupervisor(supervisorRef.current)
|
||||
supervisorRef.current = null
|
||||
setRunning(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layoutName, sigmaGraph])
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => (running ? stop(false) : start())}
|
||||
tooltip={
|
||||
running
|
||||
? t('graphPanel.sideBar.layoutsControl.stopAnimation')
|
||||
: t('graphPanel.sideBar.layoutsControl.startAnimation')
|
||||
}
|
||||
variant={controlButtonVariant}
|
||||
>
|
||||
{running ? <PauseIcon /> : <PlayIcon />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that controls the layout of the graph.
|
||||
*/
|
||||
const LayoutsControl = () => {
|
||||
const sigma = useSigma()
|
||||
const { t } = useTranslation()
|
||||
const [layout, setLayout] = useState<LayoutName>('Circular')
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
|
||||
// Only the instant, deterministic layouts use the @react-sigma hooks (they
|
||||
// compute positions synchronously and we assign them). The relaxing layouts
|
||||
// are handled by WorkerLayoutControl via supervisors.
|
||||
const layoutCircular = useLayoutCircular()
|
||||
const layoutCirclepack = useLayoutCirclepack()
|
||||
const layoutRandom = useLayoutRandom()
|
||||
|
||||
const syncLayouts = useMemo(() => {
|
||||
return {
|
||||
Circular: layoutCircular,
|
||||
Circlepack: layoutCirclepack,
|
||||
Random: layoutRandom
|
||||
} as Record<string, LayoutHook>
|
||||
}, [layoutCircular, layoutCirclepack, layoutRandom])
|
||||
|
||||
const allLayoutNames: LayoutName[] = useMemo(
|
||||
() => ['Circular', 'Circlepack', 'Random', 'Noverlaps', 'Force Directed', 'Force Atlas'],
|
||||
[]
|
||||
)
|
||||
|
||||
const runLayout = useCallback(
|
||||
(newLayout: LayoutName) => {
|
||||
console.debug('Running layout:', newLayout)
|
||||
|
||||
// Worker layouts: selecting one (re)mounts WorkerLayoutControl, which
|
||||
// auto-runs it against the live graph. Nothing is computed on the main
|
||||
// thread here.
|
||||
if (WORKER_LAYOUTS.has(newLayout)) {
|
||||
setLayout(newLayout)
|
||||
return
|
||||
}
|
||||
|
||||
const graph = sigma.getGraph()
|
||||
if (!graph || graph.order === 0) {
|
||||
console.error('No graph available')
|
||||
return
|
||||
}
|
||||
|
||||
// BUGFIX "graph collapses into a single point": node dragging installs
|
||||
// a custom bounding box that was never cleared, freezing sigma's
|
||||
// coordinate normalization to the previous layout's extent.
|
||||
const doLayout = () => {
|
||||
try {
|
||||
// Kill any running worker layout (notably the initial FA2, which has
|
||||
// no WorkerLayoutControl to unmount) before assigning positions, or
|
||||
// it keeps mutating coordinates and fights this synchronous layout.
|
||||
useGraphStore.getState().setActiveLayoutSupervisor(null)
|
||||
const pos = syncLayouts[newLayout].positions()
|
||||
sigma.setCustomBBox(null)
|
||||
if (graph.order > ANIMATE_NODE_LIMIT) {
|
||||
graph.updateEachNodeAttributes(
|
||||
(node, attr) => {
|
||||
const p = pos[node]
|
||||
if (p) {
|
||||
attr.x = p.x
|
||||
attr.y = p.y
|
||||
}
|
||||
return attr
|
||||
},
|
||||
{ attributes: ['x', 'y'] }
|
||||
)
|
||||
sigma.refresh()
|
||||
} else {
|
||||
animateNodes(graph, pos, { duration: 400 })
|
||||
}
|
||||
sigma.getCamera().animatedReset()
|
||||
setLayout(newLayout)
|
||||
} catch (error) {
|
||||
console.error('Error running layout:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// For large graphs, the assign + refresh blocks the main thread for
|
||||
// hundreds of ms. Show the loading overlay and defer the heavy work
|
||||
// one frame so React actually paints the overlay before we block.
|
||||
// Small graphs animate (400ms) -- the animation itself is feedback.
|
||||
if (graph.order > ANIMATE_NODE_LIMIT) {
|
||||
useGraphStore.getState().setIsLayoutComputing(true)
|
||||
window.requestAnimationFrame(() => {
|
||||
try {
|
||||
doLayout()
|
||||
} finally {
|
||||
useGraphStore.getState().setIsLayoutComputing(false)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
doLayout()
|
||||
}
|
||||
},
|
||||
[syncLayouts, sigma]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{WORKER_LAYOUTS.has(layout) && (
|
||||
<WorkerLayoutControl layoutName={layout as WorkerLayoutName} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={controlButtonVariant}
|
||||
onClick={() => setOpened((e: boolean) => !e)}
|
||||
tooltip={t('graphPanel.sideBar.layoutsControl.layoutGraph')}
|
||||
>
|
||||
<GripIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={5}
|
||||
sticky="always"
|
||||
className="min-w-auto p-1"
|
||||
>
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{allLayoutNames.map((name) => (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
runLayout(name)
|
||||
}}
|
||||
key={name}
|
||||
className="cursor-pointer text-xs"
|
||||
>
|
||||
{t(`graphPanel.sideBar.layoutsControl.layouts.${name}`)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutsControl
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { Card } from '@/components/ui/Card'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
|
||||
interface LegendProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Legend: React.FC<LegendProps> = ({ className }) => {
|
||||
const { t } = useTranslation()
|
||||
const typeColorMap = useGraphStore.use.typeColorMap()
|
||||
|
||||
if (!typeColorMap || typeColorMap.size === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`p-2 max-w-xs ${className}`}>
|
||||
<h3 className="text-sm font-medium mb-2">{t('graphPanel.legend')}</h3>
|
||||
<ScrollArea className="max-h-80">
|
||||
<div className="flex flex-col gap-1">
|
||||
{Array.from(typeColorMap.entries()).map(([type, color]) => (
|
||||
<div key={type} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-xs truncate" title={type}>
|
||||
{t(`graphPanel.nodeTypes.${type.toLowerCase().replace(/\s+/g, '')}`, type)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default Legend
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from 'react'
|
||||
import { BookOpenIcon } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Component that toggles legend visibility.
|
||||
*/
|
||||
const LegendButton = () => {
|
||||
const { t } = useTranslation()
|
||||
const showLegend = useSettingsStore.use.showLegend()
|
||||
const setShowLegend = useSettingsStore.use.setShowLegend()
|
||||
|
||||
const toggleLegend = useCallback(() => {
|
||||
setShowLegend(!showLegend)
|
||||
}, [showLegend, setShowLegend])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={toggleLegend}
|
||||
tooltip={t('graphPanel.sideBar.legendControl.toggleLegend')}
|
||||
size="icon"
|
||||
>
|
||||
<BookOpenIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default LegendButton
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/Dialog'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
interface MergeDialogProps {
|
||||
mergeDialogOpen: boolean
|
||||
mergeDialogInfo: {
|
||||
targetEntity: string
|
||||
sourceEntity: string
|
||||
} | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onRefresh: (useMergedStart: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* MergeDialog component that appears after a successful entity merge
|
||||
* Allows user to choose whether to use the merged entity or keep current start point
|
||||
*/
|
||||
const MergeDialog = ({
|
||||
mergeDialogOpen,
|
||||
mergeDialogInfo,
|
||||
onOpenChange,
|
||||
onRefresh
|
||||
}: MergeDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
const currentQueryLabel = useSettingsStore.use.queryLabel()
|
||||
|
||||
return (
|
||||
<Dialog open={mergeDialogOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('graphPanel.propertiesView.mergeDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('graphPanel.propertiesView.mergeDialog.description', {
|
||||
source: mergeDialogInfo?.sourceEntity ?? '',
|
||||
target: mergeDialogInfo?.targetEntity ?? '',
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('graphPanel.propertiesView.mergeDialog.refreshHint')}
|
||||
</p>
|
||||
<DialogFooter className="mt-4 flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
{currentQueryLabel !== mergeDialogInfo?.sourceEntity && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onRefresh(false)}
|
||||
>
|
||||
{t('graphPanel.propertiesView.mergeDialog.keepCurrentStart')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" onClick={() => onRefresh(true)}>
|
||||
{t('graphPanel.propertiesView.mergeDialog.useMergedStart')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default MergeDialog
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import Text from '@/components/ui/Text'
|
||||
import Button from '@/components/ui/Button'
|
||||
import useLightragGraph from '@/hooks/useLightragGraph'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GitBranchPlus, Scissors, Lock } from 'lucide-react'
|
||||
import EditablePropertyRow from './EditablePropertyRow'
|
||||
|
||||
/**
|
||||
* Component that view properties of elements in graph.
|
||||
*/
|
||||
const PropertiesView = () => {
|
||||
const { getNode, getEdge } = useLightragGraph()
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const selectedEdge = useGraphStore.use.selectedEdge()
|
||||
const focusedEdge = useGraphStore.use.focusedEdge()
|
||||
const graphDataVersion = useGraphStore.use.graphDataVersion()
|
||||
const pipelineBusy = useBackendState.use.pipelineBusy()
|
||||
|
||||
const { currentElement, currentType } = useMemo(() => {
|
||||
let type: 'node' | 'edge' | null = null
|
||||
let element: RawNodeType | RawEdgeType | null = null
|
||||
if (focusedNode) {
|
||||
type = 'node'
|
||||
element = getNode(focusedNode)
|
||||
} else if (selectedNode) {
|
||||
type = 'node'
|
||||
element = getNode(selectedNode)
|
||||
} else if (focusedEdge) {
|
||||
type = 'edge'
|
||||
element = getEdge(focusedEdge, true)
|
||||
} else if (selectedEdge) {
|
||||
type = 'edge'
|
||||
element = getEdge(selectedEdge, true)
|
||||
}
|
||||
|
||||
if (element) {
|
||||
return {
|
||||
currentElement: type === 'node'
|
||||
? refineNodeProperties(element as any)
|
||||
: refineEdgeProperties(element as any),
|
||||
currentType: type
|
||||
}
|
||||
}
|
||||
return { currentElement: null, currentType: null }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focusedNode, selectedNode, focusedEdge, selectedEdge, graphDataVersion, getNode, getEdge])
|
||||
|
||||
if (!currentElement) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<div className="bg-background/80 max-w-xs rounded-lg border-2 p-2 text-xs backdrop-blur-lg">
|
||||
{currentType == 'node' ? (
|
||||
<NodePropertiesView node={currentElement as any} pipelineBusy={pipelineBusy} />
|
||||
) : (
|
||||
<EdgePropertiesView edge={currentElement as any} pipelineBusy={pipelineBusy} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type NodeType = RawNodeType & {
|
||||
relationships: {
|
||||
type: string
|
||||
id: string
|
||||
label: string
|
||||
}[]
|
||||
}
|
||||
|
||||
type EdgeType = RawEdgeType & {
|
||||
sourceNode?: RawNodeType
|
||||
targetNode?: RawNodeType
|
||||
}
|
||||
|
||||
const refineNodeProperties = (node: RawNodeType): NodeType => {
|
||||
const state = useGraphStore.getState()
|
||||
const relationships = []
|
||||
|
||||
if (state.sigmaGraph && state.rawGraph) {
|
||||
try {
|
||||
if (!state.sigmaGraph.hasNode(node.id)) {
|
||||
console.warn('Node not found in sigmaGraph:', node.id)
|
||||
return {
|
||||
...node,
|
||||
relationships: []
|
||||
}
|
||||
}
|
||||
|
||||
const edges = state.sigmaGraph.edges(node.id)
|
||||
|
||||
for (const edgeId of edges) {
|
||||
if (!state.sigmaGraph.hasEdge(edgeId)) continue;
|
||||
|
||||
const edge = state.rawGraph.getEdge(edgeId, true)
|
||||
if (edge) {
|
||||
const isTarget = node.id === edge.source
|
||||
const neighbourId = isTarget ? edge.target : edge.source
|
||||
|
||||
if (!state.sigmaGraph.hasNode(neighbourId)) continue;
|
||||
|
||||
const neighbour = state.rawGraph.getNode(neighbourId)
|
||||
if (neighbour) {
|
||||
relationships.push({
|
||||
type: 'Neighbour',
|
||||
id: neighbourId,
|
||||
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refining node properties:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
relationships
|
||||
}
|
||||
}
|
||||
|
||||
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
|
||||
const state = useGraphStore.getState()
|
||||
let sourceNode: RawNodeType | undefined = undefined
|
||||
let targetNode: RawNodeType | undefined = undefined
|
||||
|
||||
if (state.sigmaGraph && state.rawGraph) {
|
||||
try {
|
||||
if (!state.sigmaGraph.hasEdge(edge.dynamicId)) {
|
||||
console.warn('Edge not found in sigmaGraph:', edge.id, 'dynamicId:', edge.dynamicId)
|
||||
return {
|
||||
...edge,
|
||||
sourceNode: undefined,
|
||||
targetNode: undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (state.sigmaGraph.hasNode(edge.source)) {
|
||||
sourceNode = state.rawGraph.getNode(edge.source)
|
||||
}
|
||||
|
||||
if (state.sigmaGraph.hasNode(edge.target)) {
|
||||
targetNode = state.rawGraph.getNode(edge.target)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refining edge properties:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...edge,
|
||||
sourceNode,
|
||||
targetNode
|
||||
}
|
||||
}
|
||||
|
||||
const PropertyRow = ({
|
||||
name,
|
||||
value,
|
||||
onClick,
|
||||
tooltip,
|
||||
nodeId,
|
||||
edgeId,
|
||||
dynamicId,
|
||||
entityId,
|
||||
entityType,
|
||||
sourceId,
|
||||
targetId,
|
||||
isEditable = false,
|
||||
truncate,
|
||||
pipelineBusy = false
|
||||
}: {
|
||||
name: string
|
||||
value: any
|
||||
onClick?: () => void
|
||||
tooltip?: string
|
||||
nodeId?: string
|
||||
entityId?: string
|
||||
edgeId?: string
|
||||
dynamicId?: string
|
||||
entityType?: 'node' | 'edge'
|
||||
sourceId?: string
|
||||
targetId?: string
|
||||
isEditable?: boolean
|
||||
truncate?: string
|
||||
pipelineBusy?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getPropertyNameTranslation = (name: string) => {
|
||||
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
|
||||
const translation = t(translationKey)
|
||||
return translation === translationKey ? name : translation
|
||||
}
|
||||
|
||||
// Utility function to convert <SEP> to newlines
|
||||
const formatValueWithSeparators = (value: any): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/<SEP>/g, ';\n')
|
||||
}
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
// Format the value to convert <SEP> to newlines
|
||||
const formattedValue = formatValueWithSeparators(value)
|
||||
let formattedTooltip = tooltip || formatValueWithSeparators(value)
|
||||
|
||||
// If this is source_id field and truncate info exists, append it to the tooltip
|
||||
if (name === 'source_id' && truncate) {
|
||||
formattedTooltip += `\n(Truncated: ${truncate})`
|
||||
}
|
||||
|
||||
// Use EditablePropertyRow for editable fields (description, entity_id and entity_type)
|
||||
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'entity_type' || name === 'keywords')) {
|
||||
return (
|
||||
<EditablePropertyRow
|
||||
name={name}
|
||||
value={value}
|
||||
onClick={onClick}
|
||||
nodeId={nodeId}
|
||||
entityId={entityId}
|
||||
edgeId={edgeId}
|
||||
dynamicId={dynamicId}
|
||||
entityType={entityType}
|
||||
sourceId={sourceId}
|
||||
targetId={targetId}
|
||||
isEditable={true}
|
||||
pipelineBusy={pipelineBusy}
|
||||
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// For non-editable fields, use the regular Text component
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary/60 tracking-wide whitespace-nowrap">
|
||||
{getPropertyNameTranslation(name)}
|
||||
{name === 'source_id' && truncate && <sup className="text-red-500">†</sup>}
|
||||
</span>:
|
||||
<Text
|
||||
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis"
|
||||
tooltipClassName="max-w-96 -translate-x-13"
|
||||
text={formattedValue}
|
||||
tooltip={formattedTooltip}
|
||||
side="left"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NodePropertiesView = ({ node, pipelineBusy }: { node: NodeType; pipelineBusy: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleExpandNode = () => {
|
||||
useGraphStore.getState().triggerNodeExpand(node.id)
|
||||
}
|
||||
|
||||
const handlePruneNode = () => {
|
||||
useGraphStore.getState().triggerNodePrune(node.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-md pl-1 font-bold tracking-wide text-blue-700">{t('graphPanel.propertiesView.node.title')}</h3>
|
||||
<div className="flex gap-3">
|
||||
{pipelineBusy && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t('graphPanel.propertiesView.editLockedByPipeline')}
|
||||
aria-disabled="true"
|
||||
className="h-7 w-7 border border-amber-400 hover:bg-amber-50 dark:border-amber-600 dark:hover:bg-amber-900/40 !cursor-default"
|
||||
tooltip={t('graphPanel.propertiesView.editLockedByPipeline')}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Lock className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||
onClick={handleExpandNode}
|
||||
tooltip={t('graphPanel.propertiesView.node.expandNode')}
|
||||
>
|
||||
<GitBranchPlus className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 border border-gray-400 hover:bg-gray-200 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||
onClick={handlePruneNode}
|
||||
tooltip={t('graphPanel.propertiesView.node.pruneNode')}
|
||||
>
|
||||
<Scissors className="h-4 w-4 text-gray-900 dark:text-gray-300" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={t('graphPanel.propertiesView.node.id')} value={String(node.id)} />
|
||||
<PropertyRow
|
||||
name={t('graphPanel.propertiesView.node.labels')}
|
||||
value={node.labels.join(', ')}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(node.id, true)
|
||||
}}
|
||||
/>
|
||||
<PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
|
||||
</div>
|
||||
<h3 className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.node.properties')}</h3>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(node.properties)
|
||||
.sort()
|
||||
.map((name) => {
|
||||
if (name === 'created_at' || name === 'truncate') return null; // Hide created_at and truncate properties
|
||||
return (
|
||||
<PropertyRow
|
||||
key={name}
|
||||
name={name}
|
||||
value={node.properties[name]}
|
||||
nodeId={String(node.id)}
|
||||
entityId={node.properties['entity_id']}
|
||||
entityType="node"
|
||||
isEditable={name === 'description' || name === 'entity_id' || name === 'entity_type'}
|
||||
truncate={node.properties['truncate']}
|
||||
pipelineBusy={pipelineBusy}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{node.relationships.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-md pl-1 font-bold tracking-wide text-emerald-700">
|
||||
{t('graphPanel.propertiesView.node.relationships')}
|
||||
</h3>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{node.relationships.map(({ type, id, label }) => {
|
||||
return (
|
||||
<PropertyRow
|
||||
key={id}
|
||||
name={type}
|
||||
value={label}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(id, true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EdgePropertiesView = ({ edge, pipelineBusy }: { edge: EdgeType; pipelineBusy: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</h3>
|
||||
{pipelineBusy && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t('graphPanel.propertiesView.editLockedByPipeline')}
|
||||
aria-disabled="true"
|
||||
className="h-7 w-7 border border-amber-400 hover:bg-amber-50 dark:border-amber-600 dark:hover:bg-amber-900/40 !cursor-default"
|
||||
tooltip={t('graphPanel.propertiesView.editLockedByPipeline')}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Lock className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
|
||||
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
|
||||
<PropertyRow
|
||||
name={t('graphPanel.propertiesView.edge.source')}
|
||||
value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(edge.source, true)
|
||||
}}
|
||||
/>
|
||||
<PropertyRow
|
||||
name={t('graphPanel.propertiesView.edge.target')}
|
||||
value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(edge.target, true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-md pl-1 font-bold tracking-wide text-amber-700">{t('graphPanel.propertiesView.edge.properties')}</h3>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(edge.properties)
|
||||
.sort()
|
||||
.map((name) => {
|
||||
if (name === 'created_at' || name === 'truncate') return null; // Hide created_at and truncate properties
|
||||
return (
|
||||
<PropertyRow
|
||||
key={name}
|
||||
name={name}
|
||||
value={edge.properties[name]}
|
||||
edgeId={String(edge.id)}
|
||||
dynamicId={String(edge.dynamicId)}
|
||||
entityType="edge"
|
||||
sourceId={edge.sourceNode?.properties['entity_id'] || edge.source}
|
||||
targetId={edge.targetNode?.properties['entity_id'] || edge.target}
|
||||
isEditable={name === 'description' || name === 'keywords'}
|
||||
truncate={edge.properties['truncate']}
|
||||
pipelineBusy={pipelineBusy}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PropertiesView
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '@/components/ui/Dialog'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
|
||||
interface PropertyEditDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: () => void | Promise<void>
|
||||
propertyName: string
|
||||
value: string
|
||||
allowMerge: boolean
|
||||
onValueChange: (value: string) => void
|
||||
onAllowMergeChange: (allowMerge: boolean) => void
|
||||
isSubmitting?: boolean
|
||||
errorMessage?: string | null
|
||||
disableSave?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog component for editing property values
|
||||
* Provides a modal with a title, multi-line text input, and save/cancel buttons
|
||||
*/
|
||||
const PropertyEditDialog = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
propertyName,
|
||||
value,
|
||||
allowMerge,
|
||||
onValueChange,
|
||||
onAllowMergeChange,
|
||||
isSubmitting = false,
|
||||
errorMessage = null,
|
||||
disableSave = false
|
||||
}: PropertyEditDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Get translated property name
|
||||
const getPropertyNameTranslation = (name: string) => {
|
||||
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
|
||||
const translation = t(translationKey)
|
||||
return translation === translationKey ? name : translation
|
||||
}
|
||||
|
||||
// Get textarea configuration based on property name
|
||||
const getTextareaConfig = (propertyName: string) => {
|
||||
switch (propertyName) {
|
||||
case 'description':
|
||||
return {
|
||||
// No rows attribute for description to allow auto-sizing
|
||||
className: 'max-h-[50vh] min-h-[10em] resize-y', // Maximum height 70% of viewport, minimum height ~20 lines, allow vertical resizing
|
||||
style: {
|
||||
height: '70vh', // Set initial height to 70% of viewport
|
||||
minHeight: '20em', // Minimum height ~20 lines
|
||||
resize: 'vertical' as const // Allow vertical resizing, using 'as const' to fix type
|
||||
}
|
||||
};
|
||||
case 'entity_id':
|
||||
return {
|
||||
rows: 2,
|
||||
className: '',
|
||||
style: {}
|
||||
};
|
||||
case 'keywords':
|
||||
return {
|
||||
rows: 4,
|
||||
className: '',
|
||||
style: {}
|
||||
};
|
||||
default:
|
||||
return {
|
||||
rows: 5,
|
||||
className: '',
|
||||
style: {}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = value.trim()
|
||||
if (trimmedValue !== '') {
|
||||
await onSave()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('graphPanel.propertiesView.editProperty', {
|
||||
property: getPropertyNameTranslation(propertyName)
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('graphPanel.propertiesView.editPropertyDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Pipeline busy banner: editing kept open with draft preserved, but save disabled */}
|
||||
{disableSave && (
|
||||
<div className="bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 border border-amber-400/60 px-3 py-2 rounded-md text-sm">
|
||||
{t('graphPanel.propertiesView.editLockedByPipeline')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display error message if save fails */}
|
||||
{errorMessage && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-line text input using textarea */}
|
||||
<div className="grid gap-4 py-4">
|
||||
{(() => {
|
||||
const config = getTextareaConfig(propertyName);
|
||||
return propertyName === 'description' ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}
|
||||
style={config.style}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
rows={config.rows}
|
||||
className={`border-input focus-visible:ring-ring flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${config.className}`}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{propertyName === 'entity_id' && (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<label className="flex items-start gap-2 text-sm font-medium">
|
||||
<Checkbox
|
||||
id="allow-merge"
|
||||
checked={allowMerge}
|
||||
disabled={isSubmitting}
|
||||
onCheckedChange={(checked) => onAllowMergeChange(checked === true)}
|
||||
/>
|
||||
<div>
|
||||
<span>{t('graphPanel.propertiesView.mergeOptionLabel')}</span>
|
||||
<p className="text-xs font-normal text-muted-foreground">
|
||||
{t('graphPanel.propertiesView.mergeOptionDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSubmitting || disableSave}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="mr-2">
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{t('common.saving')}
|
||||
</>
|
||||
) : (
|
||||
t('common.save')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default PropertyEditDialog
|
||||
@@ -0,0 +1,55 @@
|
||||
import { PencilIcon } from 'lucide-react'
|
||||
import Text from '@/components/ui/Text'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface PropertyNameProps {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const PropertyName = ({ name }: PropertyNameProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getPropertyNameTranslation = (propName: string) => {
|
||||
const translationKey = `graphPanel.propertiesView.node.propertyNames.${propName}`
|
||||
const translation = t(translationKey)
|
||||
return translation === translationKey ? propName : translation
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-primary/60 tracking-wide whitespace-nowrap">
|
||||
{getPropertyNameTranslation(name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface EditIconProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export const EditIcon = ({ onClick }: EditIconProps) => (
|
||||
<div>
|
||||
<PencilIcon
|
||||
className="h-3 w-3 text-gray-500 hover:text-gray-700 cursor-pointer"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface PropertyValueProps {
|
||||
value: any
|
||||
onClick?: () => void
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export const PropertyValue = ({ value, onClick, tooltip }: PropertyValueProps) => (
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<Text
|
||||
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
tooltipClassName="max-w-80 -translate-x-15"
|
||||
text={value}
|
||||
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
|
||||
side="left"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,448 @@
|
||||
import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Separator from '@/components/ui/Separator'
|
||||
import Input from '@/components/ui/Input'
|
||||
|
||||
import { controlButtonVariant, EDGE_PERF_LIMIT } from '@/lib/constants'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import useRandomGraph from '@/hooks/useRandomGraph'
|
||||
|
||||
import { SettingsIcon, Undo2, Shuffle } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Component that displays a checkbox with a label.
|
||||
*/
|
||||
const LabeledCheckBox = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
disabled,
|
||||
title
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: () => void
|
||||
label: string
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
}) => {
|
||||
// Create unique ID using the label text converted to lowercase with spaces removed
|
||||
const id = `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
|
||||
// The label's peer-disabled:* classes don't fire — Checkbox carries no `peer`
|
||||
// class — so grey the WHOLE row explicitly when disabled, not just the box.
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2${disabled ? ' cursor-not-allowed opacity-50' : ''}`}
|
||||
title={title}
|
||||
>
|
||||
<Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays a number input with a label.
|
||||
*/
|
||||
const LabeledNumberInput = ({
|
||||
value,
|
||||
onEditFinished,
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
defaultValue
|
||||
}: {
|
||||
value: number
|
||||
onEditFinished: (value: number) => void
|
||||
label: string
|
||||
min: number
|
||||
max?: number
|
||||
defaultValue?: number
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentValue, setCurrentValue] = useState<number | null>(value)
|
||||
// Create unique ID using the label text converted to lowercase with spaces removed
|
||||
const id = `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
|
||||
// Keep refs in sync so the unmount effect can read the latest values
|
||||
const currentValueRef = useRef(currentValue)
|
||||
const valueRef = useRef(value)
|
||||
const onEditFinishedRef = useRef(onEditFinished)
|
||||
useLayoutEffect(() => {
|
||||
currentValueRef.current = currentValue
|
||||
valueRef.current = value
|
||||
onEditFinishedRef.current = onEditFinished
|
||||
})
|
||||
|
||||
// Sync local state when controlled value changes (render-time comparison
|
||||
// avoids cascading renders flagged by react-hooks/set-state-in-effect).
|
||||
const [previousValue, setPreviousValue] = useState(value)
|
||||
if (value !== previousValue) {
|
||||
setPreviousValue(value)
|
||||
setCurrentValue(value)
|
||||
}
|
||||
|
||||
// Commit any pending change when the component unmounts (e.g. popover closes)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const cur = currentValueRef.current
|
||||
if (cur !== null && cur !== valueRef.current) {
|
||||
onEditFinishedRef.current(cur)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value.trim()
|
||||
if (text.length === 0) {
|
||||
setCurrentValue(null)
|
||||
return
|
||||
}
|
||||
const newValue = Number.parseInt(text)
|
||||
if (!isNaN(newValue) && newValue !== currentValue) {
|
||||
if (min !== undefined && newValue < min) {
|
||||
return
|
||||
}
|
||||
if (max !== undefined && newValue > max) {
|
||||
return
|
||||
}
|
||||
setCurrentValue(newValue)
|
||||
}
|
||||
},
|
||||
[currentValue, min, max]
|
||||
)
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
if (currentValue !== null && value !== currentValue) {
|
||||
onEditFinished(currentValue)
|
||||
}
|
||||
}, [value, currentValue, onEditFinished])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (defaultValue !== undefined && value !== defaultValue) {
|
||||
setCurrentValue(defaultValue)
|
||||
onEditFinished(defaultValue)
|
||||
}
|
||||
}, [defaultValue, value, onEditFinished])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
value={currentValue === null ? '' : currentValue}
|
||||
onChange={onValueChange}
|
||||
className="h-6 w-full min-w-0 pr-1"
|
||||
min={min}
|
||||
max={max}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{defaultValue !== undefined && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleReset}
|
||||
type="button"
|
||||
title={t('graphPanel.sideBar.settings.resetToDefault')}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays a popover with settings options.
|
||||
*/
|
||||
export default function Settings() {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
const showNodeLabel = useSettingsStore.use.showNodeLabel()
|
||||
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
|
||||
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||
const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
|
||||
const showEdgeLabel = useSettingsStore.use.showEdgeLabel()
|
||||
const minEdgeSize = useSettingsStore.use.minEdgeSize()
|
||||
const maxEdgeSize = useSettingsStore.use.maxEdgeSize()
|
||||
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
|
||||
const graphMaxNodes = useSettingsStore.use.graphMaxNodes()
|
||||
const backendMaxGraphNodes = useSettingsStore.use.backendMaxGraphNodes()
|
||||
|
||||
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
||||
|
||||
// Random graph functionality for development/testing
|
||||
const { randomGraph } = useRandomGraph()
|
||||
|
||||
const setEnableNodeDrag = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),
|
||||
[]
|
||||
)
|
||||
const setEnableEdgeEvents = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableEdgeEvents: !pre.enableEdgeEvents })),
|
||||
[]
|
||||
)
|
||||
const setEnableHideUnselectedEdges = useCallback(
|
||||
() =>
|
||||
useSettingsStore.setState((pre) => ({
|
||||
enableHideUnselectedEdges: !pre.enableHideUnselectedEdges
|
||||
})),
|
||||
[]
|
||||
)
|
||||
const setShowEdgeLabel = useCallback(
|
||||
() =>
|
||||
useSettingsStore.setState((pre) => ({
|
||||
showEdgeLabel: !pre.showEdgeLabel
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
//
|
||||
const setShowPropertyPanel = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ showPropertyPanel: !pre.showPropertyPanel })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setShowNodeSearchBar = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ showNodeSearchBar: !pre.showNodeSearchBar })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setShowNodeLabel = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ showNodeLabel: !pre.showNodeLabel })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setEnableHealthCheck = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableHealthCheck: !pre.enableHealthCheck })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setGraphQueryMaxDepth = useCallback((depth: number) => {
|
||||
if (depth < 1) return
|
||||
useSettingsStore.setState({ graphQueryMaxDepth: depth })
|
||||
useGraphStore.getState().setGraphDataFetchAttempted(false)
|
||||
}, [])
|
||||
|
||||
const setGraphMaxNodes = useCallback((nodes: number) => {
|
||||
const maxLimit = backendMaxGraphNodes || 1000
|
||||
if (nodes < 1 || nodes > maxLimit) return
|
||||
useSettingsStore.getState().setGraphMaxNodes(nodes, true)
|
||||
}, [backendMaxGraphNodes])
|
||||
|
||||
const handleGenerateRandomGraph = useCallback(() => {
|
||||
const graph = randomGraph()
|
||||
useGraphStore.getState().setSigmaGraph(graph)
|
||||
}, [randomGraph])
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const saveSettings = () => setOpened(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
tooltip={t('graphPanel.sideBar.settings.settings')}
|
||||
size="icon"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
collisionPadding={5}
|
||||
className="p-2 max-w-[200px]"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<LabeledCheckBox
|
||||
checked={enableHealthCheck}
|
||||
onCheckedChange={setEnableHealthCheck}
|
||||
label={t('graphPanel.sideBar.settings.healthCheck')}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showPropertyPanel}
|
||||
onCheckedChange={setShowPropertyPanel}
|
||||
label={t('graphPanel.sideBar.settings.showPropertyPanel')}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={showNodeSearchBar}
|
||||
onCheckedChange={setShowNodeSearchBar}
|
||||
label={t('graphPanel.sideBar.settings.showSearchBar')}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showNodeLabel}
|
||||
onCheckedChange={setShowNodeLabel}
|
||||
label={t('graphPanel.sideBar.settings.showNodeLabel')}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableNodeDrag}
|
||||
onCheckedChange={setEnableNodeDrag}
|
||||
label={t('graphPanel.sideBar.settings.nodeDraggable')}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showEdgeLabel}
|
||||
onCheckedChange={setShowEdgeLabel}
|
||||
label={t('graphPanel.sideBar.settings.showEdgeLabel')}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableHideUnselectedEdges}
|
||||
onCheckedChange={setEnableHideUnselectedEdges}
|
||||
label={t('graphPanel.sideBar.settings.hideUnselectedEdges')}
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableEdgeEvents}
|
||||
onCheckedChange={setEnableEdgeEvents}
|
||||
label={t('graphPanel.sideBar.settings.edgeEvents')}
|
||||
disabled={graphEdgeCount > EDGE_PERF_LIMIT}
|
||||
title={
|
||||
graphEdgeCount > EDGE_PERF_LIMIT
|
||||
? t('graphPanel.sideBar.settings.edgeEventsDisabledHint', { count: EDGE_PERF_LIMIT })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="edge-size-min" className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{t('graphPanel.sideBar.settings.edgeSizeRange')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="edge-size-min"
|
||||
type="number"
|
||||
value={minEdgeSize}
|
||||
onChange={(e) => {
|
||||
const newValue = Number(e.target.value);
|
||||
if (!isNaN(newValue) && newValue >= 1 && newValue <= maxEdgeSize) {
|
||||
useSettingsStore.setState({ minEdgeSize: newValue });
|
||||
}
|
||||
}}
|
||||
className="h-6 w-16 min-w-0 pr-1"
|
||||
min={1}
|
||||
max={Math.min(maxEdgeSize, 10)}
|
||||
/>
|
||||
<span>-</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id="edge-size-max"
|
||||
type="number"
|
||||
value={maxEdgeSize}
|
||||
onChange={(e) => {
|
||||
const newValue = Number(e.target.value);
|
||||
if (!isNaN(newValue) && newValue >= minEdgeSize && newValue >= 1 && newValue <= 10) {
|
||||
useSettingsStore.setState({ maxEdgeSize: newValue });
|
||||
}
|
||||
}}
|
||||
className="h-6 w-16 min-w-0 pr-1"
|
||||
min={minEdgeSize}
|
||||
max={10}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={() => useSettingsStore.setState({ minEdgeSize: 1, maxEdgeSize: 5 })}
|
||||
type="button"
|
||||
title={t('graphPanel.sideBar.settings.resetToDefault')}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<LabeledNumberInput
|
||||
label={t('graphPanel.sideBar.settings.maxQueryDepth')}
|
||||
min={1}
|
||||
value={graphQueryMaxDepth}
|
||||
defaultValue={3}
|
||||
onEditFinished={setGraphQueryMaxDepth}
|
||||
/>
|
||||
<LabeledNumberInput
|
||||
label={`${t('graphPanel.sideBar.settings.maxNodes')} (≤ ${backendMaxGraphNodes || 1000})`}
|
||||
min={1}
|
||||
max={backendMaxGraphNodes || 1000}
|
||||
value={graphMaxNodes}
|
||||
defaultValue={backendMaxGraphNodes || 1000}
|
||||
onEditFinished={setGraphMaxNodes}
|
||||
/>
|
||||
{/* Development/Testing Section - Only visible in development mode */}
|
||||
{import.meta.env.DEV && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm leading-none font-medium text-muted-foreground">
|
||||
Dev Options
|
||||
</label>
|
||||
<Button
|
||||
onClick={handleGenerateRandomGraph}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
Gen Random Graph
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto px-4"
|
||||
>
|
||||
{t('graphPanel.sideBar.settings.save')}
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Component that displays current values of important graph settings
|
||||
* Positioned to the right of the toolbar at the bottom-left corner
|
||||
*/
|
||||
const SettingsDisplay = () => {
|
||||
const { t } = useTranslation()
|
||||
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
|
||||
// Live counts of the rendered graph (reactive: updated on build/expand/prune).
|
||||
// Reading sigmaGraph.order/.size directly would not re-render, since
|
||||
// expand/prune mutate the graph in place.
|
||||
const graphNodeCount = useGraphStore.use.graphNodeCount()
|
||||
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-[calc(1rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
|
||||
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
|
||||
<div>{t('graphPanel.sideBar.settings.node')}: {graphNodeCount}</div>
|
||||
<div>{t('graphPanel.sideBar.settings.edge')}: {graphEdgeCount}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsDisplay
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCamera, useSigma } from '@react-sigma/core'
|
||||
import { useCallback } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon, RotateCwIcon, RotateCcwIcon } from 'lucide-react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Component that provides zoom controls for the graph viewer.
|
||||
*/
|
||||
const ZoomControl = () => {
|
||||
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
|
||||
const sigma = useSigma()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
|
||||
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
|
||||
const handleResetZoom = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
try {
|
||||
// First clear any custom bounding box and refresh
|
||||
sigma.setCustomBBox(null)
|
||||
sigma.refresh()
|
||||
|
||||
// Get graph after refresh
|
||||
const graph = sigma.getGraph()
|
||||
|
||||
// Check if graph has nodes before accessing them
|
||||
if (!graph?.order || graph.nodes().length === 0) {
|
||||
// Use reset() for empty graph case
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
sigma.getCamera().animate(
|
||||
{ x: 0.5, y: 0.5, ratio: 1.1 },
|
||||
{ duration: 1000 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error resetting zoom:', error)
|
||||
// Use reset() as fallback on error
|
||||
reset()
|
||||
}
|
||||
}, [sigma, reset])
|
||||
|
||||
const handleRotate = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
const camera = sigma.getCamera()
|
||||
const currentAngle = camera.angle
|
||||
const newAngle = currentAngle + Math.PI / 8
|
||||
|
||||
camera.animate(
|
||||
{ angle: newAngle },
|
||||
{ duration: 200 }
|
||||
)
|
||||
}, [sigma])
|
||||
|
||||
const handleRotateCounterClockwise = useCallback(() => {
|
||||
if (!sigma) return
|
||||
|
||||
const camera = sigma.getCamera()
|
||||
const currentAngle = camera.angle
|
||||
const newAngle = currentAngle - Math.PI / 8
|
||||
|
||||
camera.animate(
|
||||
{ angle: newAngle },
|
||||
{ duration: 200 }
|
||||
)
|
||||
}, [sigma])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleRotate}
|
||||
tooltip={t('graphPanel.sideBar.zoomControl.rotateCamera')}
|
||||
size="icon"
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleRotateCounterClockwise}
|
||||
tooltip={t('graphPanel.sideBar.zoomControl.rotateCameraCounterClockwise')}
|
||||
size="icon"
|
||||
>
|
||||
<RotateCcwIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleResetZoom}
|
||||
tooltip={t('graphPanel.sideBar.zoomControl.resetZoom')}
|
||||
size="icon"
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t('graphPanel.sideBar.zoomControl.zoomIn')} size="icon">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t('graphPanel.sideBar.zoomControl.zoomOut')} size="icon">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ZoomControl
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export default function GithubIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.4 3-.405 1.02.005 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
import { ReactNode, useEffect, useMemo, useRef, memo, useState } from 'react' // Import useMemo
|
||||
import { Message } from '@/api/lightrag'
|
||||
import useTheme from '@/hooks/useTheme'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeReact from 'rehype-react'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import remarkMath from 'remark-math'
|
||||
import mermaid from 'mermaid'
|
||||
import { remarkFootnotes } from '@/utils/remarkFootnotes'
|
||||
|
||||
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
|
||||
|
||||
import { LoaderIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// KaTeX configuration options interface
|
||||
interface KaTeXOptions {
|
||||
errorColor?: string;
|
||||
throwOnError?: boolean;
|
||||
displayMode?: boolean;
|
||||
strict?: boolean;
|
||||
trust?: boolean;
|
||||
errorCallback?: (error: string, latex: string) => void;
|
||||
}
|
||||
|
||||
export type MessageWithError = Message & {
|
||||
id: string // Unique identifier for stable React keys
|
||||
isError?: boolean
|
||||
isThinking?: boolean // Flag to indicate if the message is in a "thinking" state
|
||||
isAborted?: boolean // Flag to indicate the user terminated this query (response may be incomplete)
|
||||
/**
|
||||
* Indicates if the mermaid diagram in this message has been rendered.
|
||||
* Used to persist the rendering state across updates and prevent flickering.
|
||||
*/
|
||||
mermaidRendered?: boolean
|
||||
/**
|
||||
* Indicates if the LaTeX formulas in this message are complete and ready for rendering.
|
||||
* Used to prevent red error text during streaming of incomplete LaTeX formulas.
|
||||
*/
|
||||
latexRendered?: boolean
|
||||
}
|
||||
|
||||
// Restore original component definition and export
|
||||
export const ChatMessage = ({
|
||||
message,
|
||||
isTabActive = true
|
||||
}: {
|
||||
message: MessageWithError
|
||||
isTabActive?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const [katexPlugin, setKatexPlugin] = useState<((options?: KaTeXOptions) => any) | null>(null)
|
||||
const [isThinkingExpanded, setIsThinkingExpanded] = useState<boolean>(false)
|
||||
|
||||
// Directly use props passed from the parent.
|
||||
const { thinkingContent, displayContent, thinkingTime, isThinking } = message
|
||||
|
||||
// Reset expansion state when new thinking starts.
|
||||
// Render-time comparison avoids cascading renders from setState-in-useEffect.
|
||||
const [previousThinkingState, setPreviousThinkingState] = useState({
|
||||
isThinking,
|
||||
messageId: message.id
|
||||
})
|
||||
if (
|
||||
previousThinkingState.isThinking !== isThinking ||
|
||||
previousThinkingState.messageId !== message.id
|
||||
) {
|
||||
setPreviousThinkingState({ isThinking, messageId: message.id })
|
||||
if (isThinking) {
|
||||
setIsThinkingExpanded(false)
|
||||
}
|
||||
}
|
||||
|
||||
// The content to display is now non-ambiguous.
|
||||
const finalThinkingContent = thinkingContent
|
||||
// For user messages, displayContent will be undefined, so we fall back to content.
|
||||
// For assistant messages, we prefer displayContent but fallback to content for backward compatibility
|
||||
const finalDisplayContent = message.role === 'user'
|
||||
? message.content
|
||||
: (displayContent !== undefined ? displayContent : (message.content || ''))
|
||||
|
||||
// Load KaTeX rehype plugin dynamically
|
||||
// Note: KaTeX extensions (mhchem, copy-tex) are imported statically in main.tsx
|
||||
useEffect(() => {
|
||||
const loadKaTeX = async () => {
|
||||
try {
|
||||
const { default: rehypeKatex } = await import('rehype-katex');
|
||||
setKatexPlugin(() => rehypeKatex);
|
||||
} catch (error) {
|
||||
console.error('Failed to load KaTeX plugin:', error);
|
||||
setKatexPlugin(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadKaTeX();
|
||||
}, []);
|
||||
|
||||
const mainMarkdownComponents = useMemo(() => ({
|
||||
code: (props: any) => {
|
||||
const { inline, className, children, ...restProps } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : undefined;
|
||||
|
||||
// Handle math blocks ($$...$$) - provide better container and styling
|
||||
if (language === 'math' && !inline) {
|
||||
return (
|
||||
<div className="katex-display-wrapper my-4 overflow-x-auto">
|
||||
<div className="text-current">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle inline math ($...$) - ensure proper inline display
|
||||
if (language === 'math' && inline) {
|
||||
return (
|
||||
<span className="katex-inline-wrapper">
|
||||
<span className="text-current">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle all other code (inline and block)
|
||||
return (
|
||||
<CodeHighlight
|
||||
inline={inline}
|
||||
className={className}
|
||||
{...restProps}
|
||||
renderAsDiagram={message.mermaidRendered ?? false}
|
||||
messageRole={message.role}
|
||||
>
|
||||
{children}
|
||||
</CodeHighlight>
|
||||
);
|
||||
},
|
||||
p: ({ children }: { children?: ReactNode }) => <div className="my-2">{children}</div>,
|
||||
h1: ({ children }: { children?: ReactNode }) => <h1 className="text-xl font-bold mt-4 mb-2">{children}</h1>,
|
||||
h2: ({ children }: { children?: ReactNode }) => <h2 className="text-lg font-bold mt-4 mb-2">{children}</h2>,
|
||||
h3: ({ children }: { children?: ReactNode }) => <h3 className="text-base font-bold mt-3 mb-2">{children}</h3>,
|
||||
h4: ({ children }: { children?: ReactNode }) => <h4 className="text-base font-semibold mt-3 mb-2">{children}</h4>,
|
||||
ul: ({ children }: { children?: ReactNode }) => <ul className="list-disc pl-5 my-2">{children}</ul>,
|
||||
ol: ({ children }: { children?: ReactNode }) => <ol className="list-decimal pl-5 my-2">{children}</ol>,
|
||||
li: ({ children }: { children?: ReactNode }) => <li className="my-1">{children}</li>
|
||||
}), [message.mermaidRendered, message.role]);
|
||||
|
||||
const thinkingMarkdownComponents = useMemo(() => ({
|
||||
code: (props: any) => (<CodeHighlight {...props} renderAsDiagram={message.mermaidRendered ?? false} messageRole={message.role} />)
|
||||
}), [message.mermaidRendered, message.role]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
message.role === 'user'
|
||||
? 'max-w-[80%] bg-primary text-primary-foreground'
|
||||
: message.isError
|
||||
? 'w-[95%] bg-red-100 text-red-600 dark:bg-red-950 dark:text-red-400'
|
||||
: 'w-[95%] bg-muted'
|
||||
} rounded-lg px-4 py-2`}
|
||||
>
|
||||
{/* Thinking process display - only for assistant messages */}
|
||||
{/* Always render to prevent layout shift when switching tabs */}
|
||||
{message.role === 'assistant' && (isThinking || thinkingTime !== null) && (
|
||||
<div className={cn(
|
||||
'mb-2',
|
||||
// Reduce visual priority in inactive tabs while maintaining layout
|
||||
!isTabActive && 'opacity-50'
|
||||
)}>
|
||||
<div
|
||||
className="flex items-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-200 text-sm cursor-pointer select-none"
|
||||
onClick={() => {
|
||||
// Allow expansion when there's thinking content, even during thinking process
|
||||
if (finalThinkingContent && finalThinkingContent.trim() !== '') {
|
||||
setIsThinkingExpanded(!isThinkingExpanded)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isThinking ? (
|
||||
<>
|
||||
{/* Only show spinner animation in active tab to save resources */}
|
||||
{isTabActive && <LoaderIcon className="mr-2 size-4 animate-spin" />}
|
||||
<span>{t('retrievePanel.chatMessage.thinking')}</span>
|
||||
</>
|
||||
) : (
|
||||
typeof thinkingTime === 'number' && <span>{t('retrievePanel.chatMessage.thinkingTime', { time: thinkingTime })}</span>
|
||||
)}
|
||||
{/* Show chevron when there's thinking content, even during thinking process */}
|
||||
{finalThinkingContent && finalThinkingContent.trim() !== '' && <ChevronDownIcon className={`ml-2 size-4 shrink-0 transition-transform ${isThinkingExpanded ? 'rotate-180' : ''}`} />}
|
||||
</div>
|
||||
{/* Show thinking content when expanded and content exists, even during thinking process */}
|
||||
{isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && (
|
||||
<div className="mt-2 pl-4 border-l-2 border-primary/20 dark:border-primary/40 text-sm prose dark:prose-invert max-w-none break-words prose-p:my-1 prose-headings:my-2 [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-6 [&_.footnotes]:pt-3 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes_ol]:text-xs [&_.footnotes_li]:my-0.5 [&_a[href^='#fn']]:text-primary [&_a[href^='#fn']]:no-underline [&_a[href^='#fn']]:hover:underline [&_a[href^='#fnref']]:text-primary [&_a[href^='#fnref']]:no-underline [&_a[href^='#fnref']]:hover:underline text-foreground">
|
||||
{isThinking && (
|
||||
<div className="mb-2 text-xs text-gray-400 dark:text-gray-300 italic">
|
||||
{t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}
|
||||
</div>
|
||||
)}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
...((katexPlugin && (message.latexRendered ?? true)) ? [[katexPlugin, {
|
||||
errorColor: theme === 'dark' ? '#ef4444' : '#dc2626',
|
||||
throwOnError: false,
|
||||
displayMode: false,
|
||||
strict: false,
|
||||
trust: true,
|
||||
// Add silent error handling to avoid console noise
|
||||
errorCallback: (error: string, latex: string) => {
|
||||
// Only show detailed errors in development environment
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('KaTeX rendering error in thinking content:', error, 'for LaTeX:', latex);
|
||||
}
|
||||
}
|
||||
}] as any] : []),
|
||||
rehypeReact
|
||||
]}
|
||||
skipHtml={false}
|
||||
components={thinkingMarkdownComponents}
|
||||
>
|
||||
{finalThinkingContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Main content display */}
|
||||
{finalDisplayContent && (
|
||||
<div className="relative">
|
||||
<div className={`prose dark:prose-invert max-w-none text-sm break-words prose-headings:mt-4 prose-headings:mb-2 prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 [&_.katex]:text-current [&_.katex-display]:my-4 [&_.katex-display]:max-w-full [&_.katex-display_>.base]:overflow-x-auto [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-8 [&_.footnotes]:pt-4 [&_.footnotes]:border-t [&_.footnotes_ol]:text-sm [&_.footnotes_li]:my-1 ${
|
||||
message.role === 'user' ? 'text-primary-foreground' : 'text-foreground'
|
||||
} ${
|
||||
message.role === 'user'
|
||||
? '[&_.footnotes]:border-primary-foreground/30 [&_a[href^="#fn"]]:text-primary-foreground [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary-foreground [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline'
|
||||
: '[&_.footnotes]:border-border [&_a[href^="#fn"]]:text-primary [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline'
|
||||
}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
...((katexPlugin && (message.latexRendered ?? true)) ? [[
|
||||
katexPlugin,
|
||||
{
|
||||
errorColor: theme === 'dark' ? '#ef4444' : '#dc2626',
|
||||
throwOnError: false,
|
||||
displayMode: false,
|
||||
strict: false,
|
||||
trust: true,
|
||||
// Add silent error handling to avoid console noise
|
||||
errorCallback: (error: string, latex: string) => {
|
||||
// Only show detailed errors in development environment
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('KaTeX rendering error in main content:', error, 'for LaTeX:', latex);
|
||||
}
|
||||
}
|
||||
}
|
||||
] as any] : []),
|
||||
rehypeReact
|
||||
]}
|
||||
skipHtml={false}
|
||||
components={mainMarkdownComponents}
|
||||
>
|
||||
{finalDisplayContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* User-terminated hint - response may be incomplete */}
|
||||
{message.isAborted && (
|
||||
<div className="mt-1 text-xs italic text-muted-foreground">
|
||||
{t('retrievePanel.retrieval.userTerminated')}
|
||||
</div>
|
||||
)}
|
||||
{/* Loading indicator - only show in active tab */}
|
||||
{isTabActive && !message.isAborted && (() => {
|
||||
// More comprehensive loading state check
|
||||
const hasVisibleContent = finalDisplayContent && finalDisplayContent.trim() !== '';
|
||||
const isLoadingState = !hasVisibleContent && !isThinking && !thinkingTime;
|
||||
return isLoadingState && <LoaderIcon className="animate-spin duration-2000" />
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the incorrect memo export line
|
||||
|
||||
interface CodeHighlightProps {
|
||||
inline?: boolean
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
renderAsDiagram?: boolean // Flag to indicate if rendering as diagram should be attempted
|
||||
messageRole?: 'user' | 'assistant' // Message role for context-aware styling
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Check if it is a large JSON
|
||||
const isLargeJson = (language: string | undefined, content: string | undefined): boolean => {
|
||||
if (!content || language !== 'json') return false;
|
||||
return content.length > 5000; // JSON larger than 5KB is considered large JSON
|
||||
};
|
||||
|
||||
// Memoize the CodeHighlight component
|
||||
const CodeHighlight = memo(({ inline, className, children, renderAsDiagram = false, messageRole, ...props }: CodeHighlightProps) => {
|
||||
const { theme } = useTheme();
|
||||
const [hasRendered, setHasRendered] = useState(false); // State to track successful render
|
||||
const match = className?.match(/language-(\w+)/);
|
||||
const language = match ? match[1] : undefined;
|
||||
const mermaidRef = useRef<HTMLDivElement>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // Use ReturnType for better typing
|
||||
|
||||
// Get the content string, check if it is a large JSON
|
||||
const contentStr = String(children || '').replace(/\n$/, '');
|
||||
const isLargeJsonBlock = isLargeJson(language, contentStr);
|
||||
|
||||
// Handle Mermaid rendering with debounce
|
||||
useEffect(() => {
|
||||
// Effect should run when renderAsDiagram becomes true or hasRendered changes.
|
||||
// The actual rendering logic inside checks language and hasRendered state.
|
||||
if (renderAsDiagram && !hasRendered && language === 'mermaid' && mermaidRef.current) {
|
||||
const container = mermaidRef.current; // Capture ref value
|
||||
|
||||
// Clear previous timer if dependencies change before timeout (e.g., renderAsDiagram flips quickly)
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (!container) return; // Container might have unmounted
|
||||
|
||||
// Double check hasRendered state inside timeout, in case it changed rapidly
|
||||
if (hasRendered) return;
|
||||
|
||||
try {
|
||||
// Initialize mermaid config
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme === 'dark' ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
// Show loading indicator
|
||||
container.innerHTML = '<div class="flex justify-center items-center p-4"><svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>';
|
||||
|
||||
// Preprocess mermaid content
|
||||
const rawContent = String(children).replace(/\n$/, '').trim();
|
||||
|
||||
// Heuristic check for potentially complete graph definition
|
||||
const looksPotentiallyComplete = rawContent.length > 10 && (
|
||||
rawContent.startsWith('graph') ||
|
||||
rawContent.startsWith('sequenceDiagram') ||
|
||||
rawContent.startsWith('classDiagram') ||
|
||||
rawContent.startsWith('stateDiagram') ||
|
||||
rawContent.startsWith('gantt') ||
|
||||
rawContent.startsWith('pie') ||
|
||||
rawContent.startsWith('flowchart') ||
|
||||
rawContent.startsWith('erDiagram')
|
||||
);
|
||||
|
||||
if (!looksPotentiallyComplete) {
|
||||
console.log('Mermaid content might be incomplete, skipping render attempt:', rawContent);
|
||||
// Optionally keep loading indicator or show a message
|
||||
// container.innerHTML = '<p class="text-sm text-muted-foreground">Waiting for complete diagram...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const processedContent = rawContent
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('subgraph')) {
|
||||
const parts = trimmedLine.split(' ');
|
||||
if (parts.length > 1) {
|
||||
const title = parts.slice(1).join(' ').replace(/["']/g, '');
|
||||
return `subgraph "${title}"`;
|
||||
}
|
||||
}
|
||||
return trimmedLine;
|
||||
})
|
||||
.filter(line => !line.trim().startsWith('linkStyle'))
|
||||
.join('\n');
|
||||
|
||||
const mermaidId = `mermaid-${Date.now()}`;
|
||||
mermaid.render(mermaidId, processedContent)
|
||||
.then(({ svg, bindFunctions }) => {
|
||||
// Check ref and hasRendered state again inside async callback
|
||||
if (mermaidRef.current === container && !hasRendered) {
|
||||
container.innerHTML = svg;
|
||||
setHasRendered(true); // Mark as rendered successfully
|
||||
if (bindFunctions) {
|
||||
try {
|
||||
bindFunctions(container);
|
||||
} catch (bindError) {
|
||||
console.error('Mermaid bindFunctions error:', bindError);
|
||||
container.innerHTML += '<p class="text-orange-500 text-xs">Diagram interactions might be limited.</p>';
|
||||
}
|
||||
}
|
||||
} else if (mermaidRef.current !== container) {
|
||||
console.log('Mermaid container changed before rendering completed.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Mermaid rendering promise error (debounced):', error);
|
||||
console.error('Failed content (debounced):', processedContent);
|
||||
if (mermaidRef.current === container) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorPre = document.createElement('pre');
|
||||
errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
|
||||
errorPre.textContent = `Mermaid diagram error: ${errorMessage}\n\nContent:\n${processedContent}`;
|
||||
container.innerHTML = '';
|
||||
container.appendChild(errorPre);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mermaid synchronous error (debounced):', error);
|
||||
console.error('Failed content (debounced):', String(children));
|
||||
if (mermaidRef.current === container) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorPre = document.createElement('pre');
|
||||
errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words';
|
||||
errorPre.textContent = `Mermaid diagram setup error: ${errorMessage}`;
|
||||
container.innerHTML = '';
|
||||
container.appendChild(errorPre);
|
||||
}
|
||||
}
|
||||
}, 300); // Debounce delay
|
||||
}
|
||||
|
||||
// Cleanup function to clear the timer on unmount or before re-running effect
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
// Dependencies: renderAsDiagram ensures effect runs when diagram should be shown.
|
||||
// Dependencies include all values used inside the effect to satisfy exhaustive-deps.
|
||||
// The !hasRendered check prevents re-execution of render logic after success.
|
||||
}, [renderAsDiagram, hasRendered, language, children, theme]); // Add children and theme back
|
||||
|
||||
// For large JSON, skip syntax highlighting completely and use a simple pre tag
|
||||
if (isLargeJsonBlock) {
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap break-words bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
|
||||
{contentStr}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Render based on language type
|
||||
// If it's a mermaid language block and rendering as diagram is not requested (e.g., incomplete stream), display as plain text
|
||||
if (language === 'mermaid' && !renderAsDiagram) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={theme === 'dark' ? oneDark : oneLight}
|
||||
PreTag="div"
|
||||
language="text" // Use text as language to avoid syntax highlighting errors
|
||||
{...props}
|
||||
>
|
||||
{contentStr}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
|
||||
// If it's a mermaid language block and the message is complete, render as diagram
|
||||
if (language === 'mermaid') {
|
||||
// Container for Mermaid diagram
|
||||
return <div className="mermaid-diagram-container my-4 overflow-x-auto" ref={mermaidRef}></div>;
|
||||
}
|
||||
|
||||
|
||||
// ReactMarkdown determines inline vs block based on markdown syntax
|
||||
// Inline code: `code` (no className with language)
|
||||
// Block code: ```language (has className like "language-js")
|
||||
// If there's no language className and no explicit inline prop, it's likely inline code
|
||||
const isInline = inline ?? !className?.startsWith('language-');
|
||||
|
||||
// Generate dynamic inline code styles based on message role and theme
|
||||
const getInlineCodeStyles = () => {
|
||||
if (messageRole === 'user') {
|
||||
// User messages have dark background (bg-primary), need light inline code
|
||||
return theme === 'dark'
|
||||
? 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30'
|
||||
: 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30';
|
||||
} else {
|
||||
// Assistant messages have light background (bg-muted), need contrasting inline code
|
||||
return theme === 'dark'
|
||||
? 'bg-muted-foreground/20 text-muted-foreground border border-muted-foreground/30'
|
||||
: 'bg-slate-200 text-slate-800 border border-slate-300';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle non-Mermaid code blocks
|
||||
return !isInline ? (
|
||||
<SyntaxHighlighter
|
||||
style={theme === 'dark' ? oneDark : oneLight}
|
||||
PreTag="div"
|
||||
language={language}
|
||||
{...props}
|
||||
>
|
||||
{contentStr}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
// Handle inline code with context-aware styling
|
||||
<code
|
||||
className={cn(
|
||||
className,
|
||||
'mx-1 rounded-sm px-1 py-0.5 font-mono text-sm',
|
||||
getInlineCodeStyles()
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
});
|
||||
|
||||
// Assign display name for React DevTools
|
||||
CodeHighlight.displayName = 'CodeHighlight';
|
||||
@@ -0,0 +1,471 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { QueryMode, QueryRequest } from '@/api/lightrag'
|
||||
// Removed unused import for Text component
|
||||
import Checkbox from '@/components/ui/Checkbox'
|
||||
import Input from '@/components/ui/Input'
|
||||
import UserPromptInputWithHistory from '@/components/ui/UserPromptInputWithHistory'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/Select'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ResetButton = ({ onClick, title }: { onClick: () => void; title: string }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="mr-1 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title={title}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
export default function QuerySettings() {
|
||||
const { t } = useTranslation()
|
||||
const querySettings = useSettingsStore((state) => state.querySettings)
|
||||
const userPromptHistory = useSettingsStore((state) => state.userPromptHistory)
|
||||
|
||||
const handleChange = useCallback((key: keyof QueryRequest, value: any) => {
|
||||
useSettingsStore.getState().updateQuerySettings({ [key]: value })
|
||||
}, [])
|
||||
|
||||
const handleSelectFromHistory = useCallback((prompt: string) => {
|
||||
handleChange('user_prompt', prompt)
|
||||
}, [handleChange])
|
||||
|
||||
const handleDeleteFromHistory = useCallback((index: number) => {
|
||||
const newHistory = [...userPromptHistory]
|
||||
newHistory.splice(index, 1)
|
||||
useSettingsStore.getState().setUserPromptHistory(newHistory)
|
||||
}, [userPromptHistory])
|
||||
|
||||
// Default values for reset functionality
|
||||
const defaultValues = useMemo(() => ({
|
||||
mode: 'mix' as QueryMode,
|
||||
top_k: 40,
|
||||
chunk_top_k: 20,
|
||||
max_entity_tokens: 6000,
|
||||
max_relation_tokens: 8000,
|
||||
max_total_tokens: 30000
|
||||
}), [])
|
||||
|
||||
const handleReset = useCallback((key: keyof typeof defaultValues) => {
|
||||
handleChange(key, defaultValues[key])
|
||||
}, [handleChange, defaultValues])
|
||||
|
||||
// Mix offers the best retrieval coverage; Bypass intentionally skips retrieval.
|
||||
// Warn only for the narrower-coverage modes (hybrid/naive/local/global).
|
||||
const showQualityWarning =
|
||||
querySettings.mode !== 'mix' && querySettings.mode !== 'bypass'
|
||||
|
||||
return (
|
||||
<Card className="flex shrink-0 flex-col w-[280px]">
|
||||
<CardHeader className="px-4 pt-4 pb-2">
|
||||
<CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle>
|
||||
<CardDescription className="sr-only">{t('retrievePanel.querySettings.parametersDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="m-0 flex grow flex-col p-0 text-xs">
|
||||
<div className="relative size-full">
|
||||
<div className="absolute inset-0 flex flex-col gap-2 overflow-auto px-2 pr-2">
|
||||
{/* User Prompt - Moved to top for better dropdown space */}
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="user_prompt" className="ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.userPrompt')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.userPromptTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div>
|
||||
<UserPromptInputWithHistory
|
||||
id="user_prompt"
|
||||
value={querySettings.user_prompt || ''}
|
||||
onChange={(value) => handleChange('user_prompt', value)}
|
||||
onSelectFromHistory={handleSelectFromHistory}
|
||||
onDeleteFromHistory={handleDeleteFromHistory}
|
||||
history={userPromptHistory}
|
||||
placeholder={t('retrievePanel.querySettings.userPromptPlaceholder')}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Query Mode */}
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="query_mode_select" className="ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.queryMode')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.queryModeTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<Select
|
||||
value={querySettings.mode}
|
||||
onValueChange={(v) => handleChange('mode', v as QueryMode)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="query_mode_select"
|
||||
className={cn(
|
||||
'hover:bg-primary/5 h-9 cursor-pointer focus:ring-0 focus:ring-offset-0 focus:outline-0 active:right-0 flex-1 text-left [&>span]:break-all [&>span]:line-clamp-1',
|
||||
showQualityWarning &&
|
||||
'border-red-400 bg-red-100 text-red-700 hover:bg-red-100 dark:border-red-600 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/30'
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="mix">{t('retrievePanel.querySettings.queryModeOptions.mix')}</SelectItem>
|
||||
<SelectItem value="hybrid">{t('retrievePanel.querySettings.queryModeOptions.hybrid')}</SelectItem>
|
||||
<SelectItem value="naive">{t('retrievePanel.querySettings.queryModeOptions.naive')}</SelectItem>
|
||||
<SelectItem value="local">{t('retrievePanel.querySettings.queryModeOptions.local')}</SelectItem>
|
||||
<SelectItem value="global">{t('retrievePanel.querySettings.queryModeOptions.global')}</SelectItem>
|
||||
<SelectItem value="bypass">{t('retrievePanel.querySettings.queryModeOptions.bypass')}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ResetButton
|
||||
onClick={() => handleReset('mode')}
|
||||
title="Reset to default (Mix)"
|
||||
/>
|
||||
</div>
|
||||
{showQualityWarning && (
|
||||
<p className="ml-1 text-red-600 dark:text-red-400">
|
||||
{t('retrievePanel.querySettings.queryModeWarning')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* Top K */}
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="top_k" className="ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.topK')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.topKTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id="top_k"
|
||||
type="number"
|
||||
value={querySettings.top_k ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
handleChange('top_k', value === '' ? '' : parseInt(value) || 0)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '' || isNaN(parseInt(value))) {
|
||||
handleChange('top_k', 40)
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
placeholder={t('retrievePanel.querySettings.topKPlaceholder')}
|
||||
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
/>
|
||||
<ResetButton
|
||||
onClick={() => handleReset('top_k')}
|
||||
title="Reset to default"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Chunk Top K */}
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="chunk_top_k" className="ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.chunkTopK')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.chunkTopKTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id="chunk_top_k"
|
||||
type="number"
|
||||
value={querySettings.chunk_top_k ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
handleChange('chunk_top_k', value === '' ? '' : parseInt(value) || 0)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '' || isNaN(parseInt(value))) {
|
||||
handleChange('chunk_top_k', 20)
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
placeholder={t('retrievePanel.querySettings.chunkTopKPlaceholder')}
|
||||
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
/>
|
||||
<ResetButton
|
||||
onClick={() => handleReset('chunk_top_k')}
|
||||
title="Reset to default"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Max Entity Tokens */}
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="max_entity_tokens" className="ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.maxEntityTokens')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.maxEntityTokensTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id="max_entity_tokens"
|
||||
type="number"
|
||||
value={querySettings.max_entity_tokens ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
handleChange('max_entity_tokens', value === '' ? '' : parseInt(value) || 0)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '' || isNaN(parseInt(value))) {
|
||||
handleChange('max_entity_tokens', 6000)
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
placeholder={t('retrievePanel.querySettings.maxEntityTokensPlaceholder')}
|
||||
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
/>
|
||||
<ResetButton
|
||||
onClick={() => handleReset('max_entity_tokens')}
|
||||
title="Reset to default"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Max Relation Tokens */}
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="max_relation_tokens" className="ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.maxRelationTokens')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.maxRelationTokensTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id="max_relation_tokens"
|
||||
type="number"
|
||||
value={querySettings.max_relation_tokens ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
handleChange('max_relation_tokens', value === '' ? '' : parseInt(value) || 0)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '' || isNaN(parseInt(value))) {
|
||||
handleChange('max_relation_tokens', 8000)
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
placeholder={t('retrievePanel.querySettings.maxRelationTokensPlaceholder')}
|
||||
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
/>
|
||||
<ResetButton
|
||||
onClick={() => handleReset('max_relation_tokens')}
|
||||
title="Reset to default"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Max Total Tokens */}
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="max_total_tokens" className="ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.maxTotalTokens')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.maxTotalTokensTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id="max_total_tokens"
|
||||
type="number"
|
||||
value={querySettings.max_total_tokens ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
handleChange('max_total_tokens', value === '' ? '' : parseInt(value) || 0)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '' || isNaN(parseInt(value))) {
|
||||
handleChange('max_total_tokens', 30000)
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
placeholder={t('retrievePanel.querySettings.maxTotalTokensPlaceholder')}
|
||||
className="h-9 flex-1 pr-2 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
/>
|
||||
<ResetButton
|
||||
onClick={() => handleReset('max_total_tokens')}
|
||||
title="Reset to default"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Toggle Options */}
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="enable_rerank" className="flex-1 ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.enableRerank')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.enableRerankTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Checkbox
|
||||
className="mr-10 cursor-pointer"
|
||||
id="enable_rerank"
|
||||
checked={querySettings.enable_rerank}
|
||||
onCheckedChange={(checked) => handleChange('enable_rerank', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="only_need_context" className="flex-1 ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.onlyNeedContext')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.onlyNeedContextTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Checkbox
|
||||
className="mr-10 cursor-pointer"
|
||||
id="only_need_context"
|
||||
checked={querySettings.only_need_context}
|
||||
onCheckedChange={(checked) => {
|
||||
handleChange('only_need_context', checked)
|
||||
if (checked) {
|
||||
handleChange('only_need_prompt', false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="only_need_prompt" className="flex-1 ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.onlyNeedPrompt')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.onlyNeedPromptTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Checkbox
|
||||
className="mr-10 cursor-pointer"
|
||||
id="only_need_prompt"
|
||||
checked={querySettings.only_need_prompt}
|
||||
onCheckedChange={(checked) => {
|
||||
handleChange('only_need_prompt', checked)
|
||||
if (checked) {
|
||||
handleChange('only_need_context', false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label htmlFor="stream" className="flex-1 ml-1 cursor-help">
|
||||
{t('retrievePanel.querySettings.streamResponse')}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{t('retrievePanel.querySettings.streamResponseTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Checkbox
|
||||
className="mr-10 cursor-pointer"
|
||||
id="stream"
|
||||
checked={querySettings.stream}
|
||||
onCheckedChange={(checked) => handleChange('stream', checked)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import type {
|
||||
LightragQueueStatus,
|
||||
LightragRoleLLMConfig,
|
||||
LightragStatus
|
||||
} from '@/api/lightrag'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/Table'
|
||||
|
||||
const ROLE_ORDER = ['extract', 'keyword', 'query', 'vlm']
|
||||
|
||||
type RoleLLMRow = {
|
||||
role: string
|
||||
config: LightragRoleLLMConfig
|
||||
queue?: LightragQueueStatus
|
||||
}
|
||||
|
||||
const textValue = (value: string | number | null | undefined) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const formatKwargs = (value: Record<string, any> | null | undefined): string => {
|
||||
if (!value || typeof value !== 'object') return '-'
|
||||
const entries = Object.entries(value)
|
||||
if (!entries.length) return '-'
|
||||
return entries
|
||||
.map(([k, v]) => {
|
||||
const strVal = typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v)
|
||||
return `${k}=${strVal}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
const statValue = (value: number | undefined) => {
|
||||
return typeof value === 'number' ? value.toString() : '-'
|
||||
}
|
||||
|
||||
type MinerUStatus = NonNullable<LightragStatus['configuration']['mineru']>
|
||||
type DoclingStatus = NonNullable<LightragStatus['configuration']['docling']>
|
||||
|
||||
// Compact param display: values printed verbatim; True bools printed as
|
||||
// their flag name; False bools and empty values dropped entirely. Params
|
||||
// after the endpoint are wrapped in parens so they don't read like URL
|
||||
// path segments.
|
||||
const joinParts = (endpoint: string, parts: string[]): string => {
|
||||
if (!endpoint && !parts.length) return '-'
|
||||
if (!parts.length) return endpoint
|
||||
if (!endpoint) return parts.join(' / ')
|
||||
return `${endpoint} (${parts.join(' / ')})`
|
||||
}
|
||||
|
||||
const formatMinerU = (m: MinerUStatus | undefined): string => {
|
||||
if (!m || (!m.endpoint && !m.api_mode)) return '-'
|
||||
const opts = m.options || {}
|
||||
const parts: string[] = []
|
||||
if (m.api_mode) parts.push(m.api_mode)
|
||||
if (opts.language) parts.push(opts.language)
|
||||
if (opts.enable_table) parts.push('table')
|
||||
if (opts.enable_formula) parts.push('formula')
|
||||
if (m.api_mode === 'official') {
|
||||
if (opts.model_version) parts.push(opts.model_version)
|
||||
if (opts.is_ocr) parts.push('ocr')
|
||||
} else if (m.api_mode === 'local') {
|
||||
if (opts.local_backend) parts.push(opts.local_backend)
|
||||
if (opts.local_parse_method) parts.push(opts.local_parse_method)
|
||||
if (opts.local_image_analysis) parts.push('image_analysis')
|
||||
}
|
||||
return joinParts(m.endpoint || '', parts)
|
||||
}
|
||||
|
||||
const formatDocling = (d: DoclingStatus | undefined): string => {
|
||||
if (!d || !d.endpoint) return '-'
|
||||
const opts = d.options || {}
|
||||
const parts: string[] = []
|
||||
if (opts.ocr_engine) parts.push(opts.ocr_engine)
|
||||
if (opts.do_ocr) parts.push('ocr')
|
||||
if (opts.force_ocr) parts.push('force_ocr')
|
||||
if (opts.do_formula_enrichment) parts.push('formula')
|
||||
return joinParts(d.endpoint, parts)
|
||||
}
|
||||
|
||||
const getModelRows = (status: LightragStatus): RoleLLMRow[] => {
|
||||
const configs = status.configuration.role_llm_config || {}
|
||||
const queues = status.llm_queue_status || {}
|
||||
const discoveredRoles = new Set([...Object.keys(configs), ...Object.keys(queues)])
|
||||
const orderedRoles = [
|
||||
...ROLE_ORDER.filter((role) => discoveredRoles.has(role)),
|
||||
...Array.from(discoveredRoles).filter((role) => !ROLE_ORDER.includes(role))
|
||||
]
|
||||
|
||||
const rows: RoleLLMRow[] = orderedRoles.map((role) => ({
|
||||
role,
|
||||
config: configs[role] || {
|
||||
binding: status.configuration.llm_binding,
|
||||
model: status.configuration.llm_model,
|
||||
host: status.configuration.llm_binding_host,
|
||||
max_async: status.configuration.max_async
|
||||
},
|
||||
queue: queues[role]
|
||||
}))
|
||||
|
||||
if (!rows.length) {
|
||||
rows.push({
|
||||
role: 'base',
|
||||
config: {
|
||||
binding: status.configuration.llm_binding,
|
||||
model: status.configuration.llm_model,
|
||||
host: status.configuration.llm_binding_host,
|
||||
max_async: status.configuration.max_async
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rows.push({
|
||||
role: 'embed',
|
||||
config: {
|
||||
binding: status.configuration.embedding_binding,
|
||||
model: status.configuration.embedding_model,
|
||||
host: status.configuration.embedding_binding_host,
|
||||
max_async: status.configuration.embedding_func_max_async
|
||||
},
|
||||
queue: status.embedding_queue_status
|
||||
})
|
||||
|
||||
if (status.configuration.enable_rerank || status.rerank_queue_status?.available) {
|
||||
rows.push({
|
||||
role: 'rerank',
|
||||
config: {
|
||||
binding: status.configuration.rerank_binding,
|
||||
model: status.configuration.rerank_model,
|
||||
host: status.configuration.rerank_binding_host,
|
||||
max_async: status.rerank_queue_status?.max_async
|
||||
},
|
||||
queue: status.rerank_queue_status
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const StatusCard = ({ status }: { status: LightragStatus | null }) => {
|
||||
const { t } = useTranslation()
|
||||
if (!status) {
|
||||
return <div className="text-foreground text-xs">{t('graphPanel.statusCard.unavailable')}</div>
|
||||
}
|
||||
|
||||
const roleRows = getModelRows(status)
|
||||
const storageWorkspaces = status.configuration.storage_workspaces
|
||||
const defaultWorkspace = status.configuration.workspace
|
||||
const storageColumns = [
|
||||
{
|
||||
key: 'kv',
|
||||
label: t('graphPanel.statusCard.kvStorage'),
|
||||
storageClass: status.configuration.kv_storage,
|
||||
workspace: storageWorkspaces?.kv_storage ?? defaultWorkspace
|
||||
},
|
||||
{
|
||||
key: 'doc-status',
|
||||
label: t('graphPanel.statusCard.docStatusStorage'),
|
||||
storageClass: status.configuration.doc_status_storage,
|
||||
workspace: storageWorkspaces?.doc_status_storage ?? defaultWorkspace
|
||||
},
|
||||
{
|
||||
key: 'vector',
|
||||
label: t('graphPanel.statusCard.vectorStorage'),
|
||||
storageClass: status.configuration.vector_storage,
|
||||
workspace: storageWorkspaces?.vector_storage ?? defaultWorkspace
|
||||
},
|
||||
{
|
||||
key: 'graph',
|
||||
label: t('graphPanel.statusCard.graphStorage'),
|
||||
storageClass: status.configuration.graph_storage,
|
||||
workspace: storageWorkspaces?.graph_storage ?? defaultWorkspace
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-w-[300px] space-y-2 text-xs">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">{t('graphPanel.statusCard.serverInfo')}</h4>
|
||||
<div className="text-foreground grid grid-cols-[160px_1fr] gap-1">
|
||||
<span>{t('graphPanel.statusCard.inputDirectory')}:</span>
|
||||
<span className="truncate">{status.input_directory}</span>
|
||||
<span>{t('graphPanel.statusCard.parser')}:</span>
|
||||
<span
|
||||
className="truncate"
|
||||
title={status.configuration.parser_routing || undefined}
|
||||
>
|
||||
{textValue(status.configuration.parser_routing)}
|
||||
{' (VLM_PROCESS_ENABLE='}
|
||||
{String(status.configuration.vlm_process_enable ?? false)}
|
||||
{')'}
|
||||
</span>
|
||||
<span>{t('graphPanel.statusCard.mineru')}:</span>
|
||||
{(() => {
|
||||
const minerUText = formatMinerU(status.configuration.mineru)
|
||||
return (
|
||||
<span className="truncate" title={minerUText !== '-' ? minerUText : undefined}>
|
||||
{minerUText}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
<span>{t('graphPanel.statusCard.docling')}:</span>
|
||||
{(() => {
|
||||
const doclingText = formatDocling(status.configuration.docling)
|
||||
return (
|
||||
<span className="truncate" title={doclingText !== '-' ? doclingText : undefined}>
|
||||
{doclingText}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
<span>{t('graphPanel.statusCard.otherSettings')}:</span>
|
||||
<span>
|
||||
{status.configuration.summary_language}
|
||||
{' / Sum_on_f '}{status.configuration.force_llm_summary_on_merge.toString()}
|
||||
{' / max_p_i '}{status.configuration.max_parallel_insert}
|
||||
{' / cosine '}{status.configuration.cosine_threshold}
|
||||
{' / rerank '}{status.configuration.min_rerank_score}
|
||||
{' / max_related '}{status.configuration.related_chunk_number}
|
||||
{' / max_g_n '}{status.configuration.max_graph_nodes || '-'}
|
||||
</span>
|
||||
{status.keyed_locks && (
|
||||
<>
|
||||
<span>{t('graphPanel.statusCard.lockStatus')}:</span>
|
||||
<span>
|
||||
{status.server_mode && (
|
||||
<>
|
||||
{status.server_mode}
|
||||
{status.server_mode === 'gunicorn' && status.workers ? ` ${status.workers}` : ''}
|
||||
{' | '}
|
||||
</>
|
||||
)}
|
||||
mp {status.keyed_locks.current_status.pending_mp_cleanup}/{status.keyed_locks.current_status.total_mp_locks} |
|
||||
async {status.keyed_locks.current_status.pending_async_cleanup}/{status.keyed_locks.current_status.total_async_locks}
|
||||
(pid: {status.keyed_locks.process_id})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">{t('graphPanel.statusCard.llmConfig')}</h4>
|
||||
<div className="rounded-md border">
|
||||
<Table className="text-xs">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-7 px-2 py-1">role</TableHead>
|
||||
<TableHead className="h-7 px-2 py-1">
|
||||
binding/model
|
||||
</TableHead>
|
||||
<TableHead className="h-7 px-2 py-1">base_url/kwargs</TableHead>
|
||||
<TableHead className="h-7 px-2 py-1 text-right">
|
||||
queued
|
||||
</TableHead>
|
||||
<TableHead className="h-7 px-2 py-1 text-right">
|
||||
run/max
|
||||
</TableHead>
|
||||
<TableHead className="h-7 px-2 py-1 text-right">
|
||||
req
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roleRows.map(({ role, config, queue }) => {
|
||||
const maxAsync = queue?.max_async ?? config.max_async
|
||||
return (
|
||||
<TableRow key={role} className="hover:bg-muted/30">
|
||||
<TableCell className="px-2 py-1 font-medium capitalize">
|
||||
{role}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[170px] px-2 py-1">
|
||||
<div className="truncate">{textValue(config.binding)}</div>
|
||||
<div className="text-muted-foreground truncate">
|
||||
{textValue(config.model)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px] px-2 py-1">
|
||||
<div className="truncate">{textValue(config.host)}</div>
|
||||
{(() => {
|
||||
const providerOptions = config.metadata?.provider_options as Record<string, any> | undefined
|
||||
const kwargsStr = formatKwargs(providerOptions)
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground truncate"
|
||||
title={kwargsStr !== '-' ? JSON.stringify(providerOptions, null, 2) : undefined}
|
||||
>
|
||||
{kwargsStr}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-1 text-right tabular-nums">
|
||||
{statValue(queue?.queued)}
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-1 text-right tabular-nums">
|
||||
{statValue(queue?.running)}/{statValue(maxAsync)}
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-1 text-right tabular-nums">
|
||||
{statValue(queue?.submitted_total)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">{t('graphPanel.statusCard.storageConfig')}</h4>
|
||||
<div className="rounded-md border">
|
||||
<Table className="text-xs">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{storageColumns.map(({ key, label }) => (
|
||||
<TableHead key={key} className="h-7 min-w-[130px] px-2 py-1">
|
||||
{label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="hover:bg-muted/30">
|
||||
{storageColumns.map(({ key, storageClass }) => (
|
||||
<TableCell key={`${key}-class`} className="break-all px-2 py-1 align-top font-medium">
|
||||
{textValue(storageClass)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow className="hover:bg-muted/30">
|
||||
{storageColumns.map(({ key, workspace }) => (
|
||||
<TableCell key={`${key}-workspace`} className="text-muted-foreground break-all px-2 py-1 align-top">
|
||||
{textValue(workspace)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusCard
|
||||
@@ -0,0 +1,36 @@
|
||||
import { LightragStatus } from '@/api/lightrag'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/Dialog'
|
||||
import StatusCard from './StatusCard'
|
||||
|
||||
interface StatusDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
status: LightragStatus | null
|
||||
}
|
||||
|
||||
const StatusDialog = ({ open, onOpenChange, status }: StatusDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[920px]">
|
||||
<DialogHeader className="pr-6">
|
||||
<DialogTitle>{t('graphPanel.statusDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('graphPanel.statusDialog.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StatusCard status={status} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusDialog
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useEffect, useState } from 'react'
|
||||
import StatusDialog from './StatusDialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const StatusIndicator = () => {
|
||||
const { t } = useTranslation()
|
||||
const health = useBackendState.use.health()
|
||||
const lastCheckTime = useBackendState.use.lastCheckTime()
|
||||
const status = useBackendState.use.status()
|
||||
const [animate, setAnimate] = useState(false)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
// listen to health change
|
||||
useEffect(() => {
|
||||
const animTimer = setTimeout(() => setAnimate(true), 0)
|
||||
const timer = setTimeout(() => setAnimate(false), 300)
|
||||
return () => {
|
||||
clearTimeout(animTimer)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [lastCheckTime])
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 bottom-4 flex items-center gap-2 opacity-80 select-none">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-3 w-3 rounded-full transition-all duration-300',
|
||||
'shadow-[0_0_8px_rgba(0,0,0,0.2)]',
|
||||
health ? 'bg-green-500' : 'bg-red-500',
|
||||
animate && 'scale-125',
|
||||
animate && health && 'shadow-[0_0_12px_rgba(34,197,94,0.4)]',
|
||||
animate && !health && 'shadow-[0_0_12px_rgba(239,68,68,0.4)]'
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{health ? t('graphPanel.statusIndicator.connected') : t('graphPanel.statusIndicator.disconnected')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StatusDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusIndicator
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
))
|
||||
Alert.displayName = 'Alert'
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = 'AlertTitle'
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/Button'
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/Command'
|
||||
|
||||
export interface Option {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export interface AsyncSearchProps<T> {
|
||||
/** Async function to fetch options */
|
||||
fetcher: (query?: string) => Promise<T[]>
|
||||
/** Preload all data ahead of time */
|
||||
preload?: boolean
|
||||
/** Function to filter options */
|
||||
filterFn?: (option: T, query: string) => boolean
|
||||
/** Function to render each option */
|
||||
renderOption: (option: T) => React.ReactNode
|
||||
/** Function to get the value from an option */
|
||||
getOptionValue: (option: T) => string
|
||||
/** Custom not found message */
|
||||
notFound?: React.ReactNode
|
||||
/** Custom loading skeleton */
|
||||
loadingSkeleton?: React.ReactNode
|
||||
/** Currently selected value */
|
||||
value: string | null
|
||||
/** Callback when selection changes */
|
||||
onChange: (value: string) => void
|
||||
/** Callback when focus changes */
|
||||
onFocus: (value: string) => void
|
||||
/** Accessibility label for the search field */
|
||||
ariaLabel?: string
|
||||
/** Placeholder text when no selection */
|
||||
placeholder?: string
|
||||
/** Disable the entire select */
|
||||
disabled?: boolean
|
||||
/** Custom width for the popover */
|
||||
width?: string | number
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Custom trigger button class names */
|
||||
triggerClassName?: string
|
||||
/** Custom no results message */
|
||||
noResultsMessage?: string
|
||||
/** Allow clearing the selection */
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
export function AsyncSearch<T>({
|
||||
fetcher,
|
||||
preload,
|
||||
filterFn,
|
||||
renderOption,
|
||||
getOptionValue,
|
||||
notFound,
|
||||
loadingSkeleton,
|
||||
ariaLabel,
|
||||
placeholder = 'Select...',
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
disabled = false,
|
||||
className,
|
||||
noResultsMessage
|
||||
}: AsyncSearchProps<T>) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [fetchedOptions, setFetchedOptions] = useState<T[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle clicks outside of the component
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node) &&
|
||||
open
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const fetchOptions = useCallback(async (query: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetcher(query)
|
||||
setFetchedOptions(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fetcher])
|
||||
|
||||
// Fetch options from server when not in preload mode (search term changes).
|
||||
// The fetch is a genuine external side effect; setState happens inside fetchOptions
|
||||
// after the async call resolves, so the rule fires on the synchronous loading flag.
|
||||
useEffect(() => {
|
||||
if (preload) return
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchOptions(debouncedSearchTerm)
|
||||
}, [preload, debouncedSearchTerm, fetchOptions])
|
||||
|
||||
// Load initial value
|
||||
useEffect(() => {
|
||||
if (!value) return
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchOptions(value)
|
||||
}, [value, fetchOptions])
|
||||
|
||||
// In preload mode, derive filtered options without mutating state
|
||||
const options = useMemo(() => {
|
||||
if (preload && debouncedSearchTerm) {
|
||||
return fetchedOptions.filter((option) =>
|
||||
filterFn ? filterFn(option, debouncedSearchTerm) : true
|
||||
)
|
||||
}
|
||||
return fetchedOptions
|
||||
}, [preload, debouncedSearchTerm, filterFn, fetchedOptions])
|
||||
|
||||
const handleSelect = useCallback((currentValue: string) => {
|
||||
onChange(currentValue)
|
||||
requestAnimationFrame(() => {
|
||||
// Blur the input to ensure focus event triggers on next click
|
||||
const input = document.activeElement as HTMLElement
|
||||
input?.blur()
|
||||
// Close the dropdown
|
||||
setOpen(false)
|
||||
})
|
||||
}, [onChange])
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setOpen(true)
|
||||
// Use current search term to fetch options
|
||||
fetchOptions(searchTerm)
|
||||
}, [searchTerm, fetchOptions])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.cmd-item')) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(disabled && 'cursor-not-allowed opacity-50', className)}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<Command shouldFilter={false} className="bg-transparent">
|
||||
<div>
|
||||
<CommandInput
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
className="max-h-8"
|
||||
aria-label={ariaLabel}
|
||||
onFocus={handleFocus}
|
||||
onValueChange={(value) => {
|
||||
setSearchTerm(value)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CommandList hidden={!open}>
|
||||
{error && <div className="text-destructive p-4 text-center">{error}</div>}
|
||||
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
options.length === 0 &&
|
||||
(notFound || (
|
||||
<CommandEmpty>{noResultsMessage || 'No results found.'}</CommandEmpty>
|
||||
))}
|
||||
<CommandGroup>
|
||||
{options.map((option, idx) => (
|
||||
<React.Fragment key={getOptionValue(option) + `-fragment-${idx}`}>
|
||||
<CommandItem
|
||||
key={getOptionValue(option) + `${idx}`}
|
||||
value={getOptionValue(option)}
|
||||
onSelect={handleSelect}
|
||||
onMouseMove={() => onFocus(getOptionValue(option))}
|
||||
className="truncate cmd-item"
|
||||
>
|
||||
{renderOption(option)}
|
||||
</CommandItem>
|
||||
{idx !== options.length - 1 && (
|
||||
<div key={`divider-${idx}`} className="bg-foreground/10 h-[1px]" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultLoadingSkeleton() {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<CommandItem disabled>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="bg-muted h-6 w-6 animate-pulse rounded-full" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
|
||||
export interface Option {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export interface AsyncSelectProps<T> {
|
||||
/** Async function to fetch options */
|
||||
fetcher: (query?: string) => Promise<T[]>
|
||||
/** Preload all data ahead of time */
|
||||
preload?: boolean
|
||||
/** Function to filter options */
|
||||
filterFn?: (option: T, query: string) => boolean
|
||||
/** Function to render each option */
|
||||
renderOption: (option: T) => React.ReactNode
|
||||
/** Function to get the value from an option */
|
||||
getOptionValue: (option: T) => string
|
||||
/** Function to get the display value for the selected option */
|
||||
getDisplayValue: (option: T) => React.ReactNode
|
||||
/** Custom not found message */
|
||||
notFound?: React.ReactNode
|
||||
/** Custom loading skeleton */
|
||||
loadingSkeleton?: React.ReactNode
|
||||
/** Currently selected value */
|
||||
value: string
|
||||
/** Callback when selection changes */
|
||||
onChange: (value: string) => void
|
||||
/** Callback before opening the dropdown (async supported) */
|
||||
onBeforeOpen?: () => void | Promise<void>
|
||||
/** Accessibility label for the select field */
|
||||
ariaLabel?: string
|
||||
/** Placeholder text when no selection */
|
||||
placeholder?: string
|
||||
/** Display text for search placeholder */
|
||||
searchPlaceholder?: string
|
||||
/** Disable the entire select */
|
||||
disabled?: boolean
|
||||
/** Custom width for the popover *
|
||||
width?: string | number
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Custom trigger button class names */
|
||||
triggerClassName?: string
|
||||
/** Custom search input class names */
|
||||
searchInputClassName?: string
|
||||
/** Custom no results message */
|
||||
noResultsMessage?: string
|
||||
/** Custom trigger tooltip */
|
||||
triggerTooltip?: string
|
||||
/** Allow clearing the selection */
|
||||
clearable?: boolean
|
||||
/** Debounce time in milliseconds */
|
||||
debounceTime?: number
|
||||
}
|
||||
|
||||
export function AsyncSelect<T>({
|
||||
fetcher,
|
||||
preload,
|
||||
filterFn,
|
||||
renderOption,
|
||||
getOptionValue,
|
||||
getDisplayValue,
|
||||
notFound,
|
||||
loadingSkeleton,
|
||||
ariaLabel,
|
||||
placeholder = 'Select...',
|
||||
searchPlaceholder,
|
||||
value,
|
||||
onChange,
|
||||
onBeforeOpen,
|
||||
disabled = false,
|
||||
className,
|
||||
triggerClassName,
|
||||
searchInputClassName,
|
||||
noResultsMessage,
|
||||
triggerTooltip,
|
||||
clearable = true,
|
||||
debounceTime = 150
|
||||
}: AsyncSelectProps<T>) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [originalOptions, setOriginalOptions] = useState<T[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : debounceTime)
|
||||
|
||||
// Derive the displayed options from the fetched list and search term, instead
|
||||
// of mirroring them via setState in an effect.
|
||||
const options = useMemo(() => {
|
||||
if (preload && debouncedSearchTerm) {
|
||||
return originalOptions.filter((option) =>
|
||||
filterFn ? filterFn(option, debouncedSearchTerm) : true
|
||||
)
|
||||
}
|
||||
return originalOptions
|
||||
}, [preload, debouncedSearchTerm, filterFn, originalOptions])
|
||||
|
||||
// Derive selected option from value + currently-loaded options.
|
||||
const selectedOption = useMemo(
|
||||
() => (value ? options.find((opt) => getOptionValue(opt) === value) ?? null : null),
|
||||
[value, options, getOptionValue]
|
||||
)
|
||||
|
||||
// Show the raw value as a placeholder until the matching option is loaded.
|
||||
const initialValueDisplay = useMemo(
|
||||
() => (value && !selectedOption ? <div>{value}</div> : null),
|
||||
[value, selectedOption]
|
||||
)
|
||||
|
||||
// Fetch options whenever search term changes (skip filtering-only re-runs in preload mode)
|
||||
useEffect(() => {
|
||||
const fetchOptions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetcher(debouncedSearchTerm)
|
||||
setOriginalOptions(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (preload && originalOptions.length > 0) {
|
||||
// Already fetched; rely on the memoised filter above.
|
||||
return
|
||||
}
|
||||
fetchOptions()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher, debouncedSearchTerm, preload])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(currentValue: string) => {
|
||||
const newValue = clearable && currentValue === value ? '' : currentValue
|
||||
onChange(newValue)
|
||||
setOpen(false)
|
||||
},
|
||||
[value, onChange, clearable]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
async (newOpen: boolean) => {
|
||||
if (newOpen && onBeforeOpen) {
|
||||
await onBeforeOpen()
|
||||
}
|
||||
setOpen(newOpen)
|
||||
},
|
||||
[onBeforeOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'justify-between',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
triggerClassName
|
||||
)}
|
||||
disabled={disabled}
|
||||
tooltip={triggerTooltip}
|
||||
side="bottom"
|
||||
>
|
||||
{value === '*' ? <div>*</div> : (selectedOption ? getDisplayValue(selectedOption) : (initialValueDisplay || placeholder))}
|
||||
<ChevronsUpDown className="opacity-50" size={10} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn('p-0', className)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={5}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<div className="relative w-full border-b">
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder || 'Search...'}
|
||||
value={searchTerm}
|
||||
onValueChange={(value) => {
|
||||
setSearchTerm(value)
|
||||
}}
|
||||
className={searchInputClassName}
|
||||
/>
|
||||
{loading && options.length > 0 && (
|
||||
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CommandList>
|
||||
{error && <div className="text-destructive p-4 text-center">{error}</div>}
|
||||
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
options.length === 0 &&
|
||||
(notFound || (
|
||||
<CommandEmpty>
|
||||
{noResultsMessage || 'No results found.'}
|
||||
</CommandEmpty>
|
||||
))}
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const optionValue = getOptionValue(option);
|
||||
// Fix cmdk filtering issue: use empty string when search is empty
|
||||
// This ensures all items are shown when searchTerm is empty
|
||||
const itemValue = searchTerm.trim() === '' ? '' : optionValue;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={optionValue}
|
||||
value={itemValue}
|
||||
onSelect={() => {
|
||||
handleSelect(optionValue);
|
||||
}}
|
||||
className="truncate"
|
||||
>
|
||||
{renderOption(option)}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto h-3 w-3',
|
||||
value === optionValue ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultLoadingSkeleton() {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<CommandItem disabled>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="bg-muted h-6 w-6 animate-pulse rounded-full" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export default Badge
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'size-8'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, tooltip, size, side = 'right', asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
if (!tooltip) {
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export type ButtonVariantType = Exclude<
|
||||
NonNullable<Parameters<typeof buttonVariants>[0]>['variant'],
|
||||
undefined
|
||||
>
|
||||
|
||||
export default Button
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ComponentRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-muted data-[state=checked]:text-muted-foreground h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export default Checkbox
|
||||
@@ -0,0 +1,142 @@
|
||||
import * as React from 'react'
|
||||
import { type DialogProps } from '@radix-ui/react-dialog'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Dialog, DialogContent } from './Dialog'
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[selected=\'true\']:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/Table'
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
// TanStack Table returns unstable functions by design; keep this wrapper out of the
|
||||
// incompatible-library warning rather than pretending it is compiler-safe.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/30',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardDescription, CardTitle } from '@/components/ui/Card'
|
||||
import { FilesIcon } from 'lucide-react'
|
||||
|
||||
interface EmptyCardProps extends React.ComponentPropsWithoutRef<typeof Card> {
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
export default function EmptyCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon = FilesIcon,
|
||||
action,
|
||||
className,
|
||||
...props
|
||||
}: EmptyCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full min-h-0 w-full flex-col items-center justify-center space-y-6 rounded-none bg-transparent p-16',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mr-4 shrink-0 rounded-full border border-dashed p-4">
|
||||
<Icon className="text-muted-foreground size-8" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</div>
|
||||
{action ? action : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* @see https://github.com/sadmann7/file-uploader
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
import { FileText, Upload, X } from 'lucide-react'
|
||||
import Dropzone, { type DropzoneProps, type FileRejection } from 'react-dropzone'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { supportedFileTypes } from '@/lib/constants'
|
||||
|
||||
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Value of the uploader.
|
||||
* @type File[]
|
||||
* @default undefined
|
||||
* @example value={files}
|
||||
*/
|
||||
value?: File[]
|
||||
|
||||
/**
|
||||
* Function to be called when the value changes.
|
||||
* @type (files: File[]) => void
|
||||
* @default undefined
|
||||
* @example onValueChange={(files) => setFiles(files)}
|
||||
*/
|
||||
onValueChange?: (files: File[]) => void
|
||||
|
||||
/**
|
||||
* Function to be called when files are uploaded.
|
||||
* @type (files: File[]) => Promise<void>
|
||||
* @default undefined
|
||||
* @example onUpload={(files) => uploadFiles(files)}
|
||||
*/
|
||||
onUpload?: (files: File[]) => Promise<void>
|
||||
|
||||
/**
|
||||
* Function to be called when files are rejected.
|
||||
* @type (rejections: FileRejection[]) => void
|
||||
* @default undefined
|
||||
* @example onReject={(rejections) => handleRejectedFiles(rejections)}
|
||||
*/
|
||||
onReject?: (rejections: FileRejection[]) => void
|
||||
|
||||
/**
|
||||
* Progress of the uploaded files.
|
||||
* @type Record<string, number> | undefined
|
||||
* @default undefined
|
||||
* @example progresses={{ "file1.png": 50 }}
|
||||
*/
|
||||
progresses?: Record<string, number>
|
||||
|
||||
/**
|
||||
* Error messages for failed uploads.
|
||||
* @type Record<string, string> | undefined
|
||||
* @default undefined
|
||||
* @example fileErrors={{ "file1.png": "Upload failed" }}
|
||||
*/
|
||||
fileErrors?: Record<string, string>
|
||||
|
||||
/**
|
||||
* Accepted file types for the uploader.
|
||||
* @type { [key: string]: string[]}
|
||||
* @default
|
||||
* ```ts
|
||||
* { "text/*": [] }
|
||||
* ```
|
||||
* @example accept={["text/plain", "application/pdf"]}
|
||||
*/
|
||||
accept?: DropzoneProps['accept']
|
||||
|
||||
/**
|
||||
* Maximum file size for the uploader.
|
||||
* @type number | undefined
|
||||
* @default 1024 * 1024 * 200 // 200MB
|
||||
* @example maxSize={1024 * 1024 * 2} // 2MB
|
||||
*/
|
||||
maxSize?: DropzoneProps['maxSize']
|
||||
|
||||
/**
|
||||
* Maximum number of files for the uploader.
|
||||
* @type number | undefined
|
||||
* @default 1
|
||||
* @example maxFileCount={4}
|
||||
*/
|
||||
maxFileCount?: DropzoneProps['maxFiles']
|
||||
|
||||
/**
|
||||
* Whether the uploader should accept multiple files.
|
||||
* @type boolean
|
||||
* @default false
|
||||
* @example multiple
|
||||
*/
|
||||
multiple?: boolean
|
||||
|
||||
/**
|
||||
* Whether the uploader is disabled.
|
||||
* @type boolean
|
||||
* @default false
|
||||
* @example disabled
|
||||
*/
|
||||
disabled?: boolean
|
||||
|
||||
description?: string
|
||||
}
|
||||
|
||||
function formatBytes(
|
||||
bytes: number,
|
||||
opts: {
|
||||
decimals?: number
|
||||
sizeType?: 'accurate' | 'normal'
|
||||
} = {}
|
||||
) {
|
||||
const { decimals = 0, sizeType = 'normal' } = opts
|
||||
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
if (bytes === 0) return '0 Byte'
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
|
||||
sizeType === 'accurate' ? (accurateSizes[i] ?? 'Bytes') : (sizes[i] ?? 'Bytes')
|
||||
}`
|
||||
}
|
||||
|
||||
function FileUploader(props: FileUploaderProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
value: valueProp,
|
||||
onValueChange,
|
||||
onUpload,
|
||||
onReject,
|
||||
progresses,
|
||||
fileErrors,
|
||||
accept = supportedFileTypes,
|
||||
maxSize = 1024 * 1024 * 200,
|
||||
maxFileCount = 1,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
description,
|
||||
className,
|
||||
...dropzoneProps
|
||||
} = props
|
||||
|
||||
const [files, setFiles] = useControllableState<File[]>({
|
||||
prop: valueProp,
|
||||
defaultProp: [],
|
||||
onChange: onValueChange
|
||||
})
|
||||
|
||||
const onDrop = React.useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
// Calculate total file count including both accepted and rejected files
|
||||
const totalFileCount = (files?.length ?? 0) + acceptedFiles.length + rejectedFiles.length
|
||||
|
||||
// Check file count limits
|
||||
if (!multiple && maxFileCount === 1 && (acceptedFiles.length + rejectedFiles.length) > 1) {
|
||||
toast.error(t('documentPanel.uploadDocuments.fileUploader.singleFileLimit'))
|
||||
return
|
||||
}
|
||||
|
||||
if (totalFileCount > maxFileCount) {
|
||||
toast.error(t('documentPanel.uploadDocuments.fileUploader.maxFilesLimit', { count: maxFileCount }))
|
||||
return
|
||||
}
|
||||
|
||||
// Handle rejected files first - this will set error states
|
||||
if (rejectedFiles.length > 0) {
|
||||
if (onReject) {
|
||||
// Use the onReject callback if provided
|
||||
onReject(rejectedFiles)
|
||||
} else {
|
||||
// Fall back to toast notifications if no callback is provided
|
||||
rejectedFiles.forEach(({ file }) => {
|
||||
toast.error(t('documentPanel.uploadDocuments.fileUploader.fileRejected', { name: file.name }))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Process accepted files
|
||||
const newAcceptedFiles = acceptedFiles.map((file) =>
|
||||
Object.assign(file, {
|
||||
preview: URL.createObjectURL(file)
|
||||
})
|
||||
)
|
||||
|
||||
// Process rejected files for UI display
|
||||
const newRejectedFiles = rejectedFiles.map(({ file }) =>
|
||||
Object.assign(file, {
|
||||
preview: URL.createObjectURL(file),
|
||||
rejected: true
|
||||
})
|
||||
)
|
||||
|
||||
// Combine all files for display
|
||||
const allNewFiles = [...newAcceptedFiles, ...newRejectedFiles]
|
||||
const updatedFiles = files ? [...files, ...allNewFiles] : allNewFiles
|
||||
|
||||
// Update the files state with all files
|
||||
setFiles(updatedFiles)
|
||||
|
||||
// Only upload accepted files - make sure we're not uploading rejected files
|
||||
if (onUpload && acceptedFiles.length > 0) {
|
||||
// Filter out any files that might have been rejected by our custom validator
|
||||
const validFiles = acceptedFiles.filter(file => {
|
||||
// Skip files without a name
|
||||
if (!file.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if file type is accepted
|
||||
const fileExt = `.${file.name.split('.').pop()?.toLowerCase() || ''}`;
|
||||
const isAccepted = Object.entries(accept || {}).some(([mimeType, extensions]) => {
|
||||
return file.type === mimeType || (Array.isArray(extensions) && extensions.includes(fileExt));
|
||||
});
|
||||
|
||||
// Check file size
|
||||
const isSizeValid = file.size <= maxSize;
|
||||
|
||||
return isAccepted && isSizeValid;
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onUpload(validFiles);
|
||||
}
|
||||
}
|
||||
},
|
||||
[files, maxFileCount, multiple, onUpload, onReject, setFiles, t, accept, maxSize]
|
||||
)
|
||||
|
||||
function onRemove(index: number) {
|
||||
if (!files) return
|
||||
const newFiles = files.filter((_, i) => i !== index)
|
||||
setFiles(newFiles)
|
||||
onValueChange?.(newFiles)
|
||||
}
|
||||
|
||||
// Revoke preview url when component unmounts
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (!files) return
|
||||
files.forEach((file) => {
|
||||
if (isFileWithPreview(file)) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-6 overflow-hidden">
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
// remove 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,133 @@
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { forwardRef, useCallback, useState } from 'react'
|
||||
import { NumericFormat, NumericFormatProps } from 'react-number-format'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface NumberInputProps extends Omit<NumericFormatProps, 'value' | 'onValueChange'> {
|
||||
stepper?: number
|
||||
thousandSeparator?: string
|
||||
placeholder?: string
|
||||
defaultValue?: number
|
||||
min?: number
|
||||
max?: number
|
||||
value?: number // Controlled value
|
||||
suffix?: string
|
||||
prefix?: string
|
||||
onValueChange?: (value: number | undefined) => void
|
||||
fixedDecimalScale?: boolean
|
||||
decimalScale?: number
|
||||
}
|
||||
|
||||
const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
(
|
||||
{
|
||||
stepper,
|
||||
thousandSeparator,
|
||||
placeholder,
|
||||
defaultValue,
|
||||
min = -Infinity,
|
||||
max = Infinity,
|
||||
onValueChange,
|
||||
fixedDecimalScale = false,
|
||||
decimalScale = 0,
|
||||
className = undefined,
|
||||
suffix,
|
||||
prefix,
|
||||
value: controlledValue,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [value, setValue] = useState<number | undefined>(controlledValue ?? defaultValue)
|
||||
|
||||
// Sync local state when the controlled value changes (e.g. parent resets the field).
|
||||
// Render-time comparison avoids cascading renders flagged by react-hooks/set-state-in-effect.
|
||||
const [previousControlledValue, setPreviousControlledValue] = useState(controlledValue)
|
||||
if (controlledValue !== previousControlledValue) {
|
||||
setPreviousControlledValue(controlledValue)
|
||||
if (controlledValue !== undefined) setValue(controlledValue)
|
||||
}
|
||||
|
||||
const handleIncrement = useCallback(() => {
|
||||
setValue((prev) =>
|
||||
prev === undefined ? (stepper ?? 1) : Math.min(prev + (stepper ?? 1), max)
|
||||
)
|
||||
}, [stepper, max])
|
||||
|
||||
const handleDecrement = useCallback(() => {
|
||||
setValue((prev) =>
|
||||
prev === undefined ? -(stepper ?? 1) : Math.max(prev - (stepper ?? 1), min)
|
||||
)
|
||||
}, [stepper, min])
|
||||
|
||||
const handleChange = (values: { value: string; floatValue: number | undefined }) => {
|
||||
const newValue = values.floatValue === undefined ? undefined : values.floatValue
|
||||
setValue(newValue)
|
||||
if (onValueChange) {
|
||||
onValueChange(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (value !== undefined) {
|
||||
if (value < min) {
|
||||
setValue(min)
|
||||
;(ref as React.RefObject<HTMLInputElement>).current!.value = String(min)
|
||||
} else if (value > max) {
|
||||
setValue(max)
|
||||
;(ref as React.RefObject<HTMLInputElement>).current!.value = String(max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex">
|
||||
<NumericFormat
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
thousandSeparator={thousandSeparator}
|
||||
decimalScale={decimalScale}
|
||||
fixedDecimalScale={fixedDecimalScale}
|
||||
allowNegative={min < 0}
|
||||
valueIsNumericString
|
||||
onBlur={handleBlur}
|
||||
max={max}
|
||||
min={min}
|
||||
suffix={suffix}
|
||||
prefix={prefix}
|
||||
customInput={(props) => <Input {...props} className={cn('w-full', className)} />}
|
||||
placeholder={placeholder}
|
||||
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
getInputRef={ref}
|
||||
{...props}
|
||||
/>
|
||||
<div className="absolute top-0 right-0 bottom-0 flex flex-col">
|
||||
<Button
|
||||
aria-label="Increase value"
|
||||
className="border-input h-1/2 rounded-l-none rounded-br-none border-b border-l px-2 focus-visible:relative"
|
||||
variant="outline"
|
||||
onClick={handleIncrement}
|
||||
disabled={value === max}
|
||||
>
|
||||
<ChevronUp size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Decrease value"
|
||||
className="border-input h-1/2 rounded-l-none rounded-tr-none border-b border-l px-2 focus-visible:relative"
|
||||
variant="outline"
|
||||
onClick={handleDecrement}
|
||||
disabled={value === min}
|
||||
>
|
||||
<ChevronDown size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
NumberInput.displayName = 'NumberInput'
|
||||
|
||||
export default NumberInput
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from './Button'
|
||||
import Input from './Input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './Select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react'
|
||||
|
||||
export type PaginationControlsProps = {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
pageSize: number
|
||||
totalCount: number
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (pageSize: number) => void
|
||||
isLoading?: boolean
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 100, label: '100' },
|
||||
{ value: 200, label: '200' }
|
||||
]
|
||||
|
||||
export default function PaginationControls({
|
||||
currentPage,
|
||||
totalPages,
|
||||
pageSize,
|
||||
totalCount,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
isLoading = false,
|
||||
compact = false,
|
||||
className
|
||||
}: PaginationControlsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [inputPage, setInputPage] = useState(currentPage.toString())
|
||||
|
||||
// Sync input when currentPage changes externally (render-time comparison
|
||||
// avoids cascading renders flagged by react-hooks/set-state-in-effect).
|
||||
const [previousCurrentPage, setPreviousCurrentPage] = useState(currentPage)
|
||||
if (currentPage !== previousCurrentPage) {
|
||||
setPreviousCurrentPage(currentPage)
|
||||
setInputPage(currentPage.toString())
|
||||
}
|
||||
|
||||
// Handle page input change with debouncing
|
||||
const handlePageInputChange = useCallback((value: string) => {
|
||||
setInputPage(value)
|
||||
}, [])
|
||||
|
||||
// Handle page input submit
|
||||
const handlePageInputSubmit = useCallback(() => {
|
||||
const pageNum = parseInt(inputPage, 10)
|
||||
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
|
||||
onPageChange(pageNum)
|
||||
} else {
|
||||
// Reset to current page if invalid
|
||||
setInputPage(currentPage.toString())
|
||||
}
|
||||
}, [inputPage, totalPages, onPageChange, currentPage])
|
||||
|
||||
// Handle page input key press
|
||||
const handlePageInputKeyPress = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handlePageInputSubmit()
|
||||
}
|
||||
}, [handlePageInputSubmit])
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = useCallback((value: string) => {
|
||||
const newPageSize = parseInt(value, 10)
|
||||
if (!isNaN(newPageSize)) {
|
||||
onPageSizeChange(newPageSize)
|
||||
}
|
||||
}, [onPageSizeChange])
|
||||
|
||||
// Navigation handlers
|
||||
const goToFirstPage = useCallback(() => {
|
||||
if (currentPage > 1 && !isLoading) {
|
||||
onPageChange(1)
|
||||
}
|
||||
}, [currentPage, onPageChange, isLoading])
|
||||
|
||||
const goToPrevPage = useCallback(() => {
|
||||
if (currentPage > 1 && !isLoading) {
|
||||
onPageChange(currentPage - 1)
|
||||
}
|
||||
}, [currentPage, onPageChange, isLoading])
|
||||
|
||||
const goToNextPage = useCallback(() => {
|
||||
if (currentPage < totalPages && !isLoading) {
|
||||
onPageChange(currentPage + 1)
|
||||
}
|
||||
}, [currentPage, totalPages, onPageChange, isLoading])
|
||||
|
||||
const goToLastPage = useCallback(() => {
|
||||
if (currentPage < totalPages && !isLoading) {
|
||||
onPageChange(totalPages)
|
||||
}
|
||||
}, [currentPage, totalPages, onPageChange, isLoading])
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToPrevPage}
|
||||
disabled={currentPage <= 1 || isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={inputPage}
|
||||
onChange={(e) => handlePageInputChange(e.target.value)}
|
||||
onBlur={handlePageInputSubmit}
|
||||
onKeyPress={handlePageInputKeyPress}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-12 text-center text-sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">/ {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage >= totalPages || isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={handlePageSizeChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-16">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between gap-4', className)}>
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('pagination.showing', {
|
||||
start: Math.min((currentPage - 1) * pageSize + 1, totalCount),
|
||||
end: Math.min(currentPage * pageSize, totalCount),
|
||||
total: totalCount
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToFirstPage}
|
||||
disabled={currentPage <= 1 || isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
tooltip={t('pagination.firstPage')}
|
||||
>
|
||||
<ChevronsLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToPrevPage}
|
||||
disabled={currentPage <= 1 || isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
tooltip={t('pagination.prevPage')}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{t('pagination.page')}</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={inputPage}
|
||||
onChange={(e) => handlePageInputChange(e.target.value)}
|
||||
onBlur={handlePageInputSubmit}
|
||||
onKeyPress={handlePageInputKeyPress}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-16 text-center text-sm"
|
||||
/>
|
||||
<span className="text-sm">/ {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage >= totalPages || isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
tooltip={t('pagination.nextPage')}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToLastPage}
|
||||
disabled={currentPage >= totalPages || isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
tooltip={t('pagination.lastPage')}
|
||||
>
|
||||
<ChevronsRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{t('pagination.pageSize')}</span>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={handlePageSizeChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-16">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
// Define the props type to include positioning props
|
||||
type PopoverContentProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
|
||||
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
|
||||
sticky?: 'partial' | 'always';
|
||||
avoidCollisions?: boolean;
|
||||
};
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
PopoverContentProps
|
||||
>(({ className, align = 'center', sideOffset = 4, collisionPadding, sticky, avoidCollisions = false, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
sticky={sticky}
|
||||
avoidCollisions={avoidCollisions}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ComponentRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('bg-secondary relative h-4 w-full overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export default Progress
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -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,62 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { TabVisibilityContext } from './context';
|
||||
import { TabVisibilityContextType } from './types';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
interface TabVisibilityProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component for the TabVisibility context
|
||||
* Manages the visibility state of tabs throughout the application
|
||||
*/
|
||||
export const TabVisibilityProvider: React.FC<TabVisibilityProviderProps> = ({ children }) => {
|
||||
// Get current tab from settings store
|
||||
const currentTab = useSettingsStore.use.currentTab();
|
||||
|
||||
// Initialize visibility state with all tabs visible
|
||||
const [visibleTabs, setVisibleTabs] = useState<Record<string, boolean>>(() => ({
|
||||
'documents': true,
|
||||
'knowledge-graph': true,
|
||||
'retrieval': true,
|
||||
'api': true
|
||||
}));
|
||||
|
||||
// Keep all tabs visible because we use CSS to control TAB visibility instead of React.
|
||||
// TabContent sets its tab to false on unmount (cleanup); this effect resets them to true
|
||||
// whenever the current tab changes, acting as a safety net against stale false values.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setVisibleTabs((prev) => ({
|
||||
...prev,
|
||||
'documents': true,
|
||||
'knowledge-graph': true,
|
||||
'retrieval': true,
|
||||
'api': true
|
||||
})), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [currentTab]);
|
||||
|
||||
// Create the context value with memoization to prevent unnecessary re-renders
|
||||
const contextValue = useMemo<TabVisibilityContextType>(
|
||||
() => ({
|
||||
visibleTabs,
|
||||
setTabVisibility: (tabId: string, isVisible: boolean) => {
|
||||
setVisibleTabs((prev) => ({
|
||||
...prev,
|
||||
[tabId]: isVisible,
|
||||
}));
|
||||
},
|
||||
isTabVisible: (tabId: string) => !!visibleTabs[tabId],
|
||||
}),
|
||||
[visibleTabs]
|
||||
);
|
||||
|
||||
return (
|
||||
<TabVisibilityContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</TabVisibilityContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabVisibilityProvider;
|
||||
@@ -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,73 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTabVisibility } from '@/contexts/useTabVisibility'
|
||||
import { backendBaseUrl } from '@/lib/constants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const getRootTheme = (): 'light' | 'dark' =>
|
||||
window.document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
|
||||
export default function ApiSite() {
|
||||
const { t } = useTranslation()
|
||||
const { isTabVisible } = useTabVisibility()
|
||||
const isApiTabVisible = isTabVisible('api')
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false)
|
||||
const [docsTheme, setDocsTheme] = useState<'light' | 'dark'>(getRootTheme)
|
||||
// Freeze the initial theme so the iframe src never changes after mount;
|
||||
// subsequent theme switches are pushed via postMessage to avoid reloading
|
||||
// the entire Swagger UI on every toggle.
|
||||
const [initialTheme] = useState(docsTheme)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIframeLoaded(true), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
const syncDocsTheme = () => setDocsTheme(getRootTheme())
|
||||
|
||||
syncDocsTheme()
|
||||
const observer = new MutationObserver(syncDocsTheme)
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: 'lightrag:set-docs-theme', theme: docsTheme },
|
||||
'*'
|
||||
)
|
||||
}, [docsTheme])
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: 'lightrag:set-docs-theme', theme: docsTheme },
|
||||
'*'
|
||||
)
|
||||
}
|
||||
|
||||
// Use CSS to hide content when tab is not visible
|
||||
return (
|
||||
<div className={`size-full ${isApiTabVisible ? '' : 'hidden'}`}>
|
||||
{iframeLoaded ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${backendBaseUrl}/docs?theme=${initialTheme}`}
|
||||
className="size-full w-full h-full"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
onLoad={handleIframeLoad}
|
||||
key="api-docs-iframe"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p>{t('apiSite.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
// import { MiniMap } from '@react-sigma/minimap'
|
||||
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
|
||||
import { Settings as SigmaSettings } from 'sigma/settings'
|
||||
import { GraphSearchOption, OptionItem } from '@react-sigma/graph-search'
|
||||
import {
|
||||
EdgeArrowProgram,
|
||||
EdgeLineProgram,
|
||||
EdgeRectangleProgram,
|
||||
NodePointProgram,
|
||||
NodeCircleProgram
|
||||
} from 'sigma/rendering'
|
||||
import { NodeBorderProgram } from '@sigma/node-border'
|
||||
import { EdgeCurvedArrowProgram, createEdgeCurveProgram } from '@sigma/edge-curve'
|
||||
|
||||
import FocusOnNode from '@/components/graph/FocusOnNode'
|
||||
import LayoutsControl from '@/components/graph/LayoutsControl'
|
||||
import GraphControl from '@/components/graph/GraphControl'
|
||||
// import ThemeToggle from '@/components/ThemeToggle'
|
||||
import ZoomControl from '@/components/graph/ZoomControl'
|
||||
import FullScreenControl from '@/components/graph/FullScreenControl'
|
||||
import Settings from '@/components/graph/Settings'
|
||||
import GraphSearch from '@/components/graph/GraphSearch'
|
||||
import GraphLabels from '@/components/graph/GraphLabels'
|
||||
import PropertiesView from '@/components/graph/PropertiesView'
|
||||
import SettingsDisplay from '@/components/graph/SettingsDisplay'
|
||||
import Legend from '@/components/graph/Legend'
|
||||
import LegendButton from '@/components/graph/LegendButton'
|
||||
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import useIsDarkMode from '@/hooks/useIsDarkMode'
|
||||
import { labelColorDarkTheme, labelColorLightTheme, edgeColorDarkTheme, EDGE_PERF_LIMIT } from '@/lib/constants'
|
||||
|
||||
import '@react-sigma/core/lib/style.css'
|
||||
import '@react-sigma/graph-search/lib/style.css'
|
||||
|
||||
// Function to create sigma settings based on theme.
|
||||
// `enableEdgeEvents` MUST be passed in (not toggled at runtime): sigma allocates
|
||||
// the edge WebGL picking buffer once at construction based on this flag, so a
|
||||
// later setSettings({ enableEdgeEvents: true }) cannot retroactively enable edge
|
||||
// hover/click. We therefore key it off the user setting here and let the
|
||||
// SigmaContainer rebuild the instance when the setting changes.
|
||||
const createSigmaSettings = (
|
||||
isDarkTheme: boolean,
|
||||
enableEdgeEvents: boolean
|
||||
): Partial<SigmaSettings> => ({
|
||||
allowInvalidContainer: true,
|
||||
// Nodes use the border program so every node gets a white ring (the
|
||||
// @sigma/node-border default reads `borderColor` for the ring, `color` for
|
||||
// the fill; ring thickness is a fixed 10% of the radius). Single GPU draw
|
||||
// call -- cheap even at 70k+ nodes. (Earlier "border blanks the canvas"
|
||||
// reports were actually the fetch effect never firing -- once that was
|
||||
// fixed, the border program rendered fine.)
|
||||
defaultNodeType: 'border',
|
||||
// 'rect' (EdgeRectangleProgram) draws straight quads that RESPECT the edge
|
||||
// size attribute; EdgeLineProgram renders fixed ~1px GL lines and ignores
|
||||
// size entirely. Rectangles are still far cheaper than tessellated curves.
|
||||
defaultEdgeType: 'rect',
|
||||
renderEdgeLabels: false,
|
||||
// Skip edge rendering while panning/zooming - big win on large graphs.
|
||||
hideEdgesOnMove: true,
|
||||
edgeProgramClasses: {
|
||||
rect: EdgeRectangleProgram,
|
||||
line: EdgeLineProgram,
|
||||
arrow: EdgeArrowProgram,
|
||||
curvedArrow: EdgeCurvedArrowProgram,
|
||||
// kept registered for backward compatibility with edges that still
|
||||
// carry type: 'curvedNoArrow'
|
||||
curvedNoArrow: createEdgeCurveProgram()
|
||||
},
|
||||
nodeProgramClasses: {
|
||||
point: NodePointProgram,
|
||||
default: NodePointProgram,
|
||||
circle: NodeCircleProgram,
|
||||
border: NodeBorderProgram
|
||||
},
|
||||
labelGridCellSize: 60,
|
||||
labelRenderedSizeThreshold: 12,
|
||||
// Off by default (edge picking renders edges to an extra buffer every frame —
|
||||
// costly on large graphs); turning on the "Edge Events" setting rebuilds the
|
||||
// instance with the picking buffer so edges become hover/click-selectable.
|
||||
enableEdgeEvents,
|
||||
// Without per-edge reducers (disabled when nothing is focused), edges with
|
||||
// no color attribute fall back to this.
|
||||
defaultEdgeColor: isDarkTheme ? edgeColorDarkTheme : '#d3d3d3',
|
||||
labelColor: {
|
||||
color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,
|
||||
attribute: 'labelColor'
|
||||
},
|
||||
edgeLabelColor: {
|
||||
color: isDarkTheme ? labelColorDarkTheme : labelColorLightTheme,
|
||||
attribute: 'labelColor'
|
||||
},
|
||||
edgeLabelSize: 8,
|
||||
labelSize: 12
|
||||
})
|
||||
|
||||
const GraphEvents = () => {
|
||||
const registerEvents = useRegisterEvents()
|
||||
const sigma = useSigma()
|
||||
const [draggedNode, setDraggedNode] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Register the events
|
||||
registerEvents({
|
||||
downNode: (e) => {
|
||||
setDraggedNode(e.node)
|
||||
sigma.getGraph().setNodeAttribute(e.node, 'highlighted', true)
|
||||
},
|
||||
// On mouse move, if the drag mode is enabled, we change the position of the draggedNode
|
||||
mousemovebody: (e) => {
|
||||
if (!draggedNode) return
|
||||
// Get new position of node
|
||||
const pos = sigma.viewportToGraph(e)
|
||||
sigma.getGraph().setNodeAttribute(draggedNode, 'x', pos.x)
|
||||
sigma.getGraph().setNodeAttribute(draggedNode, 'y', pos.y)
|
||||
|
||||
// Prevent sigma to move camera:
|
||||
e.preventSigmaDefault()
|
||||
e.original.preventDefault()
|
||||
e.original.stopPropagation()
|
||||
},
|
||||
// On mouse up, we reset the autoscale and the dragging mode
|
||||
mouseup: () => {
|
||||
if (draggedNode) {
|
||||
setDraggedNode(null)
|
||||
sigma.getGraph().removeNodeAttribute(draggedNode, 'highlighted')
|
||||
}
|
||||
},
|
||||
// Disable the autoscale at the first down interaction
|
||||
mousedown: (e) => {
|
||||
// Only set custom BBox if it's a drag operation (mouse button is pressed)
|
||||
const mouseEvent = e.original as MouseEvent
|
||||
if (mouseEvent.buttons !== 0 && !sigma.getCustomBBox()) {
|
||||
sigma.setCustomBBox(sigma.getBBox())
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [registerEvents, sigma, draggedNode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const GraphViewer = () => {
|
||||
const sigmaRef = useRef<any>(null)
|
||||
const prevTheme = useRef<string>('')
|
||||
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
|
||||
const isFetching = useGraphStore.use.isFetching()
|
||||
const isLayoutComputing = useGraphStore.use.isLayoutComputing()
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||
const showLegend = useSettingsStore.use.showLegend()
|
||||
const theme = useSettingsStore.use.theme()
|
||||
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||
const graphEdgeCount = useGraphStore.use.graphEdgeCount()
|
||||
|
||||
// Edge events are disabled above EDGE_PERF_LIMIT regardless of the user
|
||||
// setting: the picking buffer renders edges to an extra frame buffer every
|
||||
// frame, which is too costly on large graphs. The user setting is preserved
|
||||
// (Settings greys the menu item but keeps its value), so dropping back below
|
||||
// the limit auto-restores edge events.
|
||||
const effectiveEdgeEvents = enableEdgeEvents && graphEdgeCount <= EDGE_PERF_LIMIT
|
||||
|
||||
const [isThemeSwitching, setIsThemeSwitching] = useState(false)
|
||||
|
||||
// Resolve effective dark mode (handles theme === 'system' against the OS
|
||||
// preference, and stays reactive to OS color-scheme changes).
|
||||
const isDarkMode = useIsDarkMode()
|
||||
|
||||
// Memoize sigma settings to prevent unnecessary re-creation. Keyed on the
|
||||
// RESOLVED dark mode, not the raw theme: under theme === 'system' + OS dark
|
||||
// the old `theme === 'dark'` check produced light-theme defaults, which the
|
||||
// idle-state (null reducers) graph rendered verbatim.
|
||||
// enableEdgeEvents is in the deps because it must be baked in at construction
|
||||
// (the picking buffer can't be added later); toggling it rebuilds the instance.
|
||||
const memoizedSigmaSettings = useMemo(
|
||||
() => createSigmaSettings(isDarkMode, effectiveEdgeEvents),
|
||||
[isDarkMode, effectiveEdgeEvents]
|
||||
)
|
||||
|
||||
// Detect theme changes and briefly show a loading overlay to avoid flash of
|
||||
// unstyled content. setState is inside setTimeout (async), not synchronously
|
||||
// in the effect body, so react-hooks/set-state-in-effect is not triggered.
|
||||
useEffect(() => {
|
||||
const isThemeChange = prevTheme.current && prevTheme.current !== theme
|
||||
if (isThemeChange) {
|
||||
console.log('Theme switching detected:', prevTheme.current, '->', theme)
|
||||
prevTheme.current = theme
|
||||
|
||||
const switchTimer = setTimeout(() => setIsThemeSwitching(true), 0)
|
||||
const timer = setTimeout(() => {
|
||||
setIsThemeSwitching(false)
|
||||
console.log('Theme switching completed')
|
||||
}, 150)
|
||||
|
||||
return () => {
|
||||
clearTimeout(switchTimer)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
prevTheme.current = theme
|
||||
console.log('Initialized sigma settings for theme:', theme)
|
||||
}, [theme])
|
||||
|
||||
// Clean up sigma instance when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// TAB is mount twice in vite dev mode, this is a workaround
|
||||
|
||||
const sigma = useGraphStore.getState().sigmaInstance
|
||||
if (sigma) {
|
||||
try {
|
||||
// Destroy 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 right-2 bottom-10 z-0">
|
||||
<Legend className="bg-background/60 backdrop-blur-lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||
<MiniMap width="100px" height="100px" />
|
||||
</div> */}
|
||||
|
||||
<SettingsDisplay />
|
||||
</SigmaContainer>
|
||||
|
||||
{/* Loading overlay - shown for data fetch, theme switch, or layout run. */}
|
||||
{(isFetching || isThemeSwitching || isLayoutComputing) && (
|
||||
<div className="bg-background/80 absolute inset-0 z-10 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||
<p>
|
||||
{isThemeSwitching
|
||||
? 'Switching Theme...'
|
||||
: isFetching
|
||||
? 'Loading Graph Data...'
|
||||
: 'Computing Layout...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GraphViewer
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { loginToServer, getAuthStatus } from '@/api/lightrag'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ZapIcon } from 'lucide-react'
|
||||
import AppSettings from '@/components/AppSettings'
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const { login, isAuthenticated } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [checkingAuth, setCheckingAuth] = useState(true)
|
||||
const authCheckRef = useRef(false); // Prevent duplicate calls in Vite dev mode
|
||||
|
||||
useEffect(() => {
|
||||
console.log('LoginPage mounted')
|
||||
}, []);
|
||||
|
||||
// Check if authentication is configured, skip login if not
|
||||
useEffect(() => {
|
||||
|
||||
const checkAuthConfig = async () => {
|
||||
// Prevent duplicate calls in Vite dev mode
|
||||
if (authCheckRef.current) {
|
||||
return;
|
||||
}
|
||||
authCheckRef.current = true;
|
||||
|
||||
try {
|
||||
// If already authenticated, redirect to home
|
||||
if (isAuthenticated) {
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
const status = await getAuthStatus()
|
||||
|
||||
// Set session flag for version check to avoid duplicate checks in App component
|
||||
if (status.core_version || status.api_version) {
|
||||
sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');
|
||||
}
|
||||
|
||||
if (!status.auth_configured && status.access_token) {
|
||||
// If auth is not configured, use the guest token and redirect
|
||||
login(status.access_token, true, status.core_version, status.api_version, status.webui_title || null, status.webui_description || null)
|
||||
if (status.message) {
|
||||
toast.info(status.message)
|
||||
}
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Only set checkingAuth to false if we need to show the login page
|
||||
setCheckingAuth(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to check auth configuration:', error)
|
||||
// Also set checkingAuth to false in case of error
|
||||
setCheckingAuth(false);
|
||||
}
|
||||
// Removed finally block as we're setting checkingAuth earlier
|
||||
}
|
||||
|
||||
// Execute immediately
|
||||
checkAuthConfig()
|
||||
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
}
|
||||
}, [isAuthenticated, login, navigate])
|
||||
|
||||
// Don't render anything while checking auth
|
||||
if (checkingAuth) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!username || !password) {
|
||||
toast.error(t('login.errorEmptyFields'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await loginToServer(username, password)
|
||||
|
||||
// Get previous username from localStorage
|
||||
const previousUsername = localStorage.getItem('LIGHTRAG-PREVIOUS-USER')
|
||||
|
||||
// Check if it's the same user logging in again
|
||||
const isSameUser = previousUsername === username
|
||||
|
||||
// If it's not the same user, clear chat history
|
||||
if (isSameUser) {
|
||||
console.log('Same user logging in, preserving chat history')
|
||||
} else {
|
||||
console.log('Different user logging in, clearing chat history')
|
||||
// Directly clear chat history instead of setting a flag
|
||||
useSettingsStore.getState().setRetrievalHistory([])
|
||||
}
|
||||
|
||||
// Update previous username
|
||||
localStorage.setItem('LIGHTRAG-PREVIOUS-USER', username)
|
||||
|
||||
// Check authentication mode
|
||||
const isGuestMode = response.auth_mode === 'disabled'
|
||||
login(response.access_token, isGuestMode, response.core_version, response.api_version, response.webui_title || null, response.webui_description || null)
|
||||
|
||||
// Set session flag for version check
|
||||
if (response.core_version || response.api_version) {
|
||||
sessionStorage.setItem('VERSION_CHECKED_FROM_LOGIN', 'true');
|
||||
}
|
||||
|
||||
if (isGuestMode) {
|
||||
// Show authentication disabled notification
|
||||
toast.info(response.message || t('login.authDisabled', 'Authentication is disabled. Using guest access.'))
|
||||
} else {
|
||||
toast.success(t('login.successMessage'))
|
||||
}
|
||||
|
||||
// Navigate to home page after successful login
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
console.error('Login failed...', error)
|
||||
toast.error(t('login.errorInvalidCredentials'))
|
||||
|
||||
// Clear any existing auth state
|
||||
useAuthStore.getState().logout()
|
||||
// Clear local storage
|
||||
localStorage.removeItem('LIGHTRAG-API-TOKEN')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
<AppSettings className="bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md" />
|
||||
</div>
|
||||
<Card className="w-full max-w-[480px] shadow-lg mx-4">
|
||||
<CardHeader className="flex items-center justify-center space-y-2 pb-8 pt-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="logo.svg" alt="LightRAG Logo" className="h-12 w-12" />
|
||||
<ZapIcon className="size-10 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">LightRAG</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('login.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-8 pb-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label htmlFor="username-input" className="text-sm font-medium w-16 shrink-0">
|
||||
{t('login.username')}
|
||||
</label>
|
||||
<Input
|
||||
id="username-input"
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="h-11 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label htmlFor="password-input" className="text-sm font-medium w-16 shrink-0">
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<Input
|
||||
id="password-input"
|
||||
type="password"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="h-11 flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 text-base font-medium mt-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('login.loggingIn') : t('login.loginButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
@@ -0,0 +1,920 @@
|
||||
import Textarea from '@/components/ui/Textarea'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { throttle } from '@/lib/utils'
|
||||
import { queryText, queryTextStream } from '@/api/lightrag'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
import QuerySettings from '@/components/retrieval/QuerySettings'
|
||||
import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage'
|
||||
import { EraserIcon, SendIcon, CopyIcon, SquareIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import type { QueryMode } from '@/api/lightrag'
|
||||
|
||||
// Helper function to generate unique IDs with browser compatibility
|
||||
const generateUniqueId = () => {
|
||||
// Use crypto.randomUUID() if available
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback to timestamp + random string for browsers without crypto.randomUUID
|
||||
return `id-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
|
||||
// LaTeX completeness detection function
|
||||
const detectLatexCompleteness = (content: string): boolean => {
|
||||
// Check for unclosed block-level LaTeX formulas ($$...$$)
|
||||
const blockLatexMatches = content.match(/\$\$/g) || []
|
||||
const hasUnclosedBlock = blockLatexMatches.length % 2 !== 0
|
||||
|
||||
// Check for unclosed inline LaTeX formulas ($...$, but not $$)
|
||||
// Remove all block formulas first to avoid interference
|
||||
const contentWithoutBlocks = content.replace(/\$\$[\s\S]*?\$\$/g, '')
|
||||
const inlineLatexMatches = contentWithoutBlocks.match(/(?<!\$)\$(?!\$)/g) || []
|
||||
const hasUnclosedInline = inlineLatexMatches.length % 2 !== 0
|
||||
|
||||
// LaTeX is complete if there are no unclosed formulas
|
||||
return !hasUnclosedBlock && !hasUnclosedInline
|
||||
}
|
||||
|
||||
// Robust COT parsing function to handle multiple think blocks and edge cases
|
||||
const parseCOTContent = (content: string) => {
|
||||
const thinkStartTag = '<think>'
|
||||
const thinkEndTag = '</think>'
|
||||
|
||||
// Find all <think> and </think> tag positions
|
||||
const startMatches: number[] = []
|
||||
const endMatches: number[] = []
|
||||
|
||||
let startIndex = 0
|
||||
while ((startIndex = content.indexOf(thinkStartTag, startIndex)) !== -1) {
|
||||
startMatches.push(startIndex)
|
||||
startIndex += thinkStartTag.length
|
||||
}
|
||||
|
||||
let endIndex = 0
|
||||
while ((endIndex = content.indexOf(thinkEndTag, endIndex)) !== -1) {
|
||||
endMatches.push(endIndex)
|
||||
endIndex += thinkEndTag.length
|
||||
}
|
||||
|
||||
// Analyze COT state
|
||||
const hasThinkStart = startMatches.length > 0
|
||||
const hasThinkEnd = endMatches.length > 0
|
||||
const isThinking = hasThinkStart && (startMatches.length > endMatches.length)
|
||||
|
||||
let thinkingContent = ''
|
||||
let displayContent = content
|
||||
|
||||
if (hasThinkStart) {
|
||||
if (hasThinkEnd && startMatches.length === endMatches.length) {
|
||||
// Complete thinking blocks: extract the last complete thinking content
|
||||
const lastStartIndex = startMatches[startMatches.length - 1]
|
||||
const lastEndIndex = endMatches[endMatches.length - 1]
|
||||
|
||||
if (lastEndIndex > lastStartIndex) {
|
||||
thinkingContent = content.substring(
|
||||
lastStartIndex + thinkStartTag.length,
|
||||
lastEndIndex
|
||||
).trim()
|
||||
|
||||
// Remove all thinking blocks, keep only the final display content
|
||||
displayContent = content.substring(lastEndIndex + thinkEndTag.length).trim()
|
||||
}
|
||||
} else if (isThinking) {
|
||||
// Currently thinking: extract current thinking content
|
||||
const lastStartIndex = startMatches[startMatches.length - 1]
|
||||
thinkingContent = content.substring(lastStartIndex + thinkStartTag.length)
|
||||
displayContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isThinking,
|
||||
thinkingContent,
|
||||
displayContent,
|
||||
hasValidThinkBlock: hasThinkStart && hasThinkEnd && startMatches.length === endMatches.length
|
||||
}
|
||||
}
|
||||
|
||||
export default function RetrievalView() {
|
||||
const { t } = useTranslation()
|
||||
// Get current tab to determine if this tab is active (for performance optimization)
|
||||
const currentTab = useSettingsStore.use.currentTab()
|
||||
const isRetrievalTabActive = currentTab === 'retrieval'
|
||||
|
||||
const [messages, setMessages] = useState<MessageWithError[]>(() => {
|
||||
try {
|
||||
const history = useSettingsStore.getState().retrievalHistory || []
|
||||
// Ensure each message from history has a unique ID and mermaidRendered status
|
||||
return history.map((msg, index) => {
|
||||
try {
|
||||
const msgWithError = msg as MessageWithError // Cast to access potential properties
|
||||
return {
|
||||
...msg,
|
||||
id: msgWithError.id || `hist-${Date.now()}-${index}`, // Add ID if missing
|
||||
mermaidRendered: msgWithError.mermaidRendered ?? true, // Assume historical mermaid is rendered
|
||||
latexRendered: msgWithError.latexRendered ?? true // Assume historical LaTeX is rendered
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error)
|
||||
// Return a default message if there's an error
|
||||
return {
|
||||
role: 'system',
|
||||
content: 'Error loading message',
|
||||
id: `error-${Date.now()}-${index}`,
|
||||
isError: true,
|
||||
mermaidRendered: true
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error loading history:', error)
|
||||
return [] // Return an empty array if there's an error
|
||||
}
|
||||
})
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
// Briefly disable the Stop button right after a query starts so a fast
|
||||
// double-click on Send (which morphs into Stop at the same position) can't
|
||||
// accidentally abort the query it just launched.
|
||||
const [stopDisabled, setStopDisabled] = useState(false)
|
||||
const stopCooldownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [inputError, setInputError] = useState('') // Error message for input
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
// Smart switching logic: use Input for single line, Textarea for multi-line
|
||||
const hasMultipleLines = inputValue.includes('\n')
|
||||
|
||||
// Enhanced event handlers for smart switching
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
if (inputError) setInputError('')
|
||||
}, [inputError])
|
||||
|
||||
// Unified height adjustment function for textarea
|
||||
const adjustTextareaHeight = useCallback((element: HTMLTextAreaElement) => {
|
||||
requestAnimationFrame(() => {
|
||||
element.style.height = 'auto'
|
||||
element.style.height = Math.min(element.scrollHeight, 120) + 'px'
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Scroll to bottom function - restored smooth scrolling with better handling
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// Set flag to indicate this is a programmatic scroll
|
||||
programmaticScrollRef.current = true
|
||||
// Use requestAnimationFrame for better performance
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesEndRef.current) {
|
||||
// Use smooth scrolling for better user experience
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'auto' })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!inputValue.trim() || isLoading) return
|
||||
|
||||
// Parse query mode prefix
|
||||
const allowedModes: QueryMode[] = ['naive', 'local', 'global', 'hybrid', 'mix', 'bypass']
|
||||
const prefixMatch = inputValue.match(/^\/(\w+)\s+([\s\S]+)/)
|
||||
let modeOverride: QueryMode | undefined = undefined
|
||||
let actualQuery = inputValue
|
||||
|
||||
// If input starts with a slash, but does not match the valid prefix pattern, treat as error
|
||||
if (/^\/\S+/.test(inputValue) && !prefixMatch) {
|
||||
setInputError(t('retrievePanel.retrieval.queryModePrefixInvalid'))
|
||||
return
|
||||
}
|
||||
|
||||
if (prefixMatch) {
|
||||
const mode = prefixMatch[1] as QueryMode
|
||||
const query = prefixMatch[2]
|
||||
if (!allowedModes.includes(mode)) {
|
||||
setInputError(
|
||||
t('retrievePanel.retrieval.queryModeError', {
|
||||
modes: 'naive, local, global, hybrid, mix, bypass',
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
modeOverride = mode
|
||||
actualQuery = query
|
||||
}
|
||||
|
||||
// Clear error message
|
||||
setInputError('')
|
||||
|
||||
// Reset thinking timer state for new query to prevent confusion
|
||||
thinkingStartTime.current = null
|
||||
thinkingProcessed.current = false
|
||||
|
||||
// Create messages
|
||||
// Save the original input (with prefix if any) in userMessage.content for display
|
||||
const userMessage: MessageWithError = {
|
||||
id: generateUniqueId(), // Use browser-compatible ID generation
|
||||
content: inputValue,
|
||||
role: 'user'
|
||||
}
|
||||
|
||||
const assistantMessage: MessageWithError = {
|
||||
id: generateUniqueId(), // Use browser-compatible ID generation
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
mermaidRendered: false,
|
||||
latexRendered: false, // Explicitly initialize to false
|
||||
thinkingTime: null, // Explicitly initialize to null
|
||||
thinkingContent: undefined, // Explicitly initialize to undefined
|
||||
displayContent: undefined, // Explicitly initialize to undefined
|
||||
isThinking: false // Explicitly initialize to false
|
||||
}
|
||||
|
||||
const prevMessages = [...messages]
|
||||
|
||||
// Create an abort controller so the user can terminate this query via the
|
||||
// Stop button. Track the active assistant message so handleStop can mark it.
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
activeAssistantIdRef.current = assistantMessage.id
|
||||
|
||||
// Add messages to chatbox
|
||||
setMessages([...prevMessages, userMessage, assistantMessage])
|
||||
|
||||
// Reset scroll following state for new query
|
||||
shouldFollowScrollRef.current = true
|
||||
// Set flag to indicate we're receiving a response
|
||||
isReceivingResponseRef.current = true
|
||||
|
||||
// Force scroll to bottom after messages are rendered
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 0)
|
||||
|
||||
// Clear input and set loading
|
||||
setInputValue('')
|
||||
setIsLoading(true)
|
||||
// Disable the Stop button for a short cooldown so a fast double-click on
|
||||
// Send doesn't immediately abort this query. Set synchronously (same batch
|
||||
// as setIsLoading) so the Stop button is already disabled on its first
|
||||
// paint, leaving no enabled gap for the second click to land in.
|
||||
setStopDisabled(true)
|
||||
if (stopCooldownTimerRef.current) clearTimeout(stopCooldownTimerRef.current)
|
||||
stopCooldownTimerRef.current = setTimeout(() => setStopDisabled(false), 500)
|
||||
|
||||
// Reset input height to minimum after clearing input
|
||||
if (inputRef.current) {
|
||||
if ('style' in inputRef.current) {
|
||||
inputRef.current.style.height = '40px'
|
||||
}
|
||||
}
|
||||
|
||||
// Create a function to update the assistant's message
|
||||
const updateAssistantMessage = (chunk: string, isError?: boolean) => {
|
||||
assistantMessage.content += chunk
|
||||
|
||||
// Start thinking timer on first sight of think tag
|
||||
if (assistantMessage.content.includes('<think>') && !thinkingStartTime.current) {
|
||||
thinkingStartTime.current = Date.now()
|
||||
}
|
||||
|
||||
// Use the new robust COT parsing function
|
||||
const cotResult = parseCOTContent(assistantMessage.content)
|
||||
|
||||
// Update thinking state
|
||||
assistantMessage.isThinking = cotResult.isThinking
|
||||
|
||||
// Only calculate time and extract thinking content once when thinking is complete
|
||||
if (cotResult.hasValidThinkBlock && !thinkingProcessed.current) {
|
||||
if (thinkingStartTime.current && !assistantMessage.thinkingTime) {
|
||||
const duration = (Date.now() - thinkingStartTime.current) / 1000
|
||||
assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))
|
||||
}
|
||||
thinkingProcessed.current = true
|
||||
}
|
||||
|
||||
// Update content based on parsing results
|
||||
assistantMessage.thinkingContent = cotResult.thinkingContent
|
||||
// Only fallback to full content if not in a thinking state.
|
||||
if (cotResult.isThinking) {
|
||||
assistantMessage.displayContent = ''
|
||||
} else {
|
||||
assistantMessage.displayContent = cotResult.displayContent || assistantMessage.content
|
||||
}
|
||||
|
||||
// Detect if the assistant message contains a complete mermaid code block
|
||||
// Simple heuristic: look for ```mermaid ... ```
|
||||
const mermaidBlockRegex = /```mermaid\s+([\s\S]+?)```/g
|
||||
let mermaidRendered = false
|
||||
let match
|
||||
while ((match = mermaidBlockRegex.exec(assistantMessage.content)) !== null) {
|
||||
// If the block is not too short, consider it complete
|
||||
if (match[1] && match[1].trim().length > 10) {
|
||||
mermaidRendered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assistantMessage.mermaidRendered = mermaidRendered
|
||||
|
||||
// Detect if the assistant message contains complete LaTeX formulas
|
||||
const latexRendered = detectLatexCompleteness(assistantMessage.content)
|
||||
assistantMessage.latexRendered = latexRendered
|
||||
|
||||
// Single unified update to avoid race conditions
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMessage = newMessages[newMessages.length - 1]
|
||||
if (lastMessage && lastMessage.id === assistantMessage.id) {
|
||||
// Update all properties at once to maintain consistency
|
||||
Object.assign(lastMessage, {
|
||||
content: assistantMessage.content,
|
||||
thinkingContent: assistantMessage.thinkingContent,
|
||||
displayContent: assistantMessage.displayContent,
|
||||
isThinking: assistantMessage.isThinking,
|
||||
isError: isError,
|
||||
mermaidRendered: assistantMessage.mermaidRendered,
|
||||
latexRendered: assistantMessage.latexRendered,
|
||||
thinkingTime: assistantMessage.thinkingTime
|
||||
})
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
|
||||
// After updating content, scroll to bottom if auto-scroll is enabled
|
||||
// Use a longer delay to ensure DOM has updated
|
||||
if (shouldFollowScrollRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 30)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare query parameters
|
||||
const state = useSettingsStore.getState()
|
||||
|
||||
// Add user prompt to history if it exists and is not empty
|
||||
if (state.querySettings.user_prompt && state.querySettings.user_prompt.trim()) {
|
||||
state.addUserPromptToHistory(state.querySettings.user_prompt.trim())
|
||||
}
|
||||
|
||||
// Determine the effective mode
|
||||
const effectiveMode = modeOverride || state.querySettings.mode
|
||||
|
||||
// Determine effective history turns with bypass override
|
||||
const configuredHistoryTurns = state.querySettings.history_turns || 0
|
||||
const effectiveHistoryTurns = (effectiveMode === 'bypass' && configuredHistoryTurns === 0)
|
||||
? 3
|
||||
: configuredHistoryTurns
|
||||
|
||||
const queryParams = {
|
||||
...state.querySettings,
|
||||
query: actualQuery,
|
||||
response_type: 'Multiple Paragraphs',
|
||||
conversation_history: effectiveHistoryTurns > 0
|
||||
? prevMessages
|
||||
.filter((m) => m.isError !== true)
|
||||
.slice(-effectiveHistoryTurns * 2)
|
||||
.map((m) => ({ role: m.role, content: m.content }))
|
||||
: [],
|
||||
...(modeOverride ? { mode: modeOverride } : {})
|
||||
}
|
||||
|
||||
try {
|
||||
// Run query
|
||||
if (state.querySettings.stream) {
|
||||
let errorMessage = ''
|
||||
await queryTextStream(queryParams, updateAssistantMessage, (error) => {
|
||||
errorMessage += error
|
||||
}, controller.signal)
|
||||
if (errorMessage) {
|
||||
if (assistantMessage.content) {
|
||||
errorMessage = assistantMessage.content + '\n' + errorMessage
|
||||
}
|
||||
updateAssistantMessage(errorMessage, true)
|
||||
}
|
||||
} else {
|
||||
const response = await queryText(queryParams, controller.signal)
|
||||
updateAssistantMessage(response.response)
|
||||
}
|
||||
} catch (err) {
|
||||
// If the user terminated the query, handleStop already finalized the
|
||||
// message state; don't render it as an error.
|
||||
if (!controller.signal.aborted) {
|
||||
updateAssistantMessage(`${t('retrievePanel.retrieval.error')}\n${errorMessage(err)}`, true)
|
||||
}
|
||||
} finally {
|
||||
// Only the owning, non-terminated query runs global cleanup. A
|
||||
// terminated query has its controller nulled by handleStop (which also
|
||||
// persists the terminated history), and a superseded query no longer
|
||||
// owns the ref — both skip here so we don't reset the shared thinking
|
||||
// refs / isLoading / history out from under a freshly started query
|
||||
// (which would break its COT animation) or undo a post-stop Clear.
|
||||
if (abortControllerRef.current === controller) {
|
||||
// Clear loading and add messages to state
|
||||
setIsLoading(false)
|
||||
isReceivingResponseRef.current = false
|
||||
abortControllerRef.current = null
|
||||
|
||||
// Enhanced cleanup with error handling to prevent memory leaks
|
||||
try {
|
||||
// Final COT state validation and cleanup
|
||||
const finalCotResult = parseCOTContent(assistantMessage.content)
|
||||
|
||||
// Force set final state - stream ended so thinking must be false
|
||||
assistantMessage.isThinking = false
|
||||
|
||||
// If we have a complete thinking block but time wasn't calculated, do final calculation
|
||||
if (finalCotResult.hasValidThinkBlock && thinkingStartTime.current && !assistantMessage.thinkingTime) {
|
||||
const duration = (Date.now() - thinkingStartTime.current) / 1000
|
||||
assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))
|
||||
}
|
||||
|
||||
// Ensure display content is correctly set based on final parsing
|
||||
if (finalCotResult.displayContent !== undefined) {
|
||||
assistantMessage.displayContent = finalCotResult.displayContent
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in final COT state validation:', error)
|
||||
// Force reset state on error
|
||||
assistantMessage.isThinking = false
|
||||
} finally {
|
||||
// Ensure cleanup happens regardless of errors
|
||||
thinkingStartTime.current = null
|
||||
}
|
||||
|
||||
// Save history with error handling
|
||||
try {
|
||||
useSettingsStore
|
||||
.getState()
|
||||
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
|
||||
} catch (error) {
|
||||
console.error('Error saving retrieval history:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[inputValue, isLoading, messages, setMessages, t, scrollToBottom]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
// Shift+Enter: Insert newline
|
||||
e.preventDefault()
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const start = target.selectionStart || 0
|
||||
const end = target.selectionEnd || 0
|
||||
const newValue = inputValue.slice(0, start) + '\n' + inputValue.slice(end)
|
||||
setInputValue(newValue)
|
||||
|
||||
// Set cursor position after the newline and adjust height if needed
|
||||
setTimeout(() => {
|
||||
if (target.setSelectionRange) {
|
||||
target.setSelectionRange(start + 1, start + 1)
|
||||
}
|
||||
|
||||
// Manually trigger height adjustment for textarea after component switch
|
||||
if (inputRef.current && inputRef.current.tagName === 'TEXTAREA') {
|
||||
adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)
|
||||
}
|
||||
}, 0)
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// Enter: Submit form
|
||||
e.preventDefault()
|
||||
handleSubmit(e as any)
|
||||
}
|
||||
}, [inputValue, handleSubmit, adjustTextareaHeight])
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// Get pasted text content
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
|
||||
// Check if it contains newlines
|
||||
if (pastedText.includes('\n')) {
|
||||
e.preventDefault() // Prevent default paste behavior
|
||||
|
||||
// Get current cursor position
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const start = target.selectionStart || 0
|
||||
const end = target.selectionEnd || 0
|
||||
|
||||
// Build new value
|
||||
const newValue = inputValue.slice(0, start) + pastedText + inputValue.slice(end)
|
||||
|
||||
// Update state (this will trigger component switch to Textarea)
|
||||
setInputValue(newValue)
|
||||
|
||||
// Set cursor position to end of pasted content
|
||||
setTimeout(() => {
|
||||
if (inputRef.current && inputRef.current.setSelectionRange) {
|
||||
const newCursorPosition = start + pastedText.length
|
||||
inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
// If no newlines, let default paste behavior continue
|
||||
}, [inputValue])
|
||||
|
||||
// Effect to handle component switching and maintain focus
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
// When component type changes, restore focus and cursor position
|
||||
const currentElement = inputRef.current
|
||||
const cursorPosition = currentElement.selectionStart || inputValue.length
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM update is complete
|
||||
requestAnimationFrame(() => {
|
||||
currentElement.focus()
|
||||
if (currentElement.setSelectionRange) {
|
||||
currentElement.setSelectionRange(cursorPosition, cursorPosition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [hasMultipleLines, inputValue.length]) // Include inputValue.length dependency
|
||||
|
||||
// Effect to adjust textarea height when switching to multi-line mode
|
||||
useEffect(() => {
|
||||
if (hasMultipleLines && inputRef.current && inputRef.current.tagName === 'TEXTAREA') {
|
||||
adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)
|
||||
}
|
||||
}, [hasMultipleLines, inputValue, adjustTextareaHeight])
|
||||
|
||||
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
|
||||
const shouldFollowScrollRef = useRef(true)
|
||||
const thinkingStartTime = useRef<number | null>(null)
|
||||
const thinkingProcessed = useRef(false)
|
||||
// Abort controller for the in-flight query (streaming or non-streaming)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
// Id of the assistant message currently receiving a response
|
||||
const activeAssistantIdRef = useRef<string | null>(null)
|
||||
// Reference to track if user interaction is from the form area
|
||||
const isFormInteractionRef = useRef(false)
|
||||
// Reference to track if scroll was triggered programmatically
|
||||
const programmaticScrollRef = useRef(false)
|
||||
// Reference to track if we're currently receiving a streaming response
|
||||
const isReceivingResponseRef = useRef(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Add cleanup effect for memory leak prevention
|
||||
useEffect(() => {
|
||||
// Component cleanup - reset timer state to prevent memory leaks
|
||||
return () => {
|
||||
if (thinkingStartTime.current) {
|
||||
thinkingStartTime.current = null;
|
||||
}
|
||||
if (stopCooldownTimerRef.current) {
|
||||
clearTimeout(stopCooldownTimerRef.current);
|
||||
stopCooldownTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add event listeners to detect when user manually interacts with the container
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Handle significant mouse wheel events - only disable auto-scroll for deliberate scrolling
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Only consider significant wheel movements (more than 10px)
|
||||
if (Math.abs(e.deltaY) > 10 && !isFormInteractionRef.current) {
|
||||
shouldFollowScrollRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle scroll events - only disable auto-scroll if not programmatically triggered
|
||||
// and if it's a significant scroll
|
||||
const handleScroll = throttle(() => {
|
||||
// If this is a programmatic scroll, don't disable auto-scroll
|
||||
if (programmaticScrollRef.current) {
|
||||
programmaticScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if scrolled to bottom or very close to bottom
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 20;
|
||||
|
||||
// If at bottom, enable auto-scroll, otherwise disable it
|
||||
if (isAtBottom) {
|
||||
shouldFollowScrollRef.current = true;
|
||||
} else if (!isFormInteractionRef.current && !isReceivingResponseRef.current) {
|
||||
shouldFollowScrollRef.current = false;
|
||||
}
|
||||
}
|
||||
}, 30);
|
||||
|
||||
// Add event listeners - only listen for wheel and scroll events
|
||||
container.addEventListener('wheel', handleWheel as EventListener);
|
||||
container.addEventListener('scroll', handleScroll as EventListener);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('wheel', handleWheel as EventListener);
|
||||
container.removeEventListener('scroll', handleScroll as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add event listeners to the form area to prevent disabling auto-scroll when interacting with form
|
||||
useEffect(() => {
|
||||
const form = document.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
const handleFormMouseDown = () => {
|
||||
// Set flag to indicate form interaction
|
||||
isFormInteractionRef.current = true;
|
||||
|
||||
// Reset the flag after a short delay
|
||||
setTimeout(() => {
|
||||
isFormInteractionRef.current = false;
|
||||
}, 500); // Give enough time for the form interaction to complete
|
||||
};
|
||||
|
||||
form.addEventListener('mousedown', handleFormMouseDown);
|
||||
|
||||
return () => {
|
||||
form.removeEventListener('mousedown', handleFormMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Use a longer debounce time for better performance with large message updates
|
||||
const debouncedMessages = useDebounce(messages, 150)
|
||||
useEffect(() => {
|
||||
// Only auto-scroll if enabled
|
||||
if (shouldFollowScrollRef.current) {
|
||||
// Force scroll to bottom when messages change
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [debouncedMessages, scrollToBottom])
|
||||
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([])
|
||||
useSettingsStore.getState().setRetrievalHistory([])
|
||||
}, [setMessages])
|
||||
|
||||
// Stop the in-flight query. Frees the UI immediately so the user can start a
|
||||
// new query without waiting for the aborted request to unwind. Marks the
|
||||
// active assistant message as terminated and stops its COT spinner.
|
||||
const handleStop = useCallback(() => {
|
||||
const controller = abortControllerRef.current
|
||||
if (!controller) return
|
||||
controller.abort()
|
||||
// Relinquish ownership so the aborted query's deferred `finally` skips its
|
||||
// cleanup. Otherwise it still sees itself as the active query and would
|
||||
// write the stale conversation back into history — undoing a Clear that the
|
||||
// user performs after stopping (the cleared chat would reappear on reload).
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
abortControllerRef.current = null
|
||||
|
||||
// Finalize the terminated assistant message (stop its COT spinner, mark it
|
||||
// aborted) and persist immediately so the terminated state is the
|
||||
// authoritative saved history.
|
||||
const activeId = activeAssistantIdRef.current
|
||||
const finalizedMessages = messages.map((m) => {
|
||||
if (m.id !== activeId) return m
|
||||
// Terminated mid-thinking: finalize the COT block so the partial reasoning
|
||||
// stays visible (as a "Thinking time Xs" block, in whatever expand state
|
||||
// the user left it) instead of vanishing once isThinking is cleared — the
|
||||
// thinking block only renders while isThinking || thinkingTime !== null.
|
||||
let thinkingTime = m.thinkingTime ?? null
|
||||
if (m.isThinking && thinkingTime === null && thinkingStartTime.current) {
|
||||
thinkingTime = parseFloat(((Date.now() - thinkingStartTime.current) / 1000).toFixed(2))
|
||||
}
|
||||
return { ...m, isThinking: false, isAborted: true, thinkingTime }
|
||||
})
|
||||
setMessages(finalizedMessages)
|
||||
try {
|
||||
useSettingsStore.getState().setRetrievalHistory(finalizedMessages)
|
||||
} catch (error) {
|
||||
console.error('Error saving retrieval history:', error)
|
||||
}
|
||||
|
||||
// The skipped `finally` won't reset these shared thinking refs.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
thinkingStartTime.current = null
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
thinkingProcessed.current = false
|
||||
|
||||
setIsLoading(false)
|
||||
// Cancel any pending Stop-button cooldown and reset it for the next query.
|
||||
if (stopCooldownTimerRef.current) {
|
||||
clearTimeout(stopCooldownTimerRef.current)
|
||||
stopCooldownTimerRef.current = null
|
||||
}
|
||||
setStopDisabled(false)
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
isReceivingResponseRef.current = false
|
||||
}, [messages, setMessages])
|
||||
|
||||
// Disable auto-scroll when the user clicks inside the messages container.
|
||||
// The ref mutation pattern is intentional and matches how it's mutated elsewhere
|
||||
// (wheel/scroll handlers in the effect above); the linter flags it here regardless.
|
||||
const handleMessagesContainerClick = useCallback(() => {
|
||||
if (shouldFollowScrollRef.current) {
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
shouldFollowScrollRef.current = false;
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle copying message content with robust clipboard support
|
||||
const handleCopyMessage = useCallback(async (message: MessageWithError) => {
|
||||
const contentToCopy = message.role === 'user'
|
||||
? (message.content || '')
|
||||
: (message.displayContent !== undefined ? message.displayContent : (message.content || ''));
|
||||
|
||||
if (!contentToCopy.trim()) {
|
||||
toast.error(t('retrievePanel.chatMessage.copyEmpty', 'No content to copy'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await copyToClipboard(contentToCopy);
|
||||
|
||||
if (result.success) {
|
||||
// Show success message with method used
|
||||
const methodMessages: Record<string, string> = {
|
||||
'clipboard-api': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'),
|
||||
'execCommand': t('retrievePanel.chatMessage.copySuccessLegacy', 'Content copied (legacy method)'),
|
||||
'manual-select': t('retrievePanel.chatMessage.copySuccessManual', 'Content copied (manual method)'),
|
||||
'fallback': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard')
|
||||
};
|
||||
|
||||
toast.success(methodMessages[result.method] || t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'));
|
||||
} else {
|
||||
// Show error with fallback instructions
|
||||
if (result.method === 'fallback') {
|
||||
toast.error(
|
||||
result.error || t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),
|
||||
{
|
||||
description: t('retrievePanel.chatMessage.copyManualInstruction', 'Please select and copy the text manually')
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'),
|
||||
{
|
||||
description: result.error
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Clipboard operation failed:', err);
|
||||
toast.error(
|
||||
t('retrievePanel.chatMessage.copyError', 'Copy operation failed'),
|
||||
{
|
||||
description: err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<div className="flex size-full gap-2 px-2 pb-12 overflow-hidden">
|
||||
<div className="flex grow flex-col gap-4">
|
||||
<div className="relative grow">
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="bg-primary-foreground/60 absolute inset-0 flex flex-col overflow-auto rounded-lg border p-2"
|
||||
onClick={handleMessagesContainerClick}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-lg">
|
||||
{t('retrievePanel.retrieval.startPrompt')}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => { // Remove unused idx
|
||||
// isComplete logic is now handled internally based on message.mermaidRendered
|
||||
return (
|
||||
<div
|
||||
key={message.id} // Use stable ID for key
|
||||
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} items-end gap-2`}
|
||||
>
|
||||
{message.role === 'user' && (
|
||||
<Button
|
||||
onClick={() => handleCopyMessage(message)}
|
||||
className="mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0"
|
||||
tooltip={t('retrievePanel.chatMessage.copyTooltip')}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<ChatMessage message={message} isTabActive={isRetrievalTabActive} />
|
||||
{message.role === 'assistant' && (
|
||||
<Button
|
||||
onClick={() => handleCopyMessage(message)}
|
||||
className="mb-2 size-6 rounded-md opacity-60 transition-opacity hover:opacity-100 shrink-0"
|
||||
tooltip={t('retrievePanel.chatMessage.copyTooltip')}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} className="pb-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
autoComplete="on"
|
||||
method="post"
|
||||
action="#"
|
||||
role="search"
|
||||
>
|
||||
{/* Hidden submit button to ensure form meets HTML standards */}
|
||||
<input type="submit" style={{ display: 'none' }} tabIndex={-1} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={clearMessages}
|
||||
disabled={isLoading}
|
||||
size="sm"
|
||||
>
|
||||
<EraserIcon />
|
||||
{t('retrievePanel.retrieval.clear')}
|
||||
</Button>
|
||||
<div className="flex-1 relative">
|
||||
<label htmlFor="query-input" className="sr-only">
|
||||
{t('retrievePanel.retrieval.placeholder')}
|
||||
</label>
|
||||
{hasMultipleLines ? (
|
||||
<Textarea
|
||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||
id="query-input"
|
||||
autoComplete="on"
|
||||
className="w-full min-h-[40px] max-h-[120px] overflow-y-auto"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={t('retrievePanel.retrieval.placeholder')}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
minHeight: '40px',
|
||||
maxHeight: '120px'
|
||||
}}
|
||||
onInput={(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
requestAnimationFrame(() => {
|
||||
target.style.height = 'auto'
|
||||
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
id="query-input"
|
||||
autoComplete="on"
|
||||
className="w-full"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={t('retrievePanel.retrieval.placeholder')}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
{/* Error message below input */}
|
||||
{inputError && (
|
||||
<div className="absolute left-0 top-full mt-1 text-xs text-red-500">{inputError}</div>
|
||||
)}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Button type="button" variant="destructive" onClick={handleStop} disabled={stopDisabled} size="sm">
|
||||
<SquareIcon />
|
||||
{t('retrievePanel.retrieval.stop')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" variant="default" size="sm">
|
||||
<SendIcon />
|
||||
{t('retrievePanel.retrieval.send')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<QuerySettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import { SiteInfo, webuiPrefix } from '@/lib/constants'
|
||||
import AppSettings from '@/components/AppSettings'
|
||||
import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useAuthStore } from '@/stores/state'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { navigationService } from '@/services/navigation'
|
||||
import { ZapIcon, LogOutIcon } from 'lucide-react'
|
||||
import GithubIcon from '@/components/icons/GithubIcon'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
|
||||
|
||||
interface NavigationTabProps {
|
||||
value: string
|
||||
currentTab: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
|
||||
return (
|
||||
<TabsTrigger
|
||||
value={value}
|
||||
className={cn(
|
||||
'cursor-pointer px-2 py-1 transition-all',
|
||||
currentTab === value ? '!bg-emerald-400 !text-zinc-50' : 'hover:bg-background/60'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</TabsTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsNavigation() {
|
||||
const currentTab = useSettingsStore.use.currentTab()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex h-8 self-center">
|
||||
<TabsList className="h-full gap-2">
|
||||
<NavigationTab value="documents" currentTab={currentTab}>
|
||||
{t('header.documents')}
|
||||
</NavigationTab>
|
||||
<NavigationTab value="knowledge-graph" currentTab={currentTab}>
|
||||
{t('header.knowledgeGraph')}
|
||||
</NavigationTab>
|
||||
<NavigationTab value="retrieval" currentTab={currentTab}>
|
||||
{t('header.retrieval')}
|
||||
</NavigationTab>
|
||||
<NavigationTab value="api" currentTab={currentTab}>
|
||||
{t('header.api')}
|
||||
</NavigationTab>
|
||||
</TabsList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { t } = useTranslation()
|
||||
const { isGuestMode, coreVersion, apiVersion, username, webuiTitle, webuiDescription } = useAuthStore()
|
||||
|
||||
const versionDisplay = (coreVersion && apiVersion)
|
||||
? `${coreVersion}/${apiVersion}`
|
||||
: null;
|
||||
|
||||
// Check if frontend needs rebuild (apiVersion ends with warning symbol)
|
||||
const hasWarning = apiVersion?.endsWith('⚠️');
|
||||
const versionTooltip = hasWarning
|
||||
? t('header.frontendNeedsRebuild')
|
||||
: versionDisplay ? `v${versionDisplay}` : '';
|
||||
|
||||
const handleLogout = () => {
|
||||
navigationService.navigateToLogin();
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
|
||||
<div className="min-w-[200px] w-auto flex items-center">
|
||||
<a href={webuiPrefix} className="flex items-center gap-2">
|
||||
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
|
||||
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
|
||||
</a>
|
||||
{webuiTitle && (
|
||||
<div className="flex items-center">
|
||||
<span className="mx-1 text-xs text-gray-500 dark:text-gray-400">|</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-medium text-sm cursor-default">
|
||||
{webuiTitle}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{webuiDescription && (
|
||||
<TooltipContent side="bottom">
|
||||
{webuiDescription}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 flex-1 items-center justify-center">
|
||||
<TabsNavigation />
|
||||
{isGuestMode && (
|
||||
<div className="ml-2 self-center px-2 py-1 text-xs bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 rounded-md">
|
||||
{t('login.guestMode', 'Guest Mode')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="w-[200px] flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{versionDisplay && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1 cursor-default">
|
||||
v{versionDisplay}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{versionTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}>
|
||||
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
|
||||
<GithubIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<AppSettings />
|
||||
{!isGuestMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
side="bottom"
|
||||
tooltip={`${t('header.logout')} (${username})`}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOutIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { getStatusRequestFilters, matchesStatusFilter } from '@/features/documentStatusFilters'
|
||||
|
||||
describe('documentStatusFilters', () => {
|
||||
test('builds exact single-status request filters for each tab', () => {
|
||||
expect(getStatusRequestFilters('completed')).toEqual({
|
||||
status_filter: 'processed',
|
||||
status_filters: null
|
||||
})
|
||||
expect(getStatusRequestFilters('parse')).toEqual({
|
||||
status_filter: 'parsing',
|
||||
status_filters: null
|
||||
})
|
||||
expect(getStatusRequestFilters('analyze')).toEqual({
|
||||
status_filter: 'analyzing',
|
||||
status_filters: null
|
||||
})
|
||||
expect(getStatusRequestFilters('process')).toEqual({
|
||||
status_filter: 'processing',
|
||||
status_filters: null
|
||||
})
|
||||
expect(getStatusRequestFilters('failed')).toEqual({
|
||||
status_filter: 'failed',
|
||||
status_filters: null
|
||||
})
|
||||
})
|
||||
|
||||
test('builds empty request filters for the all tab', () => {
|
||||
expect(getStatusRequestFilters('all')).toEqual({
|
||||
status_filter: null,
|
||||
status_filters: null
|
||||
})
|
||||
})
|
||||
|
||||
test('matches a single status per non-all tab', () => {
|
||||
expect(matchesStatusFilter('parsing', 'parse')).toBe(true)
|
||||
expect(matchesStatusFilter('analyzing', 'analyze')).toBe(true)
|
||||
expect(matchesStatusFilter('processing', 'process')).toBe(true)
|
||||
expect(matchesStatusFilter('analyzing', 'parse')).toBe(false)
|
||||
expect(matchesStatusFilter('preprocessed', 'analyze')).toBe(false)
|
||||
})
|
||||
|
||||
test('the all tab matches every status, including deprecated and hidden ones', () => {
|
||||
expect(matchesStatusFilter('pending', 'all')).toBe(true)
|
||||
expect(matchesStatusFilter('preprocessed', 'all')).toBe(true)
|
||||
expect(matchesStatusFilter('processed', 'all')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DocStatus, DocumentsRequest } from '@/api/lightrag'
|
||||
|
||||
export type StatusBucket = 'completed' | 'parse' | 'analyze' | 'process' | 'failed'
|
||||
export type StatusFilter = StatusBucket | 'all'
|
||||
|
||||
// Each filter bucket maps to exactly one DocStatus. `pending` and the deprecated
|
||||
// `preprocessed` intentionally have no dedicated bucket — they only surface under
|
||||
// the "all" tab.
|
||||
const BUCKET_TO_STATUS: Record<StatusBucket, DocStatus> = {
|
||||
completed: 'processed',
|
||||
parse: 'parsing',
|
||||
analyze: 'analyzing',
|
||||
process: 'processing',
|
||||
failed: 'failed'
|
||||
}
|
||||
|
||||
const STATUS_TO_BUCKET: Partial<Record<DocStatus, StatusBucket>> = {
|
||||
processed: 'completed',
|
||||
parsing: 'parse',
|
||||
analyzing: 'analyze',
|
||||
processing: 'process',
|
||||
failed: 'failed'
|
||||
}
|
||||
|
||||
export const getStatusBucket = (status: DocStatus): StatusBucket | null =>
|
||||
STATUS_TO_BUCKET[status] ?? null
|
||||
|
||||
export const getStatusRequestFilters = (
|
||||
statusFilter: StatusFilter
|
||||
): Pick<DocumentsRequest, 'status_filter' | 'status_filters'> => {
|
||||
if (statusFilter === 'all') {
|
||||
return {
|
||||
status_filter: null,
|
||||
status_filters: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status_filter: BUCKET_TO_STATUS[statusFilter],
|
||||
status_filters: null
|
||||
}
|
||||
}
|
||||
|
||||
export const matchesStatusFilter = (status: DocStatus, statusFilter: StatusFilter): boolean =>
|
||||
statusFilter === 'all' || BUCKET_TO_STATUS[statusFilter] === status
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
// Single source of truth for "is the UI effectively in dark mode right now",
|
||||
// resolving theme === 'system' against the OS preference. Reactive: re-renders
|
||||
// consumers both when the theme setting changes and (in system mode) when the
|
||||
// OS color scheme is toggled. This mirrors how ThemeProvider writes the
|
||||
// dark/light class, but reads matchMedia directly so it does not depend on the
|
||||
// class write having happened first.
|
||||
const darkMql = () => window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const subscribe = (onChange: () => void) => {
|
||||
const mql = darkMql()
|
||||
mql.addEventListener('change', onChange)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}
|
||||
|
||||
const getSnapshot = () => darkMql().matches
|
||||
|
||||
const useIsDarkMode = (): boolean => {
|
||||
const theme = useSettingsStore.use.theme()
|
||||
const systemDark = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
return theme === 'dark' ? true : theme === 'light' ? false : systemDark
|
||||
}
|
||||
|
||||
export default useIsDarkMode
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
import { Faker, en, faker as fak } from '@faker-js/faker'
|
||||
import Graph, { UndirectedGraph } from 'graphology'
|
||||
import erdosRenyi from 'graphology-generators/random/erdos-renyi'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import seedrandom from 'seedrandom'
|
||||
import { randomColor } from '@/lib/utils'
|
||||
import * as Constants from '@/lib/constants'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
export type NodeType = {
|
||||
x: number
|
||||
y: number
|
||||
label: string
|
||||
size: number
|
||||
color: string
|
||||
highlighted?: boolean
|
||||
}
|
||||
export type EdgeType = { label: string }
|
||||
|
||||
/**
|
||||
* The goal of this file is to seed random generators if the query params 'seed' is present.
|
||||
*/
|
||||
const useRandomGraph = () => {
|
||||
const [faker, setFaker] = useState<Faker>(fak)
|
||||
|
||||
// Seed global Math.random after commit to avoid polluting the RNG
|
||||
// during render (StrictMode double-invoke, Concurrent Mode aborts)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(document.location.search)
|
||||
const seed = params.get('seed')
|
||||
if (!seed) return
|
||||
|
||||
// Global side effect — intentionally in effect, not render
|
||||
seedrandom(seed, { global: true })
|
||||
const f = new Faker({ locale: en })
|
||||
f.seed(Math.random())
|
||||
|
||||
const timer = setTimeout(() => setFaker(f), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const randomGraph = useCallback(() => {
|
||||
useGraphStore.getState().reset()
|
||||
|
||||
// Create the graph
|
||||
const graph = erdosRenyi(UndirectedGraph, { order: 100, probability: 0.1 })
|
||||
graph.nodes().forEach((node: string) => {
|
||||
graph.mergeNodeAttributes(node, {
|
||||
label: faker.person.fullName(),
|
||||
size: faker.number.int({ min: Constants.minNodeSize, max: Constants.maxNodeSize }),
|
||||
color: randomColor(),
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
// for node-border
|
||||
borderColor: randomColor(),
|
||||
borderSize: faker.number.float({ min: 0, max: 1, multipleOf: 0.1 }),
|
||||
// for node-image
|
||||
pictoColor: randomColor(),
|
||||
image: faker.image.urlLoremFlickr()
|
||||
})
|
||||
})
|
||||
|
||||
// Add edge attributes
|
||||
graph.edges().forEach((edge: string) => {
|
||||
graph.mergeEdgeAttributes(edge, {
|
||||
label: faker.lorem.words(faker.number.int({ min: 1, max: 3 })),
|
||||
size: faker.number.float({ min: 1, max: 5 }),
|
||||
color: randomColor()
|
||||
})
|
||||
})
|
||||
|
||||
return graph as Graph<NodeType, EdgeType>
|
||||
}, [faker])
|
||||
|
||||
return { faker, randomColor, randomGraph }
|
||||
}
|
||||
|
||||
export default useRandomGraph
|
||||
@@ -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,64 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
import en from './locales/en.json'
|
||||
import zh from './locales/zh.json'
|
||||
import fr from './locales/fr.json'
|
||||
import ar from './locales/ar.json'
|
||||
import zh_TW from './locales/zh_TW.json'
|
||||
import ru from './locales/ru.json'
|
||||
import ja from './locales/ja.json'
|
||||
import de from './locales/de.json'
|
||||
import uk from './locales/uk.json'
|
||||
import ko from './locales/ko.json'
|
||||
import vi from './locales/vi.json'
|
||||
|
||||
const getStoredLanguage = () => {
|
||||
try {
|
||||
const settingsString = localStorage.getItem('settings-storage')
|
||||
if (settingsString) {
|
||||
const settings = JSON.parse(settingsString)
|
||||
return settings.state?.language || 'en'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get stored language:', e)
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh },
|
||||
fr: { translation: fr },
|
||||
ar: { translation: ar },
|
||||
zh_TW: { translation: zh_TW },
|
||||
ru: { translation: ru },
|
||||
ja: { translation: ja },
|
||||
de: { translation: de },
|
||||
uk: { translation: uk },
|
||||
ko: { translation: ko },
|
||||
vi: { translation: vi }
|
||||
},
|
||||
lng: getStoredLanguage(), // Use stored language settings
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
},
|
||||
// Configuration to handle missing translations
|
||||
returnEmptyString: false,
|
||||
returnNull: false,
|
||||
})
|
||||
|
||||
// Subscribe to language changes
|
||||
useSettingsStore.subscribe((state) => {
|
||||
const currentLanguage = state.language
|
||||
if (i18n.language !== currentLanguage) {
|
||||
i18n.changeLanguage(currentLanguage)
|
||||
}
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,228 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin 'tailwindcss-animate';
|
||||
@plugin 'tailwind-scrollbar';
|
||||
|
||||
@source '../index.html';
|
||||
@source './**/*.{ts,tsx}';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--chart-1: hsl(12 76% 61%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
--chart-5: hsl(27 87% 67%);
|
||||
--radius: 0.6rem;
|
||||
--sidebar-background: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(0 0% 80%);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: hsl(0 0% 95%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(0 0% 90%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: hsl(0 0% 0%);
|
||||
}
|
||||
}
|
||||
|
||||
/* KaTeX Math Formula Styles */
|
||||
.katex-display-wrapper {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.katex-display-wrapper .katex-display {
|
||||
margin: 0.5em 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.katex-inline-wrapper .katex {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Ensure KaTeX formulas inherit color properly */
|
||||
.katex .base {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Improve KaTeX display for different themes */
|
||||
.katex .mord,
|
||||
.katex .mop,
|
||||
.katex .mbin,
|
||||
.katex .mrel,
|
||||
.katex .mpunct,
|
||||
.katex .mopen,
|
||||
.katex .mclose,
|
||||
.katex .minner {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Fix KaTeX display overflow issues */
|
||||
.katex-display {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.katex-display > .katex {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mermaid v11 attaches helper elements directly to <body> outside React's tree:
|
||||
1) A transient <div id="dmermaid-…"> render scratchpad (~150px tall, lives
|
||||
~500ms during render) used for layout measurement.
|
||||
2) A persistent <div class="mermaidTooltip"> shared across renders.
|
||||
Both default to position:absolute with no top/left, so they land in body's
|
||||
normal flow below <main h-screen> and inflate documentElement.scrollHeight,
|
||||
producing a page-level scrollbar that flashes during render and persists
|
||||
afterwards across Tab switches. Force fixed so they don't contribute to
|
||||
page scroll dimensions. */
|
||||
.mermaidTooltip,
|
||||
body > [id^="dmermaid-"] {
|
||||
position: fixed !important;
|
||||
}
|
||||
|
||||
/* Improve KaTeX error display */
|
||||
.katex .katex-error {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.dark .katex .katex-error {
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
border-color: rgba(255, 0, 0, 0.4);
|
||||
color: #ef4444;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ButtonVariantType } from '@/components/ui/Button'
|
||||
import { normalizeApiPrefix, normalizeWebuiPrefix } from '@/lib/pathPrefix'
|
||||
import { getRuntimeApiPrefix, getRuntimeWebuiPrefix } from '@/lib/runtimeConfig'
|
||||
|
||||
export const backendBaseUrl = normalizeApiPrefix(getRuntimeApiPrefix())
|
||||
export const webuiPrefix = normalizeWebuiPrefix(getRuntimeWebuiPrefix())
|
||||
|
||||
export const controlButtonVariant: ButtonVariantType = 'ghost'
|
||||
|
||||
export const labelColorDarkTheme = '#FFFFFF'
|
||||
export const LabelColorHighlightedDarkTheme = '#000000'
|
||||
export const labelColorLightTheme = '#000'
|
||||
|
||||
export const nodeColorDisabled = '#E2E2E2'
|
||||
export const nodeBorderColor = '#EEEEEE'
|
||||
export const nodeBorderColorSelected = '#F57F17'
|
||||
|
||||
export const edgeColorDarkTheme = '#888888'
|
||||
export const edgeColorSelected = '#F57F17'
|
||||
export const edgeColorHighlightedDarkTheme = '#F57F17'
|
||||
export const edgeColorHighlightedLightTheme = '#F57F17'
|
||||
|
||||
export const searchResultLimit = 50
|
||||
export const labelListLimit = 100
|
||||
|
||||
// Search History Configuration
|
||||
export const searchHistoryMaxItems = 500
|
||||
export const searchHistoryVersion = '1.0'
|
||||
|
||||
// API Request Limits
|
||||
export const popularLabelsDefaultLimit = 300
|
||||
export const searchLabelsDefaultLimit = 50
|
||||
|
||||
// UI Display Limits
|
||||
export const dropdownDisplayLimit = 300
|
||||
|
||||
export const minNodeSize = 4
|
||||
export const maxNodeSize = 20
|
||||
|
||||
export const healthCheckInterval = 15 // seconds
|
||||
|
||||
export const defaultQueryLabel = '*'
|
||||
|
||||
// reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types/Common_types
|
||||
export const supportedFileTypes = {
|
||||
'text/plain': [
|
||||
'.txt',
|
||||
'.md',
|
||||
'.textpack', // # Markdown Bundle(zip)
|
||||
'.mdx', // # MDX (Markdown + JSX)
|
||||
'.rtf', // # Rich Text Format
|
||||
'.odt', // # OpenDocument Text
|
||||
'.tex', // # LaTeX
|
||||
'.epub', // # Electronic Publication
|
||||
'.html', // # HyperText Markup Language
|
||||
'.htm', // # HyperText Markup Language
|
||||
'.csv', // # Comma-Separated Values
|
||||
'.json', // # JavaScript Object Notation
|
||||
'.xml', // # eXtensible Markup Language
|
||||
'.yaml', // # YAML Ain't Markup Language
|
||||
'.yml', // # YAML
|
||||
'.log', // # Log files
|
||||
'.conf', // # Configuration files
|
||||
'.ini', // # Initialization files
|
||||
'.properties', // # Java properties files
|
||||
'.sql', // # SQL scripts
|
||||
'.bat', // # Batch files
|
||||
'.sh', // # Shell scripts
|
||||
'.c', // # C source code
|
||||
'.h', // # C header
|
||||
'.cpp', // # C++ source code
|
||||
'.hpp', // # C++ header
|
||||
'.py', // # Python source code
|
||||
'.java', // # Java source code
|
||||
'.js', // # JavaScript source code
|
||||
'.ts', // # TypeScript source code
|
||||
'.swift', // # Swift source code
|
||||
'.go', // # Go source code
|
||||
'.rb', // # Ruby source code
|
||||
'.php', // # PHP source code
|
||||
'.css', // # Cascading Style Sheets
|
||||
'.scss', // # Sassy CSS
|
||||
'.less'
|
||||
],
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx']
|
||||
}
|
||||
|
||||
export const SiteInfo = {
|
||||
name: 'LightRAG',
|
||||
home: '/',
|
||||
github: 'https://github.com/HKUDS/LightRAG'
|
||||
}
|
||||
|
||||
// --- Graph layout performance thresholds ------------------------------------
|
||||
// Shared by the initial FA2 layout (GraphControl) and the manual worker
|
||||
// layouts (LayoutsControl) so the two cannot drift.
|
||||
|
||||
// Above this node count, node labels are forced off regardless of the
|
||||
// showNodeLabel setting (the hovered node's label is still drawn by sigma's
|
||||
// hover layer). Rendering thousands of labels is a major large-graph slowdown.
|
||||
export const LABEL_RENDER_LIMIT = 2000
|
||||
|
||||
// Above this node count, layout switches assign positions directly instead of
|
||||
// animating: animateNodes interpolates every node per frame on the main thread.
|
||||
export const ANIMATE_NODE_LIMIT = 5000
|
||||
|
||||
// Edge-count threshold that switches the graph between "small-graph experience"
|
||||
// and "large-graph performance". At or below it edges render as curves and edge
|
||||
// events (hover/click picking) follow the user setting; above it edges render
|
||||
// straight and edge events are fully disabled (no picking buffer allocated).
|
||||
// Shared by GraphControl (defaultEdgeType), GraphViewer (enableEdgeEvents
|
||||
// gating) and Settings (greying the Edge Events menu item) so they cannot drift.
|
||||
export const EDGE_PERF_LIMIT = 5000
|
||||
|
||||
// Time budget (ms) a relaxing worker layout runs before it is stopped. Scales
|
||||
// with graph size, capped so huge graphs don't run unbounded.
|
||||
export const workerBudgetMs = (order: number): number => Math.min(1500 + order / 10, 10000)
|
||||
|
||||
// One-time system-suggested user prompts, injected once into userPromptHistory
|
||||
// (for both fresh installs and upgrades). See settings store version 20 migration.
|
||||
export const suggestedUserPrompts: string[] = [
|
||||
'Ignore the `References Section Format` instruction in the system prompt, and do not include a `References` section in the response.',
|
||||
'For inline citations, use the footnote marker syntax `[^1]`, where the `^` preceding the identifier indicates a footnote reference. When multiple citations are required at a single location, each ID should be enclosed in separate footnote markers (e.g., `[^1][^2][^3]`).'
|
||||
]
|
||||
@@ -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 @@
|
||||
/// <reference types="bun" />
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { normalizeApiPrefix, normalizeWebuiPrefix } from './pathPrefix'
|
||||
|
||||
describe('normalizeApiPrefix', () => {
|
||||
test('empty / undefined / null collapse to ""', () => {
|
||||
expect(normalizeApiPrefix(undefined)).toBe('')
|
||||
expect(normalizeApiPrefix(null)).toBe('')
|
||||
expect(normalizeApiPrefix('')).toBe('')
|
||||
expect(normalizeApiPrefix(' ')).toBe('')
|
||||
})
|
||||
|
||||
test('"/" collapses to "" — avoids `${"/"} + "/x"` producing protocol-relative `//x`', () => {
|
||||
expect(normalizeApiPrefix('/')).toBe('')
|
||||
})
|
||||
|
||||
test('strips trailing slashes (one or many)', () => {
|
||||
expect(normalizeApiPrefix('/api/v1/')).toBe('/api/v1')
|
||||
expect(normalizeApiPrefix('/api/v1//')).toBe('/api/v1')
|
||||
})
|
||||
|
||||
test('adds leading slash if missing', () => {
|
||||
expect(normalizeApiPrefix('api/v1')).toBe('/api/v1')
|
||||
})
|
||||
|
||||
test('passes canonical form through unchanged', () => {
|
||||
expect(normalizeApiPrefix('/api/v1')).toBe('/api/v1')
|
||||
})
|
||||
|
||||
test('result is safe for fetch template concat: never starts with `//` and never ends with `/`', () => {
|
||||
for (const input of ['', '/', undefined, '/api', '/api/', 'api', '/api/v1/']) {
|
||||
const out = normalizeApiPrefix(input)
|
||||
const fetchUrl = `${out}/query/stream`
|
||||
expect(fetchUrl.startsWith('//')).toBe(false)
|
||||
expect(fetchUrl).not.toContain('//')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeWebuiPrefix', () => {
|
||||
test('empty / undefined / null fall back to default with trailing slash', () => {
|
||||
expect(normalizeWebuiPrefix(undefined)).toBe('/webui/')
|
||||
expect(normalizeWebuiPrefix(null)).toBe('/webui/')
|
||||
expect(normalizeWebuiPrefix('')).toBe('/webui/')
|
||||
expect(normalizeWebuiPrefix(' ')).toBe('/webui/')
|
||||
})
|
||||
|
||||
test('"/" falls back to default — degenerate value rejected', () => {
|
||||
expect(normalizeWebuiPrefix('/')).toBe('/webui/')
|
||||
})
|
||||
|
||||
test('always ends with exactly one trailing slash (Vite `base` requirement)', () => {
|
||||
expect(normalizeWebuiPrefix('/admin/ui')).toBe('/admin/ui/')
|
||||
expect(normalizeWebuiPrefix('/admin/ui/')).toBe('/admin/ui/')
|
||||
expect(normalizeWebuiPrefix('/admin/ui//')).toBe('/admin/ui/')
|
||||
})
|
||||
|
||||
test('adds leading slash if missing', () => {
|
||||
expect(normalizeWebuiPrefix('admin/ui')).toBe('/admin/ui/')
|
||||
})
|
||||
|
||||
test('respects custom fallback', () => {
|
||||
expect(normalizeWebuiPrefix(undefined, '/custom')).toBe('/custom/')
|
||||
expect(normalizeWebuiPrefix('/', '/custom')).toBe('/custom/')
|
||||
expect(normalizeWebuiPrefix('', '/custom/')).toBe('/custom/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Path prefix normalization utilities.
|
||||
*
|
||||
* Used by both the React source (where Vite statically replaces
|
||||
* `import.meta.env.VITE_*`) and `vite.config.ts` (where the config is loaded
|
||||
* by Node and only `loadEnv()` is reliable for reading user env vars).
|
||||
*
|
||||
* Keep this module dependency-free so it can be imported from `vite.config.ts`
|
||||
* without dragging in `@/...` path aliases or React/UI types.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize an API path prefix used as `axios.baseURL` and as a string-concat
|
||||
* prefix in `fetch(`${prefix}/path`)` and iframe `src`.
|
||||
*
|
||||
* - Empty / undefined / "/" → "" (avoids producing `//path` which fetch
|
||||
* interprets as a protocol-relative URL pointing at host "path").
|
||||
* - Always returns either "" or a string with a leading "/" and no trailing "/".
|
||||
*/
|
||||
export function normalizeApiPrefix(value: string | undefined | null): string {
|
||||
if (!value) return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || trimmed === '/') return ''
|
||||
const withLeading = trimmed.startsWith('/') ? trimmed : '/' + trimmed
|
||||
return withLeading.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a WebUI mount path used as Vite's `base` option and as `<a href>`.
|
||||
*
|
||||
* Vite requires `base` to end with "/", and serving via `<a href={prefix}>`
|
||||
* also avoids a 307 redirect when the value already ends with "/".
|
||||
*
|
||||
* - Empty / undefined / "/" → fallback (default `/webui`) with trailing "/".
|
||||
* - Always returns a value with a leading "/" and exactly one trailing "/".
|
||||
*/
|
||||
export function normalizeWebuiPrefix(
|
||||
value: string | undefined | null,
|
||||
fallback = '/webui'
|
||||
): string {
|
||||
const trimmed = (value ?? '').trim()
|
||||
const candidate =
|
||||
!trimmed || trimmed === '/'
|
||||
? fallback
|
||||
: trimmed.startsWith('/')
|
||||
? trimmed
|
||||
: '/' + trimmed
|
||||
return candidate.replace(/\/+$/, '') + '/'
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Runtime path prefix configuration.
|
||||
*
|
||||
* The browser-visible URL prefixes (API base, WebUI mount path) used to be
|
||||
* baked into the bundle from `import.meta.env.VITE_*_PREFIX` at build time,
|
||||
* forcing one build per reverse-proxy mount point. They are now resolved at
|
||||
* REQUEST time: the FastAPI server replaces a `<!-- __LIGHTRAG_RUNTIME_CONFIG__ -->`
|
||||
* comment in `index.html` with a `<script>window.__LIGHTRAG_CONFIG__ = ...</script>`
|
||||
* snippet built from `LIGHTRAG_API_PREFIX` / `LIGHTRAG_WEBUI_PATH`. This module
|
||||
* is the single read point for that injected value.
|
||||
*
|
||||
* Dev parity: `vite.config.ts` performs the same injection via a
|
||||
* `transformIndexHtml` plugin using `VITE_DEV_API_PREFIX` /
|
||||
* `VITE_DEV_WEBUI_PREFIX`, so dev and prod use the same lookup.
|
||||
*
|
||||
* Always read through {@link normalizeApiPrefix} / {@link normalizeWebuiPrefix}
|
||||
* downstream (see `constants.ts`); this module returns the raw injected value.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__LIGHTRAG_CONFIG__?: {
|
||||
apiPrefix?: string
|
||||
webuiPrefix?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config =
|
||||
(typeof window !== 'undefined' && window.__LIGHTRAG_CONFIG__) || {}
|
||||
|
||||
/** Browser-visible API prefix; empty string means same-origin / no prefix. */
|
||||
export function getRuntimeApiPrefix(): string | undefined {
|
||||
return config.apiPrefix
|
||||
}
|
||||
|
||||
/** Browser-visible WebUI mount path including the trailing slash. */
|
||||
export function getRuntimeWebuiPrefix(): string | undefined {
|
||||
return config.webuiPrefix
|
||||
}
|
||||
@@ -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,493 @@
|
||||
{
|
||||
"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، TEXTPACK، MDX، DOCX، PDF، PPTX، XLSX، RTF، ODT، EPUB، HTML، HTM، TEX، JSON، XML، YAML، YML، CSV، LOG، CONF، INI، PROPERTIES، SQL، BAT، SH، C، H، CPP، HPP، 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": "اختيار"
|
||||
},
|
||||
"filters": {
|
||||
"all": "الكل",
|
||||
"completed": "مكتمل",
|
||||
"parse": "استخراج",
|
||||
"analyze": "تحليل",
|
||||
"process": "معالجة",
|
||||
"failed": "فشل"
|
||||
},
|
||||
"status": {
|
||||
"all": "الكل",
|
||||
"completed": "مكتمل",
|
||||
"preprocessed": "مُعالج مسبقًا",
|
||||
"parsing": "جارٍ التحليل الأولي",
|
||||
"analyzing": "جارٍ التحليل",
|
||||
"processing": "قيد المعالجة",
|
||||
"pending": "معلق",
|
||||
"failed": "فشل"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "فشل تحميل المستندات\n{{error}}",
|
||||
"scanFailed": "فشل مسح المستندات\n{{error}}",
|
||||
"scanProgressFailed": "فشل الحصول على تقدم المسح\n{{error}}"
|
||||
},
|
||||
"fileNameLabel": "اسم الملف",
|
||||
"showButton": "عرض",
|
||||
"hideButton": "إخفاء",
|
||||
"showFileNameTooltip": "عرض اسم الملف",
|
||||
"hideFileNameTooltip": "إخفاء اسم الملف",
|
||||
"details": {
|
||||
"title": "تفاصيل الحالة",
|
||||
"openTooltip": "عرض تفاصيل الحالة",
|
||||
"content": "التفاصيل",
|
||||
"copyButton": "نسخ",
|
||||
"copyTooltip": "نسخ تفاصيل الحالة",
|
||||
"copySuccess": "تم نسخ تفاصيل الحالة",
|
||||
"copyFailed": "فشل نسخ تفاصيل الحالة"
|
||||
}
|
||||
},
|
||||
"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": "تم اقتصار بيانات الرسم البياني على الحد الأقصى للعقد",
|
||||
"fetchRetriesExhausted": "فشل تحميل بيانات الرسم البياني. استخدم التحديث للمحاولة مرة أخرى.",
|
||||
"graphBuildFailed": "فشل عرض بيانات الرسم البياني. استخدم التحديث لإعادة المحاولة.",
|
||||
"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": "طعام",
|
||||
"table": "جدول",
|
||||
"drawing": "رسم",
|
||||
"equation": "معادلة",
|
||||
"other": "أخرى"
|
||||
},
|
||||
"sideBar": {
|
||||
"settings": {
|
||||
"settings": "الإعدادات",
|
||||
"healthCheck": "فحص الحالة",
|
||||
"showPropertyPanel": "إظهار لوحة الخصائص",
|
||||
"showSearchBar": "إظهار شريط البحث",
|
||||
"showNodeLabel": "إظهار تسمية العقدة",
|
||||
"nodeDraggable": "العقدة قابلة للسحب",
|
||||
"showEdgeLabel": "إظهار تسمية الحافة",
|
||||
"hideUnselectedEdges": "إخفاء الحواف غير المحددة",
|
||||
"edgeEvents": "أحداث الحافة",
|
||||
"edgeEventsDisabledHint": "معطّل للرسوم البيانية التي تحتوي على أكثر من {{count}} حافة",
|
||||
"maxQueryDepth": "أقصى عمق للاستعلام",
|
||||
"maxNodes": "الحد الأقصى للعقد",
|
||||
"resetToDefault": "إعادة التعيين إلى الافتراضي",
|
||||
"edgeSizeRange": "نطاق حجم الحافة",
|
||||
"depth": "D",
|
||||
"node": "عقدة",
|
||||
"edge": "حافة",
|
||||
"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": "معلومات الخادم",
|
||||
"inputDirectory": "دليل الإدخال",
|
||||
"parser": "المحلل",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "إعدادات أخرى",
|
||||
"llmConfig": "تكوين النموذج",
|
||||
"llmBinding": "ربط نموذج اللغة الكبير",
|
||||
"llmBindingHost": "نقطة نهاية نموذج اللغة الكبير",
|
||||
"llmModel": "نموذج اللغة الكبير",
|
||||
"embeddingConfig": "تكوين التضمين",
|
||||
"embeddingBinding": "ربط التضمين",
|
||||
"embeddingBindingHost": "نقطة نهاية التضمين",
|
||||
"embeddingModel": "نموذج التضمين",
|
||||
"storageConfig": "تكوين التخزين",
|
||||
"kvStorage": "تخزين المفتاح-القيمة",
|
||||
"docStatusStorage": "تخزين حالة المستند",
|
||||
"graphStorage": "تخزين الرسم البياني",
|
||||
"vectorStorage": "تخزين المتجهات",
|
||||
"workspace": "مساحة العمل",
|
||||
"rerankerConfig": "تكوين إعادة الترتيب",
|
||||
"rerankerBindingHost": "نقطة نهاية إعادة الترتيب",
|
||||
"rerankerModel": "نموذج إعادة الترتيب",
|
||||
"lockStatus": "حالة القفل"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "تعديل {{property}}",
|
||||
"editLockedByPipeline": "خط الأنابيب مشغول؛ تم تعطيل التحرير",
|
||||
"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": "إرسال",
|
||||
"stop": "إيقاف",
|
||||
"userTerminated": "أوقف المستخدم العملية — قد تكون الإجابة غير مكتملة",
|
||||
"placeholder": "اكتب استفسارك (بادئة وضع الاستعلام: /<Query Mode>)",
|
||||
"error": "خطأ: فشل الحصول على الرد",
|
||||
"queryModeError": "يُسمح فقط بأنماط الاستعلام التالية: {{modes}}",
|
||||
"queryModePrefixInvalid": "بادئة وضع الاستعلام غير صالحة. استخدم: /<الوضع> [مسافة] استفسارك"
|
||||
},
|
||||
"querySettings": {
|
||||
"parametersTitle": "المعلمات",
|
||||
"parametersDescription": "تكوين معلمات الاستعلام الخاص بك",
|
||||
"queryMode": "وضع الاستعلام",
|
||||
"queryModeTooltip": "حدد استراتيجية الاسترجاع:\n• ساذج: استرجاع متجهي تقليدي لقطع النص\n• محلي: يركز على استرجاع الكيانات\n• عالمي: يركز على استرجاع العلاقات\n• مختلط: محلي+عالمي\n• مزيج: محلي+عالمي+ساذج\n• تجاوز: تخطي الاسترجاع، إرسال تاريخ المحادثة والسؤال الحالي إلى LLM",
|
||||
"queryModeWarning": "قد يقلل من جودة الاسترجاع",
|
||||
"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,493 @@
|
||||
{
|
||||
"settings": {
|
||||
"language": "Sprache",
|
||||
"theme": "Design",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"system": "System"
|
||||
},
|
||||
"header": {
|
||||
"documents": "Dokumente",
|
||||
"knowledgeGraph": "Wissensgraph",
|
||||
"retrieval": "Abruf",
|
||||
"api": "API",
|
||||
"projectRepository": "Projekt-Repository",
|
||||
"logout": "Abmelden",
|
||||
"frontendNeedsRebuild": "Frontend muss neu erstellt werden",
|
||||
"themeToggle": {
|
||||
"switchToLight": "Zu hellem Design wechseln",
|
||||
"switchToDark": "Zu dunklem Design wechseln"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"description": "Bitte geben Sie Ihr Konto und Passwort ein, um sich im System anzumelden",
|
||||
"username": "Benutzername",
|
||||
"usernamePlaceholder": "Bitte geben Sie einen Benutzernamen ein",
|
||||
"password": "Passwort",
|
||||
"passwordPlaceholder": "Bitte geben Sie ein Passwort ein",
|
||||
"loginButton": "Anmelden",
|
||||
"loggingIn": "Anmeldung läuft...",
|
||||
"successMessage": "Anmeldung erfolgreich",
|
||||
"errorEmptyFields": "Bitte geben Sie Ihren Benutzernamen und Ihr Passwort ein",
|
||||
"errorInvalidCredentials": "Anmeldung fehlgeschlagen, bitte überprüfen Sie Benutzername und Passwort",
|
||||
"authDisabled": "Authentifizierung ist deaktiviert. Verwenden des anmeldefreien Modus.",
|
||||
"guestMode": "Anmeldefrei"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern...",
|
||||
"saveFailed": "Speichern fehlgeschlagen"
|
||||
},
|
||||
"documentPanel": {
|
||||
"clearDocuments": {
|
||||
"button": "Löschen",
|
||||
"tooltip": "Dokumente löschen",
|
||||
"title": "Dokumente löschen",
|
||||
"description": "Dies entfernt alle Dokumente aus dem System",
|
||||
"warning": "WARNUNG: Diese Aktion löscht alle Dokumente dauerhaft und kann nicht rückgängig gemacht werden!",
|
||||
"confirm": "Möchten Sie wirklich alle Dokumente löschen?",
|
||||
"confirmPrompt": "Geben Sie 'yes' ein, um diese Aktion zu bestätigen",
|
||||
"confirmPlaceholder": "Geben Sie yes ein, um zu bestätigen",
|
||||
"clearCache": "LLM-Cache löschen",
|
||||
"confirmButton": "JA",
|
||||
"clearing": "Löschen...",
|
||||
"timeout": "Löschvorgang hat das Zeitlimit überschritten, bitte versuchen Sie es erneut",
|
||||
"success": "Dokumente erfolgreich gelöscht",
|
||||
"cacheCleared": "Cache erfolgreich gelöscht",
|
||||
"cacheClearFailed": "Cache konnte nicht gelöscht werden:\n{{error}}",
|
||||
"failed": "Dokumente löschen fehlgeschlagen:\n{{message}}",
|
||||
"error": "Dokumente löschen fehlgeschlagen:\n{{error}}"
|
||||
},
|
||||
"deleteDocuments": {
|
||||
"button": "Löschen",
|
||||
"tooltip": "Ausgewählte Dokumente löschen",
|
||||
"title": "Dokumente löschen",
|
||||
"description": "Dies löscht die ausgewählten Dokumente dauerhaft aus dem System",
|
||||
"warning": "WARNUNG: Diese Aktion löscht die ausgewählten Dokumente dauerhaft und kann nicht rückgängig gemacht werden!",
|
||||
"confirm": "Möchten Sie wirklich {{count}} ausgewählte(s) Dokument(e) löschen?",
|
||||
"confirmPrompt": "Geben Sie 'yes' ein, um diese Aktion zu bestätigen",
|
||||
"confirmPlaceholder": "Geben Sie yes ein, um zu bestätigen",
|
||||
"confirmButton": "JA",
|
||||
"deleteFileOption": "Auch hochgeladene Dateien löschen",
|
||||
"deleteFileTooltip": "Aktivieren Sie diese Option, um auch die entsprechenden hochgeladenen Dateien auf dem Server zu löschen",
|
||||
"deleteLLMCacheOption": "Auch extrahierten LLM-Cache löschen",
|
||||
"success": "Dokumentlösch-Pipeline erfolgreich gestartet",
|
||||
"failed": "Dokumente löschen fehlgeschlagen:\n{{message}}",
|
||||
"error": "Dokumente löschen fehlgeschlagen:\n{{error}}",
|
||||
"busy": "Pipeline ist beschäftigt, bitte versuchen Sie es später erneut",
|
||||
"notAllowed": "Keine Berechtigung, diese Operation auszuführen"
|
||||
},
|
||||
"selectDocuments": {
|
||||
"selectCurrentPage": "Aktuelle Seite auswählen ({{count}})",
|
||||
"deselectAll": "Alle Auswahl aufheben ({{count}})"
|
||||
},
|
||||
"uploadDocuments": {
|
||||
"button": "Hochladen",
|
||||
"tooltip": "Dokumente hochladen",
|
||||
"title": "Dokumente hochladen",
|
||||
"description": "Ziehen Sie Ihre Dokumente hierher oder klicken Sie zum Durchsuchen.",
|
||||
"single": {
|
||||
"uploading": "Hochladen {{name}}: {{percent}}%",
|
||||
"success": "Upload erfolgreich:\n{{name}} erfolgreich hochgeladen",
|
||||
"failed": "Upload fehlgeschlagen:\n{{name}}\n{{message}}",
|
||||
"error": "Upload fehlgeschlagen:\n{{name}}\n{{error}}"
|
||||
},
|
||||
"batch": {
|
||||
"uploading": "Dateien werden hochgeladen...",
|
||||
"success": "Dateien erfolgreich hochgeladen",
|
||||
"error": "Einige Dateien konnten nicht hochgeladen werden"
|
||||
},
|
||||
"generalError": "Upload fehlgeschlagen\n{{error}}",
|
||||
"fileTypes": "Unterstützte Typen: TXT, MD, TEXTPACK, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
|
||||
"fileUploader": {
|
||||
"singleFileLimit": "Es kann nicht mehr als 1 Datei gleichzeitig hochgeladen werden",
|
||||
"maxFilesLimit": "Es können nicht mehr als {{count}} Dateien hochgeladen werden",
|
||||
"fileRejected": "Datei {{name}} wurde abgelehnt",
|
||||
"unsupportedType": "Nicht unterstützter Dateityp",
|
||||
"fileTooLarge": "Datei zu groß, maximale Größe ist {{maxSize}}",
|
||||
"dropHere": "Dateien hier ablegen",
|
||||
"dragAndDrop": "Dateien hierher ziehen oder klicken, um Dateien auszuwählen",
|
||||
"removeFile": "Datei entfernen",
|
||||
"uploadDescription": "Sie können {{isMultiple ? 'mehrere' : count}} Dateien hochladen (bis zu {{maxSize}} pro Datei)",
|
||||
"duplicateFile": "Dateiname existiert bereits im Server-Cache"
|
||||
}
|
||||
},
|
||||
"documentManager": {
|
||||
"title": "Dokumentenverwaltung",
|
||||
"scanButton": "Scannen/Wiederholen",
|
||||
"scanTooltip": "Dokumente im Eingabeordner scannen und verarbeiten sowie alle fehlgeschlagenen Dokumente erneut verarbeiten",
|
||||
"refreshTooltip": "Dokumentenliste zurücksetzen",
|
||||
"pipelineStatusButton": "Pipeline",
|
||||
"pipelineStatusTooltip": "Status der Dokumentverarbeitungs-Pipeline anzeigen",
|
||||
"uploadedTitle": "Hochgeladene Dokumente",
|
||||
"uploadedDescription": "Liste der hochgeladenen Dokumente und ihrer Status.",
|
||||
"emptyTitle": "Keine Dokumente",
|
||||
"emptyDescription": "Es wurden noch keine Dokumente hochgeladen.",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"fileName": "Dateiname",
|
||||
"summary": "Zusammenfassung",
|
||||
"status": "Status",
|
||||
"length": "Länge",
|
||||
"chunks": "Chunks",
|
||||
"created": "Erstellt",
|
||||
"updated": "Aktualisiert",
|
||||
"metadata": "Metadaten",
|
||||
"select": "Auswählen"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Alle",
|
||||
"completed": "Abgeschlossen",
|
||||
"parse": "Extraktion",
|
||||
"analyze": "Analyse",
|
||||
"process": "Verarbeitung",
|
||||
"failed": "Fehlgeschlagen"
|
||||
},
|
||||
"status": {
|
||||
"all": "Alle",
|
||||
"completed": "Abgeschlossen",
|
||||
"preprocessed": "Vorverarbeitet",
|
||||
"parsing": "Wird geparst",
|
||||
"analyzing": "Wird analysiert",
|
||||
"processing": "Verarbeitung",
|
||||
"pending": "Ausstehend",
|
||||
"failed": "Fehlgeschlagen"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Dokumente konnten nicht geladen werden\n{{error}}",
|
||||
"scanFailed": "Dokumente konnten nicht gescannt werden\n{{error}}",
|
||||
"scanProgressFailed": "Scan-Fortschritt konnte nicht abgerufen werden\n{{error}}"
|
||||
},
|
||||
"fileNameLabel": "Dateiname",
|
||||
"showButton": "Anzeigen",
|
||||
"hideButton": "Ausblenden",
|
||||
"showFileNameTooltip": "Dateiname anzeigen",
|
||||
"hideFileNameTooltip": "Dateiname ausblenden",
|
||||
"details": {
|
||||
"title": "Statusdetails",
|
||||
"openTooltip": "Statusdetails anzeigen",
|
||||
"content": "Details",
|
||||
"copyButton": "Kopieren",
|
||||
"copyTooltip": "Statusdetails kopieren",
|
||||
"copySuccess": "Statusdetails kopiert",
|
||||
"copyFailed": "Statusdetails konnten nicht kopiert werden"
|
||||
}
|
||||
},
|
||||
"pipelineStatus": {
|
||||
"title": "Pipeline-Status",
|
||||
"busy": "Pipeline beschäftigt",
|
||||
"requestPending": "Anfrage ausstehend",
|
||||
"cancellationRequested": "Stornierung angefordert",
|
||||
"jobName": "Auftragsname",
|
||||
"startTime": "Startzeit",
|
||||
"progress": "Fortschritt",
|
||||
"unit": "Batch",
|
||||
"pipelineMessages": "Pipeline-Nachrichten",
|
||||
"cancelButton": "Abbrechen",
|
||||
"cancelTooltip": "Pipeline-Verarbeitung abbrechen",
|
||||
"cancelConfirmTitle": "Pipeline-Stornierung bestätigen",
|
||||
"cancelConfirmDescription": "Dies unterbricht die laufende Pipeline-Verarbeitung. Möchten Sie wirklich fortfahren?",
|
||||
"cancelConfirmButton": "Stornierung bestätigen",
|
||||
"cancelInProgress": "Stornierung läuft...",
|
||||
"pipelineNotRunning": "Pipeline läuft nicht",
|
||||
"cancelSuccess": "Pipeline-Stornierung angefordert",
|
||||
"cancelFailed": "Pipeline konnte nicht abgebrochen werden\n{{error}}",
|
||||
"cancelNotBusy": "Pipeline läuft nicht, keine Stornierung erforderlich",
|
||||
"errors": {
|
||||
"fetchFailed": "Pipeline-Status konnte nicht abgerufen werden\n{{error}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"graphPanel": {
|
||||
"dataIsTruncated": "Graphdaten wurden auf maximale Knotenanzahl gekürzt",
|
||||
"fetchRetriesExhausted": "Graphdaten konnten nicht geladen werden. Zum erneuten Versuch aktualisieren.",
|
||||
"graphBuildFailed": "Graphdaten konnten nicht gerendert werden. Zum erneuten Versuch aktualisieren.",
|
||||
"statusDialog": {
|
||||
"title": "LightRAG Server-Einstellungen",
|
||||
"description": "Aktuellen Systemstatus und Verbindungsinformationen anzeigen"
|
||||
},
|
||||
"legend": "Legende",
|
||||
"nodeTypes": {
|
||||
"person": "Person",
|
||||
"category": "Kategorie",
|
||||
"geo": "Geografisch",
|
||||
"location": "Ort",
|
||||
"organization": "Organisation",
|
||||
"event": "Ereignis",
|
||||
"equipment": "Ausrüstung",
|
||||
"weapon": "Waffe",
|
||||
"animal": "Tier",
|
||||
"unknown": "Unbekannt",
|
||||
"object": "Objekt",
|
||||
"group": "Gruppe",
|
||||
"technology": "Technologie",
|
||||
"product": "Produkt",
|
||||
"document": "Dokument",
|
||||
"content": "Inhalt",
|
||||
"data": "Daten",
|
||||
"artifact": "Artefakt",
|
||||
"concept": "Konzept",
|
||||
"naturalobject": "Natürliches Objekt",
|
||||
"method": "Methode",
|
||||
"creature": "Kreatur",
|
||||
"plant": "Pflanze",
|
||||
"disease": "Krankheit",
|
||||
"drug": "Medikament",
|
||||
"food": "Lebensmittel",
|
||||
"table": "Tabelle",
|
||||
"drawing": "Zeichnung",
|
||||
"equation": "Gleichung",
|
||||
"other": "Sonstiges"
|
||||
},
|
||||
"sideBar": {
|
||||
"settings": {
|
||||
"settings": "Einstellungen",
|
||||
"healthCheck": "Gesundheitsprüfung",
|
||||
"showPropertyPanel": "Eigenschaften-Panel anzeigen",
|
||||
"showSearchBar": "Suchleiste anzeigen",
|
||||
"showNodeLabel": "Knotenbezeichnung anzeigen",
|
||||
"nodeDraggable": "Knoten verschiebbar",
|
||||
"showEdgeLabel": "Kantenbezeichnung anzeigen",
|
||||
"hideUnselectedEdges": "Nicht ausgewählte Kanten ausblenden",
|
||||
"edgeEvents": "Kanten-Ereignisse",
|
||||
"edgeEventsDisabledHint": "Deaktiviert für Graphen mit mehr als {{count}} Kanten",
|
||||
"maxQueryDepth": "Maximale Abfragetiefe",
|
||||
"maxNodes": "Maximale Knotenanzahl",
|
||||
"resetToDefault": "Auf Standard zurücksetzen",
|
||||
"edgeSizeRange": "Kantengrößenbereich",
|
||||
"depth": "T",
|
||||
"node": "Knoten",
|
||||
"edge": "Kante",
|
||||
"degree": "Grad",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"enterYourAPIkey": "Geben Sie Ihren API-Schlüssel ein",
|
||||
"save": "Speichern",
|
||||
"refreshLayout": "Layout aktualisieren"
|
||||
},
|
||||
"zoomControl": {
|
||||
"zoomIn": "Vergrößern",
|
||||
"zoomOut": "Verkleinern",
|
||||
"resetZoom": "Zoom zurücksetzen",
|
||||
"rotateCamera": "Im Uhrzeigersinn drehen",
|
||||
"rotateCameraCounterClockwise": "Gegen den Uhrzeigersinn drehen"
|
||||
},
|
||||
"layoutsControl": {
|
||||
"startAnimation": "Layout-Animation fortsetzen",
|
||||
"stopAnimation": "Layout-Animation stoppen",
|
||||
"layoutGraph": "Graph layouten",
|
||||
"layouts": {
|
||||
"Circular": "Kreisförmig",
|
||||
"Circlepack": "Kreis-Packung",
|
||||
"Random": "Zufällig",
|
||||
"Noverlaps": "Keine Überlappungen",
|
||||
"Force Directed": "Kraftgerichtet",
|
||||
"Force Atlas": "Force Atlas"
|
||||
}
|
||||
},
|
||||
"fullScreenControl": {
|
||||
"fullScreen": "Vollbild",
|
||||
"windowed": "Fenstermodus"
|
||||
},
|
||||
"legendControl": {
|
||||
"toggleLegend": "Legende umschalten"
|
||||
}
|
||||
},
|
||||
"statusIndicator": {
|
||||
"connected": "Verbunden",
|
||||
"disconnected": "Getrennt"
|
||||
},
|
||||
"statusCard": {
|
||||
"unavailable": "Statusinformationen nicht verfügbar",
|
||||
"serverInfo": "Server-Informationen",
|
||||
"inputDirectory": "Eingabeverzeichnis",
|
||||
"parser": "Parser",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "Weitere Einstellungen",
|
||||
"llmConfig": "Modellkonfiguration",
|
||||
"llmBinding": "LLM-Bindung",
|
||||
"llmBindingHost": "LLM-Endpunkt",
|
||||
"llmModel": "LLM-Modell",
|
||||
"embeddingConfig": "Einbettungskonfiguration",
|
||||
"embeddingBinding": "Einbettungsbindung",
|
||||
"embeddingBindingHost": "Einbettungs-Endpunkt",
|
||||
"embeddingModel": "Einbettungsmodell",
|
||||
"storageConfig": "Speicherkonfiguration",
|
||||
"kvStorage": "KV-Speicher",
|
||||
"docStatusStorage": "Dokumentstatus-Speicher",
|
||||
"graphStorage": "Graph-Speicher",
|
||||
"vectorStorage": "Vektor-Speicher",
|
||||
"workspace": "Arbeitsbereich",
|
||||
"rerankerConfig": "Reranker-Konfiguration",
|
||||
"rerankerBindingHost": "Reranker-Endpunkt",
|
||||
"rerankerModel": "Reranker-Modell",
|
||||
"lockStatus": "Sperrstatus"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "{{property}} bearbeiten",
|
||||
"editLockedByPipeline": "Pipeline ist ausgelastet; Bearbeitung deaktiviert",
|
||||
"editPropertyDescription": "Bearbeiten Sie den Eigenschaftswert im Textbereich unten.",
|
||||
"errors": {
|
||||
"duplicateName": "Knotenname existiert bereits",
|
||||
"updateFailed": "Knoten konnte nicht aktualisiert werden",
|
||||
"tryAgainLater": "Bitte versuchen Sie es später erneut",
|
||||
"updateSuccessButMergeFailed": "Eigenschaften aktualisiert, aber Zusammenführung fehlgeschlagen: {{error}}",
|
||||
"mergeFailed": "Zusammenführung fehlgeschlagen: {{error}}"
|
||||
},
|
||||
"success": {
|
||||
"entityUpdated": "Knoten erfolgreich aktualisiert",
|
||||
"relationUpdated": "Beziehung erfolgreich aktualisiert",
|
||||
"entityMerged": "Knoten erfolgreich zusammengeführt"
|
||||
},
|
||||
"mergeOptionLabel": "Automatisch zusammenführen, wenn ein doppelter Name gefunden wird",
|
||||
"mergeOptionDescription": "Wenn aktiviert, wird beim Umbenennen in einen bestehenden Namen dieser Knoten in den bestehenden zusammengeführt, anstatt zu scheitern.",
|
||||
"mergeDialog": {
|
||||
"title": "Knoten zusammengeführt",
|
||||
"description": "\"{{source}}\" wurde in \"{{target}}\" zusammengeführt.",
|
||||
"refreshHint": "Aktualisieren Sie den Graph, um die neueste Struktur zu laden.",
|
||||
"keepCurrentStart": "Aktualisieren und aktuellen Startknoten beibehalten",
|
||||
"useMergedStart": "Aktualisieren und zusammengeführten Knoten verwenden",
|
||||
"refreshing": "Graph wird aktualisiert..."
|
||||
},
|
||||
"node": {
|
||||
"title": "Knoten",
|
||||
"id": "ID",
|
||||
"labels": "Bezeichnungen",
|
||||
"degree": "Grad",
|
||||
"properties": "Eigenschaften",
|
||||
"relationships": "Beziehungen (innerhalb des Teilgraphen)",
|
||||
"expandNode": "Knoten erweitern",
|
||||
"pruneNode": "Knoten ausblenden",
|
||||
"deleteAllNodesError": "Löschen aller Knoten im Graph verweigert",
|
||||
"nodesRemoved": "{{count}} Knoten entfernt, einschließlich verwaister Knoten",
|
||||
"noNewNodes": "Keine erweiterbaren Knoten gefunden",
|
||||
"propertyNames": {
|
||||
"description": "Beschreibung",
|
||||
"entity_id": "Name",
|
||||
"entity_type": "Typ",
|
||||
"source_id": "C-ID",
|
||||
"Neighbour": "Nachbar",
|
||||
"file_path": "Datei",
|
||||
"keywords": "Schlüssel",
|
||||
"weight": "Gewicht"
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"title": "Beziehung",
|
||||
"id": "ID",
|
||||
"type": "Typ",
|
||||
"source": "Quelle",
|
||||
"target": "Ziel",
|
||||
"properties": "Eigenschaften"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Knoten auf der Seite suchen...",
|
||||
"message": "Und {{count}} weitere"
|
||||
},
|
||||
"graphLabels": {
|
||||
"selectTooltip": "Teilgraph eines Knotens (Bezeichnung) abrufen",
|
||||
"noLabels": "Keine passenden Knoten gefunden",
|
||||
"label": "Knotenname suchen",
|
||||
"placeholder": "Knotenname suchen...",
|
||||
"andOthers": "Und {{count}} weitere",
|
||||
"refreshGlobalTooltip": "Globale Graphdaten aktualisieren und Suchverlauf zurücksetzen",
|
||||
"refreshCurrentLabelTooltip": "Graphdaten der aktuellen Seite aktualisieren",
|
||||
"refreshingTooltip": "Daten werden aktualisiert..."
|
||||
},
|
||||
"emptyGraph": "Leer (Bitte erneut laden)"
|
||||
},
|
||||
"retrievePanel": {
|
||||
"chatMessage": {
|
||||
"copyTooltip": "In Zwischenablage kopieren",
|
||||
"copyError": "Text konnte nicht in die Zwischenablage kopiert werden",
|
||||
"copyEmpty": "Kein Inhalt zum Kopieren",
|
||||
"copySuccess": "Inhalt in Zwischenablage kopiert",
|
||||
"copySuccessLegacy": "Inhalt kopiert (Legacy-Methode)",
|
||||
"copySuccessManual": "Inhalt kopiert (manuelle Methode)",
|
||||
"copyFailed": "Inhalt konnte nicht kopiert werden",
|
||||
"copyManualInstruction": "Bitte wählen Sie den Text manuell aus und kopieren Sie ihn",
|
||||
"thinking": "Denken...",
|
||||
"thinkingTime": "Denkzeit {{time}}s",
|
||||
"thinkingInProgress": "Denken läuft..."
|
||||
},
|
||||
"retrieval": {
|
||||
"startPrompt": "Starten Sie eine Abfrage, indem Sie Ihre Frage unten eingeben",
|
||||
"clear": "Löschen",
|
||||
"send": "Senden",
|
||||
"stop": "Stopp",
|
||||
"userTerminated": "Vom Benutzer abgebrochen — die Antwort ist möglicherweise unvollständig",
|
||||
"placeholder": "Geben Sie Ihre Abfrage ein (Präfix unterstützt: /<Abfragemodus>)",
|
||||
"error": "Fehler: Antwort konnte nicht abgerufen werden",
|
||||
"queryModeError": "Nur die folgenden Abfragemodi werden unterstützt: {{modes}}",
|
||||
"queryModePrefixInvalid": "Ungültiges Abfragemodus-Präfix. Verwenden Sie: /<mode> [Leerzeichen] Ihre Abfrage"
|
||||
},
|
||||
"querySettings": {
|
||||
"parametersTitle": "Parameter",
|
||||
"parametersDescription": "Konfigurieren Sie Ihre Abfrageparameter",
|
||||
"queryMode": "Abfragemodus",
|
||||
"queryModeTooltip": "Abfragestrategie auswählen:\n• Naive: Traditionelle Text-Chunk-Vektorabfrage\n• Local: Fokus auf Entitätsabfrage\n• Global: Fokus auf Beziehungsabfrage\n• Hybrid: Local+Global\n• Mix: Local+Global+Naive\n• Bypass: Abfrage überspringen, Konversationsverlauf und aktuelle Frage an LLM senden",
|
||||
"queryModeWarning": "Kann die Abrufqualität verringern",
|
||||
"queryModeOptions": {
|
||||
"naive": "Naive",
|
||||
"local": "Local",
|
||||
"global": "Global",
|
||||
"hybrid": "Hybrid",
|
||||
"mix": "Mix",
|
||||
"bypass": "Bypass"
|
||||
},
|
||||
"responseFormat": "Antwortformat",
|
||||
"responseFormatTooltip": "Definiert das Antwortformat. Beispiele:\n• Mehrere Absätze\n• Einzelner Absatz\n• Aufzählungspunkte",
|
||||
"responseFormatOptions": {
|
||||
"multipleParagraphs": "Mehrere Absätze",
|
||||
"singleParagraph": "Einzelner Absatz",
|
||||
"bulletPoints": "Aufzählungspunkte"
|
||||
},
|
||||
"topK": "KG Top K",
|
||||
"topKTooltip": "Anzahl der abzurufenden Entitäten und Beziehungen. Gilt für Nicht-Naive-Modi.",
|
||||
"topKPlaceholder": "top_k-Wert eingeben",
|
||||
"chunkTopK": "Chunk Top K",
|
||||
"chunkTopKTooltip": "Anzahl der abzurufenden Text-Chunks, gilt für alle Modi.",
|
||||
"chunkTopKPlaceholder": "chunk_top_k-Wert eingeben",
|
||||
"maxEntityTokens": "Max. Entitäts-Tokens",
|
||||
"maxEntityTokensTooltip": "Maximale Anzahl von Tokens, die für den Entitätskontext im einheitlichen Token-Kontrollsystem zugewiesen werden",
|
||||
"maxRelationTokens": "Max. Beziehungs-Tokens",
|
||||
"maxRelationTokensTooltip": "Maximale Anzahl von Tokens, die für den Beziehungskontext im einheitlichen Token-Kontrollsystem zugewiesen werden",
|
||||
"maxTotalTokens": "Max. Gesamt-Tokens",
|
||||
"maxTotalTokensTooltip": "Maximales Gesamt-Token-Budget für den gesamten Abfragekontext (Entitäten + Beziehungen + Chunks + System-Prompt)",
|
||||
"historyTurns": "Verlaufsturns",
|
||||
"historyTurnsTooltip": "Anzahl der vollständigen Konversationsturns (Benutzer-Assistenten-Paare), die im Antwortkontext berücksichtigt werden sollen",
|
||||
"historyTurnsPlaceholder": "Anzahl der Verlaufsturns",
|
||||
"onlyNeedContext": "Nur Kontext benötigt",
|
||||
"onlyNeedContextTooltip": "Wenn True, wird nur der abgerufene Kontext ohne Generierung einer Antwort zurückgegeben",
|
||||
"onlyNeedPrompt": "Nur Prompt benötigt",
|
||||
"onlyNeedPromptTooltip": "Wenn True, wird nur der generierte Prompt ohne Erzeugung einer Antwort zurückgegeben",
|
||||
"streamResponse": "Stream-Antwort",
|
||||
"streamResponseTooltip": "Wenn True, aktiviert Streaming-Ausgabe für Echtzeit-Antworten",
|
||||
"userPrompt": "Zusätzlicher Ausgabe-Prompt",
|
||||
"userPromptTooltip": "Geben Sie zusätzliche Antwortanforderungen an das LLM an (unabhängig vom Abfrageinhalt, nur für die Ausgabeverarbeitung).",
|
||||
"userPromptPlaceholder": "Benutzerdefinierten Prompt eingeben (optional)",
|
||||
"enableRerank": "Rerank aktivieren",
|
||||
"enableRerankTooltip": "Reranking für abgerufene Text-Chunks aktivieren. Wenn True, aber kein Rerank-Modell konfiguriert ist, wird eine Warnung ausgegeben. Standard ist True."
|
||||
}
|
||||
},
|
||||
"apiSite": {
|
||||
"loading": "API-Dokumentation wird geladen..."
|
||||
},
|
||||
"apiKeyAlert": {
|
||||
"title": "API-Schlüssel ist erforderlich",
|
||||
"description": "Bitte geben Sie Ihren API-Schlüssel ein, um auf den Dienst zuzugreifen",
|
||||
"placeholder": "API-Schlüssel eingeben",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Zeige {{start}} bis {{end}} von {{total}} Einträgen",
|
||||
"page": "Seite",
|
||||
"pageSize": "Seitengröße",
|
||||
"firstPage": "Erste Seite",
|
||||
"prevPage": "Vorherige Seite",
|
||||
"nextPage": "Nächste Seite",
|
||||
"lastPage": "Letzte Seite"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"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, TEXTPACK, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, 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"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"completed": "Completed",
|
||||
"parse": "Parse",
|
||||
"analyze": "Analyze",
|
||||
"process": "Process",
|
||||
"failed": "Fail"
|
||||
},
|
||||
"status": {
|
||||
"all": "All",
|
||||
"completed": "Completed",
|
||||
"preprocessed": "Preprocessed",
|
||||
"parsing": "Parsing",
|
||||
"analyzing": "Analyzing",
|
||||
"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",
|
||||
"details": {
|
||||
"title": "Status Details",
|
||||
"openTooltip": "View status details",
|
||||
"content": "Details",
|
||||
"copyButton": "Copy",
|
||||
"copyTooltip": "Copy status details",
|
||||
"copySuccess": "Status details copied",
|
||||
"copyFailed": "Failed to copy status details"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"fetchRetriesExhausted": "Failed to load graph data. Use refresh to try again.",
|
||||
"graphBuildFailed": "Failed to render graph data. Use refresh to retry.",
|
||||
"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",
|
||||
"table": "Table",
|
||||
"drawing": "Drawing",
|
||||
"equation": "Equation",
|
||||
"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",
|
||||
"edgeEventsDisabledHint": "Disabled for graphs with more than {{count}} edges",
|
||||
"maxQueryDepth": "Max Query Depth",
|
||||
"maxNodes": "Max Nodes",
|
||||
"resetToDefault": "Reset to default",
|
||||
"edgeSizeRange": "Edge Size Range",
|
||||
"depth": "D",
|
||||
"node": "node",
|
||||
"edge": "edge",
|
||||
"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",
|
||||
"inputDirectory": "Input Directory",
|
||||
"parser": "Parser",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "Other Settings",
|
||||
"llmConfig": "Model 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",
|
||||
"rerankerConfig": "Reranker Configuration",
|
||||
"rerankerBindingHost": "Reranker Endpoint",
|
||||
"rerankerModel": "Reranker Model",
|
||||
"lockStatus": "Lock Status"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "Edit {{property}}",
|
||||
"editLockedByPipeline": "Pipeline is busy; editing disabled",
|
||||
"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": "Hide 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",
|
||||
"stop": "Stop",
|
||||
"userTerminated": "User terminated — response may be incomplete",
|
||||
"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",
|
||||
"queryModeWarning": "May reduce retrieval quality",
|
||||
"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,493 @@
|
||||
{
|
||||
"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, TEXTPACK, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, 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"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Tous",
|
||||
"completed": "Terminé",
|
||||
"parse": "Extraction",
|
||||
"analyze": "Analyse",
|
||||
"process": "Traitement",
|
||||
"failed": "Échec"
|
||||
},
|
||||
"status": {
|
||||
"all": "Tous",
|
||||
"completed": "Terminé",
|
||||
"preprocessed": "Prétraité",
|
||||
"parsing": "Analyse syntaxique",
|
||||
"analyzing": "Analyse en cours",
|
||||
"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",
|
||||
"details": {
|
||||
"title": "Détails du statut",
|
||||
"openTooltip": "Afficher les détails du statut",
|
||||
"content": "Détails",
|
||||
"copyButton": "Copier",
|
||||
"copyTooltip": "Copier les détails du statut",
|
||||
"copySuccess": "Détails du statut copiés",
|
||||
"copyFailed": "Échec de la copie des détails du statut"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"fetchRetriesExhausted": "Échec du chargement des données du graphe. Utilisez l'actualisation pour réessayer.",
|
||||
"graphBuildFailed": "Échec du rendu des données du graphe. Utilisez l'actualisation pour réessayer.",
|
||||
"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",
|
||||
"table": "Tableau",
|
||||
"drawing": "Dessin",
|
||||
"equation": "Équation",
|
||||
"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",
|
||||
"edgeEventsDisabledHint": "Désactivé pour les graphes de plus de {{count}} arêtes",
|
||||
"maxQueryDepth": "Profondeur maximale de la requête",
|
||||
"maxNodes": "Nombre maximum de nœuds",
|
||||
"resetToDefault": "Réinitialiser par défaut",
|
||||
"edgeSizeRange": "Plage de taille des arêtes",
|
||||
"depth": "D",
|
||||
"node": "Nœud",
|
||||
"edge": "Arête",
|
||||
"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",
|
||||
"inputDirectory": "Répertoire d'entrée",
|
||||
"parser": "Analyseur",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "Autres paramètres",
|
||||
"llmConfig": "Configuration du modèle",
|
||||
"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",
|
||||
"rerankerConfig": "Configuration du reclassement",
|
||||
"rerankerBindingHost": "Point de terminaison de reclassement",
|
||||
"rerankerModel": "Modèle de reclassement",
|
||||
"lockStatus": "État des verrous"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "Modifier {{property}}",
|
||||
"editLockedByPipeline": "Le pipeline est occupé ; modification désactivée",
|
||||
"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": "Masquer 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",
|
||||
"stop": "Arrêter",
|
||||
"userTerminated": "Interrompu par l'utilisateur — la réponse peut être incomplète",
|
||||
"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",
|
||||
"queryModeWarning": "Peut réduire la qualité de récupération",
|
||||
"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,493 @@
|
||||
{
|
||||
"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, TEXTPACK, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
|
||||
"fileUploader": {
|
||||
"singleFileLimit": "一度に1つ以上のファイルをアップロードできません",
|
||||
"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": "選択"
|
||||
},
|
||||
"filters": {
|
||||
"all": "すべて",
|
||||
"completed": "完了",
|
||||
"parse": "抽出",
|
||||
"analyze": "分析",
|
||||
"process": "処理",
|
||||
"failed": "失敗"
|
||||
},
|
||||
"status": {
|
||||
"all": "すべて",
|
||||
"completed": "完了",
|
||||
"preprocessed": "前処理済み",
|
||||
"parsing": "解析中",
|
||||
"analyzing": "分析中",
|
||||
"processing": "処理中",
|
||||
"pending": "保留中",
|
||||
"failed": "失敗"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "ドキュメントの読み込みに失敗しました\n{{error}}",
|
||||
"scanFailed": "ドキュメントのスキャンに失敗しました\n{{error}}",
|
||||
"scanProgressFailed": "スキャン進捗の取得に失敗しました\n{{error}}"
|
||||
},
|
||||
"fileNameLabel": "ファイル名",
|
||||
"showButton": "表示",
|
||||
"hideButton": "非表示",
|
||||
"showFileNameTooltip": "ファイル名を表示",
|
||||
"hideFileNameTooltip": "ファイル名を非表示",
|
||||
"details": {
|
||||
"title": "ステータス詳細",
|
||||
"openTooltip": "ステータス詳細を表示",
|
||||
"content": "詳細",
|
||||
"copyButton": "コピー",
|
||||
"copyTooltip": "ステータス詳細をコピー",
|
||||
"copySuccess": "ステータス詳細をコピーしました",
|
||||
"copyFailed": "ステータス詳細のコピーに失敗しました"
|
||||
}
|
||||
},
|
||||
"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": "グラフデータは最大ノード数に切り詰められました",
|
||||
"fetchRetriesExhausted": "グラフデータの読み込みに失敗しました。更新して再試行してください。",
|
||||
"graphBuildFailed": "グラフデータの描画に失敗しました。更新して再試行してください。",
|
||||
"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": "食品",
|
||||
"table": "表",
|
||||
"drawing": "図",
|
||||
"equation": "数式",
|
||||
"other": "その他"
|
||||
},
|
||||
"sideBar": {
|
||||
"settings": {
|
||||
"settings": "設定",
|
||||
"healthCheck": "ヘルスチェック",
|
||||
"showPropertyPanel": "プロパティパネルを表示",
|
||||
"showSearchBar": "検索バーを表示",
|
||||
"showNodeLabel": "ノードラベルを表示",
|
||||
"nodeDraggable": "ノードをドラッグ可能",
|
||||
"showEdgeLabel": "エッジラベルを表示",
|
||||
"hideUnselectedEdges": "選択されていないエッジを非表示",
|
||||
"edgeEvents": "エッジイベント",
|
||||
"edgeEventsDisabledHint": "エッジ数が {{count}} を超えるグラフでは無効です",
|
||||
"maxQueryDepth": "最大クエリ深度",
|
||||
"maxNodes": "最大ノード数",
|
||||
"resetToDefault": "デフォルトにリセット",
|
||||
"edgeSizeRange": "エッジサイズ範囲",
|
||||
"depth": "D",
|
||||
"node": "ノード",
|
||||
"edge": "エッジ",
|
||||
"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": "サーバー情報",
|
||||
"inputDirectory": "入力ディレクトリ",
|
||||
"parser": "パーサー",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "その他の設定",
|
||||
"llmConfig": "モデル設定",
|
||||
"llmBinding": "LLMバインディング",
|
||||
"llmBindingHost": "LLMエンドポイント",
|
||||
"llmModel": "LLMモデル",
|
||||
"embeddingConfig": "埋め込み設定",
|
||||
"embeddingBinding": "埋め込みバインディング",
|
||||
"embeddingBindingHost": "埋め込みエンドポイント",
|
||||
"embeddingModel": "埋め込みモデル",
|
||||
"storageConfig": "ストレージ設定",
|
||||
"kvStorage": "KVストレージ",
|
||||
"docStatusStorage": "ドキュメントステータスストレージ",
|
||||
"graphStorage": "グラフストレージ",
|
||||
"vectorStorage": "ベクトルストレージ",
|
||||
"workspace": "ワークスペース",
|
||||
"rerankerConfig": "リランカー設定",
|
||||
"rerankerBindingHost": "リランカーエンドポイント",
|
||||
"rerankerModel": "リランカーモデル",
|
||||
"lockStatus": "ロックステータス"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "{{property}}を編集",
|
||||
"editLockedByPipeline": "パイプライン処理中のため、編集できません",
|
||||
"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": "キー",
|
||||
"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": "送信",
|
||||
"stop": "停止",
|
||||
"userTerminated": "ユーザーが中断しました。応答が不完全な可能性があります",
|
||||
"placeholder": "クエリを入力(プレフィックス対応: /<クエリモード>)",
|
||||
"error": "エラー: 応答の取得に失敗しました",
|
||||
"queryModeError": "次のクエリモードのみサポートされています: {{modes}}",
|
||||
"queryModePrefixInvalid": "無効なクエリモードプレフィックス。使用: /<mode> [スペース] クエリ"
|
||||
},
|
||||
"querySettings": {
|
||||
"parametersTitle": "パラメータ",
|
||||
"parametersDescription": "クエリパラメータを設定",
|
||||
"queryMode": "クエリモード",
|
||||
"queryModeTooltip": "検索戦略を選択:\n• Naive: 従来のテキストチャンクベクトル検索\n• Local: エンティティ検索に焦点\n• Global: 関係検索に焦点\n• Hybrid: Local+Global\n• Mix: Local+Global+Naive\n• Bypass: 検索をスキップし、会話履歴と現在の質問をLLMに送信",
|
||||
"queryModeWarning": "検索品質が低下する可能性があります",
|
||||
"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キーが必要です",
|
||||
"description": "サービスにアクセスするには、APIキーを入力してください",
|
||||
"placeholder": "APIキーを入力",
|
||||
"save": "保存"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "{{start}}から{{end}}まで、全{{total}}件を表示",
|
||||
"page": "ページ",
|
||||
"pageSize": "ページサイズ",
|
||||
"firstPage": "最初のページ",
|
||||
"prevPage": "前のページ",
|
||||
"nextPage": "次のページ",
|
||||
"lastPage": "最後のページ"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"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, TEXTPACK, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, H, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
|
||||
"fileUploader": {
|
||||
"singleFileLimit": "한 번에 1개의 파일만 업로드할 수 있습니다",
|
||||
"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": "선택"
|
||||
},
|
||||
"filters": {
|
||||
"all": "전체",
|
||||
"completed": "완료",
|
||||
"parse": "추출",
|
||||
"analyze": "분석",
|
||||
"process": "처리",
|
||||
"failed": "실패"
|
||||
},
|
||||
"status": {
|
||||
"all": "전체",
|
||||
"completed": "완료",
|
||||
"preprocessed": "전처리 완료",
|
||||
"parsing": "파싱 중",
|
||||
"analyzing": "분석 중",
|
||||
"processing": "처리 중",
|
||||
"pending": "대기 중",
|
||||
"failed": "실패"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "문서 불러오기 실패\n{{error}}",
|
||||
"scanFailed": "문서 스캔 실패\n{{error}}",
|
||||
"scanProgressFailed": "스캔 진행 상황 가져오기 실패\n{{error}}"
|
||||
},
|
||||
"fileNameLabel": "파일명",
|
||||
"showButton": "표시",
|
||||
"hideButton": "숨기기",
|
||||
"showFileNameTooltip": "파일명 표시",
|
||||
"hideFileNameTooltip": "파일명 숨기기",
|
||||
"details": {
|
||||
"title": "상태 상세",
|
||||
"openTooltip": "상태 상세 보기",
|
||||
"content": "상세",
|
||||
"copyButton": "복사",
|
||||
"copyTooltip": "상태 상세 복사",
|
||||
"copySuccess": "상태 상세가 복사되었습니다",
|
||||
"copyFailed": "상태 상세 복사 실패"
|
||||
}
|
||||
},
|
||||
"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": "그래프 데이터가 최대 노드 수로 제한되었습니다",
|
||||
"fetchRetriesExhausted": "그래프 데이터를 불러오지 못했습니다. 새로고침하여 다시 시도하세요.",
|
||||
"graphBuildFailed": "그래프 데이터를 렌더링하지 못했습니다. 새로고침하여 다시 시도하세요.",
|
||||
"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": "식품",
|
||||
"table": "표",
|
||||
"drawing": "그림",
|
||||
"equation": "수식",
|
||||
"other": "기타"
|
||||
},
|
||||
"sideBar": {
|
||||
"settings": {
|
||||
"settings": "설정",
|
||||
"healthCheck": "헬스 체크",
|
||||
"showPropertyPanel": "속성 패널 표시",
|
||||
"showSearchBar": "검색 바 표시",
|
||||
"showNodeLabel": "노드 레이블 표시",
|
||||
"nodeDraggable": "노드 드래그 가능",
|
||||
"showEdgeLabel": "엣지 레이블 표시",
|
||||
"hideUnselectedEdges": "선택되지 않은 엣지 숨기기",
|
||||
"edgeEvents": "엣지 이벤트",
|
||||
"edgeEventsDisabledHint": "엣지가 {{count}}개를 초과하는 그래프에서는 비활성화됩니다",
|
||||
"maxQueryDepth": "최대 쿼리 깊이",
|
||||
"maxNodes": "최대 노드",
|
||||
"resetToDefault": "기본값으로 재설정",
|
||||
"edgeSizeRange": "엣지 크기 범위",
|
||||
"depth": "D",
|
||||
"node": "노드",
|
||||
"edge": "엣지",
|
||||
"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": "서버 정보",
|
||||
"inputDirectory": "입력 디렉토리",
|
||||
"parser": "파서",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "기타 설정",
|
||||
"llmConfig": "모델 구성",
|
||||
"llmBinding": "LLM 바인딩",
|
||||
"llmBindingHost": "LLM 엔드포인트",
|
||||
"llmModel": "LLM 모델",
|
||||
"embeddingConfig": "임베딩 구성",
|
||||
"embeddingBinding": "임베딩 바인딩",
|
||||
"embeddingBindingHost": "임베딩 엔드포인트",
|
||||
"embeddingModel": "임베딩 모델",
|
||||
"storageConfig": "스토리지 구성",
|
||||
"kvStorage": "KV 스토리지",
|
||||
"docStatusStorage": "문서 상태 스토리지",
|
||||
"graphStorage": "그래프 스토리지",
|
||||
"vectorStorage": "벡터 스토리지",
|
||||
"workspace": "작업 공간",
|
||||
"rerankerConfig": "Reranker 구성",
|
||||
"rerankerBindingHost": "Reranker 엔드포인트",
|
||||
"rerankerModel": "Reranker 모델",
|
||||
"lockStatus": "잠금 상태"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "{{property}} 수정",
|
||||
"editLockedByPipeline": "파이프라인 사용 중 - 편집이 비활성화되었습니다",
|
||||
"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": "키워드",
|
||||
"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": "전송",
|
||||
"stop": "중지",
|
||||
"userTerminated": "사용자가 중단했습니다. 응답이 불완전할 수 있습니다",
|
||||
"placeholder": "질문을 입력하세요 (접두사 지원: /<쿼리 모드>)",
|
||||
"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에 전송",
|
||||
"queryModeWarning": "검색 품질이 저하될 수 있습니다",
|
||||
"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": "Chunk 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": "Rerank 활성화",
|
||||
"enableRerankTooltip": "검색된 텍스트 청크에 대해 Rerank를 활성화합니다. True이지만 Rerank 모델이 구성되지 않은 경우 경고가 발생합니다. 기본값은 True입니다."
|
||||
}
|
||||
},
|
||||
"apiSite": {
|
||||
"loading": "API 문서 로드 중..."
|
||||
},
|
||||
"apiKeyAlert": {
|
||||
"title": "API 키가 필요합니다",
|
||||
"description": "서비스에 액세스하려면 API 키를 입력하세요",
|
||||
"placeholder": "API 키 입력",
|
||||
"save": "저장"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "전체 {{total}}개 중 {{start}}-{{end}}",
|
||||
"page": "페이지",
|
||||
"pageSize": "페이지 크기",
|
||||
"firstPage": "첫 페이지",
|
||||
"prevPage": "이전 페이지",
|
||||
"nextPage": "다음 페이지",
|
||||
"lastPage": "마지막 페이지"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"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, TEXTPACK, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, H, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
|
||||
"fileUploader": {
|
||||
"singleFileLimit": "Нельзя загрузить более 1 файла за раз",
|
||||
"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": "Выбрать"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Все",
|
||||
"completed": "Завершено",
|
||||
"parse": "Извлечение",
|
||||
"analyze": "Анализ",
|
||||
"process": "Обработка",
|
||||
"failed": "Ошибка"
|
||||
},
|
||||
"status": {
|
||||
"all": "Все",
|
||||
"completed": "Завершено",
|
||||
"preprocessed": "Предобработано",
|
||||
"parsing": "Разбор",
|
||||
"analyzing": "Анализ",
|
||||
"processing": "Обработка",
|
||||
"pending": "Ожидание",
|
||||
"failed": "Ошибка"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Не удалось загрузить документы\n{{error}}",
|
||||
"scanFailed": "Не удалось просканировать документы\n{{error}}",
|
||||
"scanProgressFailed": "Не удалось получить прогресс сканирования\n{{error}}"
|
||||
},
|
||||
"fileNameLabel": "Имя файла",
|
||||
"showButton": "Показать",
|
||||
"hideButton": "Скрыть",
|
||||
"showFileNameTooltip": "Показать имя файла",
|
||||
"hideFileNameTooltip": "Скрыть имя файла",
|
||||
"details": {
|
||||
"title": "Сведения о статусе",
|
||||
"openTooltip": "Показать сведения о статусе",
|
||||
"content": "Сведения",
|
||||
"copyButton": "Копировать",
|
||||
"copyTooltip": "Копировать сведения о статусе",
|
||||
"copySuccess": "Сведения о статусе скопированы",
|
||||
"copyFailed": "Не удалось скопировать сведения о статусе"
|
||||
}
|
||||
},
|
||||
"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": "Данные графа обрезаны до максимального количества узлов",
|
||||
"fetchRetriesExhausted": "Не удалось загрузить данные графа. Обновите страницу, чтобы повторить попытку.",
|
||||
"graphBuildFailed": "Не удалось отобразить данные графа. Обновите страницу, чтобы повторить попытку.",
|
||||
"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": "Еда",
|
||||
"table": "Таблица",
|
||||
"drawing": "Рисунок",
|
||||
"equation": "Уравнение",
|
||||
"other": "Другое"
|
||||
},
|
||||
"sideBar": {
|
||||
"settings": {
|
||||
"settings": "Настройки",
|
||||
"healthCheck": "Проверка здоровья",
|
||||
"showPropertyPanel": "Показать панель свойств",
|
||||
"showSearchBar": "Показать панель поиска",
|
||||
"showNodeLabel": "Показать метки узлов",
|
||||
"nodeDraggable": "Узлы перетаскиваемые",
|
||||
"showEdgeLabel": "Показать метки рёбер",
|
||||
"hideUnselectedEdges": "Скрыть невыбранные рёбра",
|
||||
"edgeEvents": "События рёбер",
|
||||
"edgeEventsDisabledHint": "Отключено для графов с более чем {{count}} рёбрами",
|
||||
"maxQueryDepth": "Максимальная глубина запроса",
|
||||
"maxNodes": "Максимальное количество узлов",
|
||||
"resetToDefault": "Сбросить к значениям по умолчанию",
|
||||
"edgeSizeRange": "Диапазон размера рёбер",
|
||||
"depth": "Г",
|
||||
"node": "Узел",
|
||||
"edge": "Ребро",
|
||||
"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": "Информация о сервере",
|
||||
"inputDirectory": "Входная директория",
|
||||
"parser": "Парсер",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "Другие настройки",
|
||||
"llmConfig": "Конфигурация модели",
|
||||
"llmBinding": "Привязка LLM",
|
||||
"llmBindingHost": "Конечная точка LLM",
|
||||
"llmModel": "Модель LLM",
|
||||
"embeddingConfig": "Конфигурация встраивания",
|
||||
"embeddingBinding": "Привязка встраивания",
|
||||
"embeddingBindingHost": "Конечная точка встраивания",
|
||||
"embeddingModel": "Модель встраивания",
|
||||
"storageConfig": "Конфигурация хранилища",
|
||||
"kvStorage": "KV хранилище",
|
||||
"docStatusStorage": "Хранилище статуса документов",
|
||||
"graphStorage": "Хранилище графа",
|
||||
"vectorStorage": "Векторное хранилище",
|
||||
"workspace": "Рабочее пространство",
|
||||
"rerankerConfig": "Конфигурация ранжирования",
|
||||
"rerankerBindingHost": "Конечная точка ранжирования",
|
||||
"rerankerModel": "Модель ранжирования",
|
||||
"lockStatus": "Статус блокировки"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "Редактировать {{property}}",
|
||||
"editLockedByPipeline": "Конвейер занят; редактирование отключено",
|
||||
"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": "Ключи",
|
||||
"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": "Отправить",
|
||||
"stop": "Остановить",
|
||||
"userTerminated": "Прервано пользователем — ответ может быть неполным",
|
||||
"placeholder": "Введите ваш запрос (Поддержка префикса: /<Режим запроса>)",
|
||||
"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",
|
||||
"queryModeWarning": "Может снизить качество поиска",
|
||||
"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": "Chunk 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 ключ",
|
||||
"description": "Пожалуйста, введите ваш API ключ для доступа к сервису",
|
||||
"placeholder": "Введите ваш API ключ",
|
||||
"save": "Сохранить"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Показано {{start}} - {{end}} из {{total}} записей",
|
||||
"page": "Страница",
|
||||
"pageSize": "Размер страницы",
|
||||
"firstPage": "Первая страница",
|
||||
"prevPage": "Предыдущая страница",
|
||||
"nextPage": "Следующая страница",
|
||||
"lastPage": "Последняя страница"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"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, TEXTPACK, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, H, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
|
||||
"fileUploader": {
|
||||
"singleFileLimit": "Не можна завантажити більше 1 файлу одночасно",
|
||||
"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": "Вибрати"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Усі",
|
||||
"completed": "Завершено",
|
||||
"parse": "Видобування",
|
||||
"analyze": "Аналіз",
|
||||
"process": "Обробка",
|
||||
"failed": "Помилка"
|
||||
},
|
||||
"status": {
|
||||
"all": "Всі",
|
||||
"completed": "Завершено",
|
||||
"preprocessed": "Попередньо оброблено",
|
||||
"parsing": "Розбір",
|
||||
"analyzing": "Аналіз",
|
||||
"processing": "Обробка",
|
||||
"pending": "Очікування",
|
||||
"failed": "Невдало"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Не вдалося завантажити документи\n{{error}}",
|
||||
"scanFailed": "Не вдалося відсканувати документи\n{{error}}",
|
||||
"scanProgressFailed": "Не вдалося отримати прогрес сканування\n{{error}}"
|
||||
},
|
||||
"fileNameLabel": "Ім'я файлу",
|
||||
"showButton": "Показати",
|
||||
"hideButton": "Приховати",
|
||||
"showFileNameTooltip": "Показати ім'я файлу",
|
||||
"hideFileNameTooltip": "Приховати ім'я файлу",
|
||||
"details": {
|
||||
"title": "Відомості про статус",
|
||||
"openTooltip": "Показати відомості про статус",
|
||||
"content": "Відомості",
|
||||
"copyButton": "Копіювати",
|
||||
"copyTooltip": "Копіювати відомості про статус",
|
||||
"copySuccess": "Відомості про статус скопійовано",
|
||||
"copyFailed": "Не вдалося скопіювати відомості про статус"
|
||||
}
|
||||
},
|
||||
"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": "Дані графа обрізано до максимальної кількості вузлів",
|
||||
"fetchRetriesExhausted": "Не вдалося завантажити дані графа. Оновіть, щоб спробувати ще раз.",
|
||||
"graphBuildFailed": "Не вдалося відобразити дані графа. Оновіть, щоб спробувати ще раз.",
|
||||
"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": "Їжа",
|
||||
"table": "Таблиця",
|
||||
"drawing": "Малюнок",
|
||||
"equation": "Рівняння",
|
||||
"other": "Інше"
|
||||
},
|
||||
"sideBar": {
|
||||
"settings": {
|
||||
"settings": "Налаштування",
|
||||
"healthCheck": "Перевірка здоров'я",
|
||||
"showPropertyPanel": "Показати панель властивостей",
|
||||
"showSearchBar": "Показати панель пошуку",
|
||||
"showNodeLabel": "Показати мітку вузла",
|
||||
"nodeDraggable": "Вузол перетягуваний",
|
||||
"showEdgeLabel": "Показати мітку ребра",
|
||||
"hideUnselectedEdges": "Приховати невибрані ребра",
|
||||
"edgeEvents": "Події ребер",
|
||||
"edgeEventsDisabledHint": "Вимкнено для графів із понад {{count}} ребрами",
|
||||
"maxQueryDepth": "Максимальна глибина запиту",
|
||||
"maxNodes": "Максимальна кількість вузлів",
|
||||
"resetToDefault": "Скинути до за замовчуванням",
|
||||
"edgeSizeRange": "Діапазон розміру ребер",
|
||||
"depth": "Г",
|
||||
"node": "Вузол",
|
||||
"edge": "Ребро",
|
||||
"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": "Force Atlas"
|
||||
}
|
||||
},
|
||||
"fullScreenControl": {
|
||||
"fullScreen": "Повноекранний режим",
|
||||
"windowed": "Віконний режим"
|
||||
},
|
||||
"legendControl": {
|
||||
"toggleLegend": "Перемкнути легенду"
|
||||
}
|
||||
},
|
||||
"statusIndicator": {
|
||||
"connected": "Підключено",
|
||||
"disconnected": "Відключено"
|
||||
},
|
||||
"statusCard": {
|
||||
"unavailable": "Інформація про статус недоступна",
|
||||
"serverInfo": "Інформація про сервер",
|
||||
"inputDirectory": "Вхідна директорія",
|
||||
"parser": "Парсер",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "Інші налаштування",
|
||||
"llmConfig": "Конфігурація моделі",
|
||||
"llmBinding": "Прив'язка LLM",
|
||||
"llmBindingHost": "Кінцева точка LLM",
|
||||
"llmModel": "Модель LLM",
|
||||
"embeddingConfig": "Конфігурація вбудовування",
|
||||
"embeddingBinding": "Прив'язка вбудовування",
|
||||
"embeddingBindingHost": "Кінцева точка вбудовування",
|
||||
"embeddingModel": "Модель вбудовування",
|
||||
"storageConfig": "Конфігурація сховища",
|
||||
"kvStorage": "KV сховище",
|
||||
"docStatusStorage": "Сховище статусу документів",
|
||||
"graphStorage": "Сховище графа",
|
||||
"vectorStorage": "Векторне сховище",
|
||||
"workspace": "Робочий простір",
|
||||
"rerankerConfig": "Конфігурація реранкера",
|
||||
"rerankerBindingHost": "Кінцева точка реранкера",
|
||||
"rerankerModel": "Модель реранкера",
|
||||
"lockStatus": "Статус блокування"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "Редагувати {{property}}",
|
||||
"editLockedByPipeline": "Конвеєр зайнятий; редагування вимкнено",
|
||||
"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": "Ключі",
|
||||
"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": "Відправити",
|
||||
"stop": "Зупинити",
|
||||
"userTerminated": "Перервано користувачем — відповідь може бути неповною",
|
||||
"placeholder": "Введіть ваш запит (Підтримка префіксу: /<Режим запиту>)",
|
||||
"error": "Помилка: Не вдалося отримати відповідь",
|
||||
"queryModeError": "Підтримуються лише наступні режими запиту: {{modes}}",
|
||||
"queryModePrefixInvalid": "Недійсний префікс режиму запиту. Використовуйте: /<mode> [пробіл] ваш запит"
|
||||
},
|
||||
"querySettings": {
|
||||
"parametersTitle": "Параметри",
|
||||
"parametersDescription": "Налаштуйте параметри вашого запиту",
|
||||
"queryMode": "Режим запиту",
|
||||
"queryModeTooltip": "Виберіть стратегію пошуку:\n• Naive: Традиційний пошук векторів текстових чанків\n• Local: Фокус на пошуку сутностей\n• Global: Фокус на пошуку відношень\n• Hybrid: Local+Global\n• Mix: Local+Global+Naive\n• Bypass: Пропустити пошук, надіслати історію розмови та поточне питання в LLM",
|
||||
"queryModeWarning": "Може знизити якість пошуку",
|
||||
"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": "Chunk 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 ключ",
|
||||
"description": "Будь ласка, введіть ваш API ключ для доступу до сервісу",
|
||||
"placeholder": "Введіть ваш API ключ",
|
||||
"save": "Зберегти"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Показано {{start}} до {{end}} з {{total}} записів",
|
||||
"page": "Сторінка",
|
||||
"pageSize": "Розмір сторінки",
|
||||
"firstPage": "Перша сторінка",
|
||||
"prevPage": "Попередня сторінка",
|
||||
"nextPage": "Наступна сторінка",
|
||||
"lastPage": "Остання сторінка"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"settings": {
|
||||
"language": "Ngôn ngữ",
|
||||
"theme": "Giao diện",
|
||||
"light": "Sáng",
|
||||
"dark": "Tối",
|
||||
"system": "Hệ thống"
|
||||
},
|
||||
"header": {
|
||||
"documents": "Tài liệu",
|
||||
"knowledgeGraph": "Đồ thị tri thức",
|
||||
"retrieval": "Truy xuất",
|
||||
"api": "API",
|
||||
"projectRepository": "Kho dự án",
|
||||
"logout": "Đăng xuất",
|
||||
"frontendNeedsRebuild": "Giao diện cần được xây dựng lại",
|
||||
"themeToggle": {
|
||||
"switchToLight": "Chuyển sang giao diện sáng",
|
||||
"switchToDark": "Chuyển sang giao diện tối"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"description": "Vui lòng nhập tài khoản và mật khẩu để đăng nhập vào hệ thống",
|
||||
"username": "Tên người dùng",
|
||||
"usernamePlaceholder": "Vui lòng nhập tên người dùng",
|
||||
"password": "Mật khẩu",
|
||||
"passwordPlaceholder": "Vui lòng nhập mật khẩu",
|
||||
"loginButton": "Đăng nhập",
|
||||
"loggingIn": "Đang đăng nhập...",
|
||||
"successMessage": "Đăng nhập thành công",
|
||||
"errorEmptyFields": "Vui lòng nhập tên người dùng và mật khẩu",
|
||||
"errorInvalidCredentials": "Đăng nhập thất bại, vui lòng kiểm tra tên người dùng và mật khẩu",
|
||||
"authDisabled": "Xác thực bị tắt. Đang sử dụng chế độ không cần đăng nhập.",
|
||||
"guestMode": "Không cần đăng nhập"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Hủy",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu...",
|
||||
"saveFailed": "Lưu thất bại"
|
||||
},
|
||||
"documentPanel": {
|
||||
"clearDocuments": {
|
||||
"button": "Xóa tất cả",
|
||||
"tooltip": "Xóa tài liệu",
|
||||
"title": "Xóa Tài Liệu",
|
||||
"description": "Thao tác này sẽ xóa tất cả tài liệu khỏi hệ thống",
|
||||
"warning": "CẢNH BÁO: Hành động này sẽ xóa vĩnh viễn tất cả tài liệu và không thể hoàn tác!",
|
||||
"confirm": "Bạn có thực sự muốn xóa tất cả tài liệu không?",
|
||||
"confirmPrompt": "Nhập 'yes' để xác nhận hành động này",
|
||||
"confirmPlaceholder": "Nhập yes để xác nhận",
|
||||
"clearCache": "Xóa bộ nhớ cache LLM",
|
||||
"confirmButton": "CÓ",
|
||||
"clearing": "Đang xóa...",
|
||||
"timeout": "Thao tác xóa hết thời gian, vui lòng thử lại",
|
||||
"success": "Đã xóa tài liệu thành công",
|
||||
"cacheCleared": "Đã xóa bộ nhớ cache thành công",
|
||||
"cacheClearFailed": "Xóa bộ nhớ cache thất bại:\n{{error}}",
|
||||
"failed": "Xóa Tài Liệu Thất Bại:\n{{message}}",
|
||||
"error": "Xóa Tài Liệu Thất Bại:\n{{error}}"
|
||||
},
|
||||
"deleteDocuments": {
|
||||
"button": "Xóa",
|
||||
"tooltip": "Xóa tài liệu đã chọn",
|
||||
"title": "Xóa Tài Liệu",
|
||||
"description": "Thao tác này sẽ xóa vĩnh viễn các tài liệu đã chọn khỏi hệ thống",
|
||||
"warning": "CẢNH BÁO: Hành động này sẽ xóa vĩnh viễn các tài liệu đã chọn và không thể hoàn tác!",
|
||||
"confirm": "Bạn có thực sự muốn xóa {{count}} tài liệu đã chọn không?",
|
||||
"confirmPrompt": "Nhập 'yes' để xác nhận hành động này",
|
||||
"confirmPlaceholder": "Nhập yes để xác nhận",
|
||||
"confirmButton": "CÓ",
|
||||
"deleteFileOption": "Cũng xóa các tệp đã tải lên",
|
||||
"deleteFileTooltip": "Chọn tùy chọn này để cũng xóa các tệp đã tải lên tương ứng trên máy chủ",
|
||||
"deleteLLMCacheOption": "Cũng xóa cache LLM đã trích xuất",
|
||||
"success": "Đã bắt đầu quy trình xóa tài liệu thành công",
|
||||
"failed": "Xóa Tài Liệu Thất Bại:\n{{message}}",
|
||||
"error": "Xóa Tài Liệu Thất Bại:\n{{error}}",
|
||||
"busy": "Quy trình đang bận, vui lòng thử lại sau",
|
||||
"notAllowed": "Không có quyền thực hiện thao tác này"
|
||||
},
|
||||
"selectDocuments": {
|
||||
"selectCurrentPage": "Chọn Trang Hiện Tại ({{count}})",
|
||||
"deselectAll": "Bỏ Chọn Tất Cả ({{count}})"
|
||||
},
|
||||
"uploadDocuments": {
|
||||
"button": "Tải lên",
|
||||
"tooltip": "Tải lên tài liệu",
|
||||
"title": "Tải Lên Tài Liệu",
|
||||
"description": "Kéo và thả tài liệu vào đây hoặc nhấp để duyệt.",
|
||||
"single": {
|
||||
"uploading": "Đang tải lên {{name}}: {{percent}}%",
|
||||
"success": "Tải Lên Thành Công:\n{{name}} đã được tải lên thành công",
|
||||
"failed": "Tải Lên Thất Bại:\n{{name}}\n{{message}}",
|
||||
"error": "Tải Lên Thất Bại:\n{{name}}\n{{error}}"
|
||||
},
|
||||
"batch": {
|
||||
"uploading": "Đang tải lên các tệp...",
|
||||
"success": "Các tệp đã được tải lên thành công",
|
||||
"error": "Một số tệp tải lên thất bại"
|
||||
},
|
||||
"generalError": "Tải Lên Thất Bại\n{{error}}",
|
||||
"fileTypes": "Các loại được hỗ trợ: TXT, MD, TEXTPACK, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, H, CPP, HPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS",
|
||||
"fileUploader": {
|
||||
"singleFileLimit": "Không thể tải lên nhiều hơn 1 tệp một lần",
|
||||
"maxFilesLimit": "Không thể tải lên nhiều hơn {{count}} tệp",
|
||||
"fileRejected": "Tệp {{name}} đã bị từ chối",
|
||||
"unsupportedType": "Loại tệp không được hỗ trợ",
|
||||
"fileTooLarge": "Tệp quá lớn, kích thước tối đa là {{maxSize}}",
|
||||
"dropHere": "Thả tệp vào đây",
|
||||
"dragAndDrop": "Kéo và thả tệp vào đây, hoặc nhấp để chọn tệp",
|
||||
"removeFile": "Xóa tệp",
|
||||
"uploadDescription": "Bạn có thể tải lên {{isMultiple ? 'nhiều' : count}} tệp (tối đa {{maxSize}} mỗi tệp)",
|
||||
"duplicateFile": "Tên tệp đã tồn tại trong bộ nhớ cache của máy chủ"
|
||||
}
|
||||
},
|
||||
"documentManager": {
|
||||
"title": "Quản Lý Tài Liệu",
|
||||
"scanButton": "Quét/Thử lại",
|
||||
"scanTooltip": "Quét và xử lý tài liệu trong thư mục đầu vào, đồng thời xử lý lại tất cả tài liệu thất bại",
|
||||
"refreshTooltip": "Đặt lại danh sách tài liệu",
|
||||
"pipelineStatusButton": "Quy trình",
|
||||
"pipelineStatusTooltip": "Xem trạng thái quy trình xử lý tài liệu",
|
||||
"uploadedTitle": "Tài Liệu Đã Tải Lên",
|
||||
"uploadedDescription": "Danh sách các tài liệu đã tải lên và trạng thái của chúng.",
|
||||
"emptyTitle": "Không Có Tài Liệu",
|
||||
"emptyDescription": "Chưa có tài liệu nào được tải lên.",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"fileName": "Tên Tệp",
|
||||
"summary": "Tóm Tắt",
|
||||
"status": "Trạng Thái",
|
||||
"length": "Độ Dài",
|
||||
"chunks": "Đoạn",
|
||||
"created": "Ngày Tạo",
|
||||
"updated": "Ngày Cập Nhật",
|
||||
"metadata": "Siêu Dữ Liệu",
|
||||
"select": "Chọn"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Tất cả",
|
||||
"completed": "Hoàn tất",
|
||||
"parse": "Trích xuất",
|
||||
"analyze": "Phân tích",
|
||||
"process": "Xử lý",
|
||||
"failed": "Thất bại"
|
||||
},
|
||||
"status": {
|
||||
"all": "Tất cả",
|
||||
"completed": "Hoàn thành",
|
||||
"preprocessed": "Đã tiền xử lý",
|
||||
"parsing": "Đang phân tích cú pháp",
|
||||
"analyzing": "Đang phân tích",
|
||||
"processing": "Đang xử lý",
|
||||
"pending": "Đang chờ",
|
||||
"failed": "Thất bại"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Tải tài liệu thất bại\n{{error}}",
|
||||
"scanFailed": "Quét tài liệu thất bại\n{{error}}",
|
||||
"scanProgressFailed": "Lấy tiến trình quét thất bại\n{{error}}"
|
||||
},
|
||||
"fileNameLabel": "Tên Tệp",
|
||||
"showButton": "Hiện",
|
||||
"hideButton": "Ẩn",
|
||||
"showFileNameTooltip": "Hiện tên tệp",
|
||||
"hideFileNameTooltip": "Ẩn tên tệp",
|
||||
"details": {
|
||||
"title": "Chi tiết trạng thái",
|
||||
"openTooltip": "Xem chi tiết trạng thái",
|
||||
"content": "Chi tiết",
|
||||
"copyButton": "Sao chép",
|
||||
"copyTooltip": "Sao chép chi tiết trạng thái",
|
||||
"copySuccess": "Đã sao chép chi tiết trạng thái",
|
||||
"copyFailed": "Sao chép chi tiết trạng thái thất bại"
|
||||
}
|
||||
},
|
||||
"pipelineStatus": {
|
||||
"title": "Trạng Thái Quy Trình",
|
||||
"busy": "Quy Trình Đang Bận",
|
||||
"requestPending": "Yêu Cầu Đang Chờ",
|
||||
"cancellationRequested": "Đã Yêu Cầu Hủy",
|
||||
"jobName": "Tên Công Việc",
|
||||
"startTime": "Thời Gian Bắt Đầu",
|
||||
"progress": "Tiến Trình",
|
||||
"unit": "Lô",
|
||||
"pipelineMessages": "Thông Báo Quy Trình",
|
||||
"cancelButton": "Hủy",
|
||||
"cancelTooltip": "Hủy xử lý quy trình",
|
||||
"cancelConfirmTitle": "Xác Nhận Hủy Quy Trình",
|
||||
"cancelConfirmDescription": "Thao tác này sẽ ngắt quá trình xử lý đang diễn ra. Bạn có chắc chắn muốn tiếp tục không?",
|
||||
"cancelConfirmButton": "Xác Nhận Hủy",
|
||||
"cancelInProgress": "Đang hủy...",
|
||||
"pipelineNotRunning": "Quy trình không đang chạy",
|
||||
"cancelSuccess": "Đã yêu cầu hủy quy trình",
|
||||
"cancelFailed": "Hủy quy trình thất bại\n{{error}}",
|
||||
"cancelNotBusy": "Quy trình không đang chạy, không cần hủy",
|
||||
"errors": {
|
||||
"fetchFailed": "Lấy trạng thái quy trình thất bại\n{{error}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"graphPanel": {
|
||||
"dataIsTruncated": "Dữ liệu đồ thị bị cắt bớt đến số Nút tối Đa",
|
||||
"fetchRetriesExhausted": "Không tải được dữ liệu đồ thị. Hãy làm mới để thử lại.",
|
||||
"graphBuildFailed": "Không hiển thị được dữ liệu đồ thị. Hãy làm mới để thử lại.",
|
||||
"statusDialog": {
|
||||
"title": "Cài Đặt Máy Chủ LightRAG",
|
||||
"description": "Xem trạng thái hệ thống hiện tại và thông tin kết nối"
|
||||
},
|
||||
"legend": "Chú thích",
|
||||
"nodeTypes": {
|
||||
"person": "Người",
|
||||
"category": "Danh Mục",
|
||||
"geo": "Địa Lý",
|
||||
"location": "Địa Điểm",
|
||||
"organization": "Tổ Chức",
|
||||
"event": "Sự Kiện",
|
||||
"equipment": "Thiết Bị",
|
||||
"weapon": "Vũ Khí",
|
||||
"animal": "Động Vật",
|
||||
"unknown": "Không Xác Định",
|
||||
"object": "Đối Tượng",
|
||||
"group": "Nhóm",
|
||||
"technology": "Công Nghệ",
|
||||
"product": "Sản Phẩm",
|
||||
"document": "Tài Liệu",
|
||||
"content": "Nội Dung",
|
||||
"data": "Dữ Liệu",
|
||||
"artifact": "Vật Phẩm",
|
||||
"concept": "Khái Niệm",
|
||||
"naturalobject": "Vật Thể Tự Nhiên",
|
||||
"method": "Phương Pháp",
|
||||
"creature": "Sinh Vật",
|
||||
"plant": "Thực Vật",
|
||||
"disease": "Bệnh",
|
||||
"drug": "Thuốc",
|
||||
"food": "Thực Phẩm",
|
||||
"table": "Bảng",
|
||||
"drawing": "Hình Vẽ",
|
||||
"equation": "Phương Trình",
|
||||
"other": "Khác"
|
||||
},
|
||||
"sideBar": {
|
||||
"settings": {
|
||||
"settings": "Cài Đặt",
|
||||
"healthCheck": "Kiểm Tra Kết Nối",
|
||||
"showPropertyPanel": "Hiển Thị Bảng Thuộc Tính",
|
||||
"showSearchBar": "Hiển Thị Thanh Tìm Kiếm",
|
||||
"showNodeLabel": "Hiển Thị Nhãn Nút",
|
||||
"nodeDraggable": "Cho Phép Kéo Nút",
|
||||
"showEdgeLabel": "Hiển Thị Nhãn Cạnh",
|
||||
"hideUnselectedEdges": "Ẩn Cạnh Không Được Chọn",
|
||||
"edgeEvents": "Sự Kiện Cạnh",
|
||||
"edgeEventsDisabledHint": "Bị vô hiệu hóa với đồ thị có hơn {{count}} cạnh",
|
||||
"maxQueryDepth": "Độ Sâu Truy Vấn Tối Đa",
|
||||
"maxNodes": "Số Nút Tối Đa",
|
||||
"resetToDefault": "Đặt lại về mặc định",
|
||||
"edgeSizeRange": "Phạm Vi Kích Thước Cạnh",
|
||||
"depth": "Sâu",
|
||||
"node": "Nút",
|
||||
"edge": "Cạnh",
|
||||
"degree": "Bậc",
|
||||
"apiKey": "Khóa API",
|
||||
"enterYourAPIkey": "Nhập khóa API của bạn",
|
||||
"save": "Lưu",
|
||||
"refreshLayout": "Làm Mới Bố Cục"
|
||||
},
|
||||
"zoomControl": {
|
||||
"zoomIn": "Phóng To",
|
||||
"zoomOut": "Thu Nhỏ",
|
||||
"resetZoom": "Đặt Lại Thu Phóng",
|
||||
"rotateCamera": "Xoay Theo Chiều Kim Đồng Hồ",
|
||||
"rotateCameraCounterClockwise": "Xoay Ngược Chiều Kim Đồng Hồ"
|
||||
},
|
||||
"layoutsControl": {
|
||||
"startAnimation": "Tiếp tục hoạt ảnh bố cục",
|
||||
"stopAnimation": "Dừng hoạt ảnh bố cục",
|
||||
"layoutGraph": "Bố Cục Đồ Thị",
|
||||
"layouts": {
|
||||
"Circular": "Circular",
|
||||
"Circlepack": "Circlepack",
|
||||
"Random": "Random",
|
||||
"Noverlaps": "Noverlaps",
|
||||
"Force Directed": "Force Directed",
|
||||
"Force Atlas": "Force Atlas"
|
||||
}
|
||||
},
|
||||
"fullScreenControl": {
|
||||
"fullScreen": "Toàn Màn Hình",
|
||||
"windowed": "Chế Độ Cửa Sổ"
|
||||
},
|
||||
"legendControl": {
|
||||
"toggleLegend": "Bật/Tắt Chú Thích"
|
||||
}
|
||||
},
|
||||
"statusIndicator": {
|
||||
"connected": "Đã kết nối",
|
||||
"disconnected": "Mất kết nối"
|
||||
},
|
||||
"statusCard": {
|
||||
"unavailable": "Thông tin trạng thái không khả dụng",
|
||||
"serverInfo": "Thông Tin Máy Chủ",
|
||||
"inputDirectory": "Thư Mục Đầu Vào",
|
||||
"parser": "Bộ Phân Tích",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "Cài Đặt Khác",
|
||||
"llmConfig": "Cấu Hình Mô Hình",
|
||||
"llmBinding": "LLM Binding",
|
||||
"llmBindingHost": "Điểm Cuối LLM",
|
||||
"llmModel": "Mô Hình LLM",
|
||||
"embeddingConfig": "Cấu Hình Embedding",
|
||||
"embeddingBinding": "Embedding Binding",
|
||||
"embeddingBindingHost": "Điểm Cuối Embedding",
|
||||
"embeddingModel": "Mô Hình Embedding",
|
||||
"storageConfig": "Cấu Hình Lưu Trữ",
|
||||
"kvStorage": "KV Storage",
|
||||
"docStatusStorage": "Lưu Trữ Trạng Thái Tài Liệu",
|
||||
"graphStorage": "Graph Storage",
|
||||
"vectorStorage": "Vector Storage",
|
||||
"workspace": "Không Gian Làm Việc",
|
||||
"rerankerConfig": "Cấu Hình Reranker",
|
||||
"rerankerBindingHost": "Điểm Cuối Reranker",
|
||||
"rerankerModel": "Mô Hình Reranker",
|
||||
"lockStatus": "Trạng Thái Khóa"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "Chỉnh Sửa {{property}}",
|
||||
"editLockedByPipeline": "Pipeline đang bận; chỉnh sửa đã bị tắt",
|
||||
"editPropertyDescription": "Chỉnh sửa giá trị thuộc tính trong vùng văn bản bên dưới.",
|
||||
"errors": {
|
||||
"duplicateName": "Tên nút đã tồn tại",
|
||||
"updateFailed": "Cập nhật nút thất bại",
|
||||
"tryAgainLater": "Vui lòng thử lại sau",
|
||||
"updateSuccessButMergeFailed": "Đã cập nhật thuộc tính, nhưng hợp nhất thất bại: {{error}}",
|
||||
"mergeFailed": "Hợp nhất thất bại: {{error}}"
|
||||
},
|
||||
"success": {
|
||||
"entityUpdated": "Nút đã được cập nhật thành công",
|
||||
"relationUpdated": "Quan hệ đã được cập nhật thành công",
|
||||
"entityMerged": "Các nút đã được hợp nhất thành công"
|
||||
},
|
||||
"mergeOptionLabel": "Tự động hợp nhất khi tìm thấy tên trùng lặp",
|
||||
"mergeOptionDescription": "Nếu được bật, đổi tên thành một tên đã tồn tại sẽ hợp nhất nút này vào nút hiện có thay vì thất bại.",
|
||||
"mergeDialog": {
|
||||
"title": "Nút đã được hợp nhất",
|
||||
"description": "\"{{source}}\" đã được hợp nhất vào \"{{target}}\".",
|
||||
"refreshHint": "Làm mới đồ thị để tải cấu trúc mới nhất.",
|
||||
"keepCurrentStart": "Làm mới và giữ nút bắt đầu hiện tại",
|
||||
"useMergedStart": "Làm mới và sử dụng nút đã hợp nhất",
|
||||
"refreshing": "Đang làm mới đồ thị..."
|
||||
},
|
||||
"node": {
|
||||
"title": "Nút",
|
||||
"id": "ID",
|
||||
"labels": "Nhãn",
|
||||
"degree": "Bậc",
|
||||
"properties": "Thuộc Tính",
|
||||
"relationships": "Quan Hệ (trong đồ thị con)",
|
||||
"expandNode": "Mở Rộng Nút",
|
||||
"pruneNode": "Ẩn Nút",
|
||||
"deleteAllNodesError": "Từ chối xóa tất cả các nút trong đồ thị",
|
||||
"nodesRemoved": "Đã xóa {{count}} nút, bao gồm các nút cô lập",
|
||||
"noNewNodes": "Không tìm thấy nút có thể mở rộng",
|
||||
"propertyNames": {
|
||||
"description": "Mô Tả",
|
||||
"entity_id": "Tên",
|
||||
"entity_type": "Loại",
|
||||
"source_id": "C-ID",
|
||||
"Neighbour": "Láng Giềng",
|
||||
"file_path": "Tệp",
|
||||
"keywords": "Từ Khóa",
|
||||
"weight": "Trọng Số"
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"title": "Quan Hệ",
|
||||
"id": "ID",
|
||||
"type": "Loại",
|
||||
"source": "Nguồn",
|
||||
"target": "Đích",
|
||||
"properties": "Thuộc Tính"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Tìm kiếm nút trong trang...",
|
||||
"message": "Và {{count}} khác"
|
||||
},
|
||||
"graphLabels": {
|
||||
"selectTooltip": "Lấy đồ thị con của một nút (nhãn)",
|
||||
"noLabels": "Không tìm thấy nút phù hợp",
|
||||
"label": "Tìm kiếm tên nút",
|
||||
"placeholder": "Tìm kiếm tên nút...",
|
||||
"andOthers": "Và {{count}} khác",
|
||||
"refreshGlobalTooltip": "Làm mới dữ liệu đồ thị toàn cục và đặt lại lịch sử tìm kiếm",
|
||||
"refreshCurrentLabelTooltip": "Làm mới dữ liệu đồ thị trang hiện tại",
|
||||
"refreshingTooltip": "Đang làm mới dữ liệu..."
|
||||
},
|
||||
"emptyGraph": "Trống (Thử Tải Lại)"
|
||||
},
|
||||
"retrievePanel": {
|
||||
"chatMessage": {
|
||||
"copyTooltip": "Sao chép vào bộ nhớ tạm",
|
||||
"copyError": "Sao chép văn bản vào bộ nhớ tạm thất bại",
|
||||
"copyEmpty": "Không có nội dung để sao chép",
|
||||
"copySuccess": "Đã sao chép nội dung vào bộ nhớ tạm",
|
||||
"copySuccessLegacy": "Đã sao chép nội dung (phương pháp cũ)",
|
||||
"copySuccessManual": "Đã sao chép nội dung (phương pháp thủ công)",
|
||||
"copyFailed": "Sao chép nội dung thất bại",
|
||||
"copyManualInstruction": "Vui lòng chọn và sao chép văn bản thủ công",
|
||||
"thinking": "Đang suy nghĩ...",
|
||||
"thinkingTime": "Thời gian suy nghĩ {{time}}s",
|
||||
"thinkingInProgress": "Đang suy nghĩ..."
|
||||
},
|
||||
"retrieval": {
|
||||
"startPrompt": "Bắt đầu truy xuất bằng cách nhập truy vấn của bạn bên dưới",
|
||||
"clear": "Xóa",
|
||||
"send": "Gửi",
|
||||
"stop": "Dừng",
|
||||
"userTerminated": "Người dùng đã dừng — phản hồi có thể chưa hoàn chỉnh",
|
||||
"placeholder": "Nhập truy vấn của bạn (Hỗ trợ tiền tố: /<Chế Độ Truy Vấn>)",
|
||||
"error": "Lỗi: Không thể nhận phản hồi",
|
||||
"queryModeError": "Chỉ hỗ trợ các chế độ truy vấn sau: {{modes}}",
|
||||
"queryModePrefixInvalid": "Tiền tố chế độ truy vấn không hợp lệ. Dùng: /<mode> [khoảng trắng] truy vấn của bạn"
|
||||
},
|
||||
"querySettings": {
|
||||
"parametersTitle": "Tham Số",
|
||||
"parametersDescription": "Cấu hình tham số truy vấn của bạn",
|
||||
"queryMode": "Chế Độ Truy Vấn",
|
||||
"queryModeTooltip": "Chọn chiến lược truy xuất:\n• Naive: Truy xuất vector đoạn văn bản truyền thống\n• Local: Tập trung vào truy xuất thực thể\n• Global: Tập trung vào truy xuất quan hệ\n• Hybrid: Local+Global\n• Mix: Local+Global+Naive\n• Bypass: Bỏ qua truy xuất, gửi lịch sử hội thoại và câu hỏi hiện tại đến LLM",
|
||||
"queryModeWarning": "Có thể làm giảm chất lượng truy xuất",
|
||||
"queryModeOptions": {
|
||||
"naive": "Naive",
|
||||
"local": "Local",
|
||||
"global": "Global",
|
||||
"hybrid": "Hybrid",
|
||||
"mix": "Mix",
|
||||
"bypass": "Bypass"
|
||||
},
|
||||
"responseFormat": "Định Dạng Phản Hồi",
|
||||
"responseFormatTooltip": "Xác định định dạng phản hồi. Ví dụ:\n• Nhiều Đoạn Văn\n• Một Đoạn Văn\n• Danh Sách Điểm",
|
||||
"responseFormatOptions": {
|
||||
"multipleParagraphs": "Nhiều Đoạn Văn",
|
||||
"singleParagraph": "Một Đoạn Văn",
|
||||
"bulletPoints": "Danh Sách Điểm"
|
||||
},
|
||||
"topK": "KG Top K",
|
||||
"topKTooltip": "Số lượng thực thể và quan hệ cần truy xuất. Áp dụng cho các chế độ không phải naive.",
|
||||
"topKPlaceholder": "Nhập giá trị top_k",
|
||||
"chunkTopK": "Chunk Top K",
|
||||
"chunkTopKTooltip": "Số lượng đoạn văn bản cần truy xuất, áp dụng cho tất cả các chế độ.",
|
||||
"chunkTopKPlaceholder": "Nhập giá trị chunk_top_k",
|
||||
"maxEntityTokens": "Token Thực Thể Tối Đa",
|
||||
"maxEntityTokensTooltip": "Số lượng token tối đa được phân bổ cho ngữ cảnh thực thể trong hệ thống kiểm soát token thống nhất",
|
||||
"maxRelationTokens": "Token Quan Hệ Tối Đa",
|
||||
"maxRelationTokensTooltip": "Số lượng token tối đa được phân bổ cho ngữ cảnh quan hệ trong hệ thống kiểm soát token thống nhất",
|
||||
"maxTotalTokens": "Tổng Token Tối Đa",
|
||||
"maxTotalTokensTooltip": "Ngân sách token tổng tối đa cho toàn bộ ngữ cảnh truy vấn (thực thể + quan hệ + đoạn + lời nhắc hệ thống)",
|
||||
"historyTurns": "Lượt Lịch Sử",
|
||||
"historyTurnsTooltip": "Số lượt hội thoại hoàn chỉnh (cặp người dùng-trợ lý) cần xem xét trong ngữ cảnh phản hồi",
|
||||
"historyTurnsPlaceholder": "Số lượt lịch sử",
|
||||
"onlyNeedContext": "Chỉ Cần Ngữ Cảnh",
|
||||
"onlyNeedContextTooltip": "Nếu True, chỉ trả về ngữ cảnh đã truy xuất mà không tạo phản hồi",
|
||||
"onlyNeedPrompt": "Chỉ Cần Lời Nhắc",
|
||||
"onlyNeedPromptTooltip": "Nếu True, chỉ trả về lời nhắc đã tạo mà không tạo phản hồi",
|
||||
"streamResponse": "Phản Hồi Theo Luồng",
|
||||
"streamResponseTooltip": "Nếu True, bật đầu ra theo luồng cho phản hồi thời gian thực",
|
||||
"userPrompt": "Lời Nhắc Đầu Ra Bổ Sung",
|
||||
"userPromptTooltip": "Cung cấp yêu cầu phản hồi bổ sung cho LLM (không liên quan đến nội dung truy vấn, chỉ để xử lý đầu ra).",
|
||||
"userPromptPlaceholder": "Nhập lời nhắc tùy chỉnh (tùy chọn)",
|
||||
"enableRerank": "Bật Xếp Hạng Lại",
|
||||
"enableRerankTooltip": "Bật xếp hạng lại cho các đoạn văn bản đã truy xuất. Nếu True nhưng không có mô hình rerank nào được cấu hình, một cảnh báo sẽ được đưa ra. Mặc định là True."
|
||||
}
|
||||
},
|
||||
"apiSite": {
|
||||
"loading": "Đang tải tài liệu API..."
|
||||
},
|
||||
"apiKeyAlert": {
|
||||
"title": "Cần Có Khóa API",
|
||||
"description": "Vui lòng nhập khóa API để truy cập dịch vụ",
|
||||
"placeholder": "Nhập khóa API của bạn",
|
||||
"save": "Lưu"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Hiển thị {{start}} đến {{end}} của {{total}} mục",
|
||||
"page": "Trang",
|
||||
"pageSize": "Kích Thước Trang",
|
||||
"firstPage": "Trang Đầu",
|
||||
"prevPage": "Trang Trước",
|
||||
"nextPage": "Trang Tiếp",
|
||||
"lastPage": "Trang Cuối"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"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, TEXTPACK, MDX, DOCX, PDF, PPTX, XLSX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, H, HPP, 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": "选择"
|
||||
},
|
||||
"filters": {
|
||||
"all": "全部",
|
||||
"completed": "已完成",
|
||||
"parse": "内容解析",
|
||||
"analyze": "模态分析",
|
||||
"process": "抽取处理",
|
||||
"failed": "失败"
|
||||
},
|
||||
"status": {
|
||||
"all": "全部",
|
||||
"completed": "已完成",
|
||||
"preprocessed": "预处理",
|
||||
"parsing": "内容提取",
|
||||
"analyzing": "分析中",
|
||||
"processing": "处理中",
|
||||
"pending": "等待中",
|
||||
"failed": "失败"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "加载文档失败\n{{error}}",
|
||||
"scanFailed": "扫描文档失败\n{{error}}",
|
||||
"scanProgressFailed": "获取扫描进度失败\n{{error}}"
|
||||
},
|
||||
"fileNameLabel": "文件名",
|
||||
"showButton": "显示",
|
||||
"hideButton": "隐藏",
|
||||
"showFileNameTooltip": "显示文件名",
|
||||
"hideFileNameTooltip": "隐藏文件名",
|
||||
"details": {
|
||||
"title": "状态详情",
|
||||
"openTooltip": "查看状态详情",
|
||||
"content": "详情",
|
||||
"copyButton": "复制",
|
||||
"copyTooltip": "复制状态详情",
|
||||
"copySuccess": "状态详情已复制",
|
||||
"copyFailed": "复制状态详情失败"
|
||||
}
|
||||
},
|
||||
"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": "图数据已截断至最大返回节点数",
|
||||
"fetchRetriesExhausted": "加载图数据失败。请刷新后重试。",
|
||||
"graphBuildFailed": "渲染图数据失败。请刷新后重试。",
|
||||
"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": "食物",
|
||||
"table": "表格",
|
||||
"drawing": "图片",
|
||||
"equation": "公式",
|
||||
"other": "其他"
|
||||
},
|
||||
"sideBar": {
|
||||
"settings": {
|
||||
"settings": "设置",
|
||||
"healthCheck": "健康检查",
|
||||
"showPropertyPanel": "显示属性面板",
|
||||
"showSearchBar": "显示搜索栏",
|
||||
"showNodeLabel": "显示节点标签",
|
||||
"nodeDraggable": "节点可拖动",
|
||||
"showEdgeLabel": "显示边标签",
|
||||
"hideUnselectedEdges": "隐藏未选中的边",
|
||||
"edgeEvents": "边事件",
|
||||
"edgeEventsDisabledHint": "图的边数超过 {{count}} 时禁用",
|
||||
"maxQueryDepth": "最大查询深度",
|
||||
"maxNodes": "最大返回节点数",
|
||||
"resetToDefault": "重置为默认值",
|
||||
"edgeSizeRange": "边粗细范围",
|
||||
"depth": "深",
|
||||
"node": "节点",
|
||||
"edge": "边",
|
||||
"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": "服务器信息",
|
||||
"inputDirectory": "输入目录",
|
||||
"parser": "解析器",
|
||||
"mineru": "MinerU",
|
||||
"docling": "Docling",
|
||||
"otherSettings": "其它设置",
|
||||
"llmConfig": "模型配置",
|
||||
"llmBinding": "LLM绑定",
|
||||
"llmBindingHost": "LLM端点",
|
||||
"llmModel": "LLM模型",
|
||||
"embeddingConfig": "嵌入配置",
|
||||
"embeddingBinding": "嵌入绑定",
|
||||
"embeddingBindingHost": "嵌入端点",
|
||||
"embeddingModel": "嵌入模型",
|
||||
"storageConfig": "存储配置",
|
||||
"kvStorage": "KV存储",
|
||||
"docStatusStorage": "文档状态存储",
|
||||
"graphStorage": "图存储",
|
||||
"vectorStorage": "向量存储",
|
||||
"workspace": "工作空间",
|
||||
"rerankerConfig": "重排序配置",
|
||||
"rerankerBindingHost": "重排序端点",
|
||||
"rerankerModel": "重排序模型",
|
||||
"lockStatus": "锁状态"
|
||||
},
|
||||
"propertiesView": {
|
||||
"editProperty": "编辑{{property}}",
|
||||
"editLockedByPipeline": "流水线正忙,暂时无法编辑",
|
||||
"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": "发送",
|
||||
"stop": "停止",
|
||||
"userTerminated": "用户已终止,返回内容可能不完整",
|
||||
"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",
|
||||
"queryModeWarning": "可能降低检索质量",
|
||||
"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": "末页"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user