add document manager and site heaer
clean format
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import ThemeProvider from '@/components/ThemeProvider'
|
||||
import MessageAlert from '@/components/MessageAlert'
|
||||
import StatusIndicator from '@/components/StatusIndicator'
|
||||
import GraphViewer from '@/GraphViewer'
|
||||
import { healthCheckInterval } from '@/lib/constants'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useEffect } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
import SiteHeader from '@/features/SiteHeader'
|
||||
|
||||
import GraphViewer from '@/features/GraphViewer'
|
||||
import DocumentManager from '@/features/DocumentManager'
|
||||
|
||||
import { Tabs, TabsContent } from '@/components/ui/Tabs'
|
||||
|
||||
function App() {
|
||||
const message = useBackendState.use.message()
|
||||
@@ -26,11 +32,23 @@ function App() {
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<div className="h-screen w-screen">
|
||||
<GraphViewer />
|
||||
<div className="flex h-screen w-screen">
|
||||
<Tabs defaultValue="knowledge-graph" className="flex size-full flex-col">
|
||||
<SiteHeader />
|
||||
<TabsContent value="documents" className="flex-1">
|
||||
<DocumentManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="knowledge-graph" className="flex-1">
|
||||
<GraphViewer />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="size-full">
|
||||
<h1> Settings </h1>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
{enableHealthCheck && <StatusIndicator />}
|
||||
{message !== null && <MessageAlert />}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { backendBaseUrl } from '@/lib/constants'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
@@ -64,81 +65,64 @@ export type QueryResponse = {
|
||||
response: string
|
||||
}
|
||||
|
||||
export type DocumentActionResponse = {
|
||||
status: 'success' | 'partial_success' | 'failure'
|
||||
message: string
|
||||
document_count: number
|
||||
}
|
||||
|
||||
export const InvalidApiKeyError = 'Invalid API Key'
|
||||
export const RequireApiKeError = 'API Key required'
|
||||
|
||||
// Helper functions
|
||||
const getResponseContent = async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (contentType) {
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json()
|
||||
return JSON.stringify(data, undefined, 2)
|
||||
} else if (contentType.startsWith('text/')) {
|
||||
return await response.text()
|
||||
} else if (contentType.includes('application/xml') || contentType.includes('text/xml')) {
|
||||
return await response.text()
|
||||
} else if (contentType.includes('application/octet-stream')) {
|
||||
const buffer = await response.arrayBuffer()
|
||||
const decoder = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true })
|
||||
return decoder.decode(buffer)
|
||||
} else {
|
||||
try {
|
||||
return await response.text()
|
||||
} catch (error) {
|
||||
console.warn('Failed to decode as text, may be binary:', error)
|
||||
return `[Could not decode response body. Content-Type: ${contentType}]`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return await response.text()
|
||||
} catch (error) {
|
||||
console.warn('Failed to decode as text, may be binary:', error)
|
||||
return '[Could not decode response body. No Content-Type header.]'
|
||||
}
|
||||
// Axios instance
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: backendBaseUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const fetchWithAuth = async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||
// Interceptor:add api key
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const apiKey = useSettingsStore.getState().apiKey
|
||||
const headers = {
|
||||
...(options.headers || {}),
|
||||
...(apiKey ? { 'X-API-Key': apiKey } : {})
|
||||
if (apiKey) {
|
||||
config.headers['X-API-Key'] = apiKey
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
const response = await fetch(backendBaseUrl + url, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${response.status} ${response.statusText}\n${await getResponseContent(response)}\n${response.url}`
|
||||
)
|
||||
// Interceptor:hanle error
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response) {
|
||||
throw new Error(
|
||||
`${error.response.status} ${error.response.statusText}\n${JSON.stringify(
|
||||
error.response.data
|
||||
)}\n${error.config?.url}`
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
)
|
||||
|
||||
// API methods
|
||||
export const queryGraphs = async (label: string): Promise<LightragGraphType> => {
|
||||
const response = await fetchWithAuth(`/graphs?label=${label}`)
|
||||
return await response.json()
|
||||
const response = await axiosInstance.get(`/graphs?label=${label}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getGraphLabels = async (): Promise<string[]> => {
|
||||
const response = await fetchWithAuth('/graph/label/list')
|
||||
return await response.json()
|
||||
const response = await axiosInstance.get('/graph/label/list')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const checkHealth = async (): Promise<
|
||||
LightragStatus | { status: 'error'; message: string }
|
||||
> => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/health')
|
||||
return await response.json()
|
||||
const response = await axiosInstance.get('/health')
|
||||
return response.data
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'error',
|
||||
@@ -148,63 +132,33 @@ export const checkHealth = async (): Promise<
|
||||
}
|
||||
|
||||
export const getDocuments = async (): Promise<string[]> => {
|
||||
const response = await fetchWithAuth('/documents')
|
||||
return await response.json()
|
||||
const response = await axiosInstance.get('/documents')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const scanNewDocuments = async (): Promise<{ status: string }> => {
|
||||
const response = await axiosInstance.post('/documents/scan')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getDocumentsScanProgress = async (): Promise<LightragDocumentsScanProgress> => {
|
||||
const response = await fetchWithAuth('/documents/scan-progress')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const uploadDocument = async (
|
||||
file: File
|
||||
): Promise<{
|
||||
status: string
|
||||
message: string
|
||||
total_documents: number
|
||||
}> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetchWithAuth('/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const startDocumentScan = async (): Promise<{ status: string }> => {
|
||||
const response = await fetchWithAuth('/documents/scan', {
|
||||
method: 'POST'
|
||||
})
|
||||
return await response.json()
|
||||
const response = await axiosInstance.get('/documents/scan-progress')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const queryText = async (request: QueryRequest): Promise<QueryResponse> => {
|
||||
const response = await fetchWithAuth('/query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
})
|
||||
return await response.json()
|
||||
const response = await axiosInstance.post('/query', request)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const queryTextStream = async (request: QueryRequest, onChunk: (chunk: string) => void) => {
|
||||
const response = await fetchWithAuth('/query/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
const response = await axiosInstance.post('/query/stream', request, {
|
||||
responseType: 'stream'
|
||||
})
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const reader = response.data.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
@@ -226,53 +180,50 @@ export const queryTextStream = async (request: QueryRequest, onChunk: (chunk: st
|
||||
}
|
||||
}
|
||||
|
||||
// Text insertion API
|
||||
export const insertText = async (
|
||||
text: string,
|
||||
description?: string
|
||||
): Promise<{
|
||||
status: string
|
||||
message: string
|
||||
document_count: number
|
||||
}> => {
|
||||
const response = await fetchWithAuth('/documents/text', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ text, description })
|
||||
})
|
||||
return await response.json()
|
||||
): Promise<DocumentActionResponse> => {
|
||||
const response = await axiosInstance.post('/documents/text', { text, description })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Batch file upload API
|
||||
export const uploadBatchDocuments = async (
|
||||
files: File[]
|
||||
): Promise<{
|
||||
status: string
|
||||
message: string
|
||||
document_count: number
|
||||
}> => {
|
||||
export const uploadDocument = async (
|
||||
file: File,
|
||||
onUploadProgress?: (percentCompleted: number) => void
|
||||
): Promise<DocumentActionResponse> => {
|
||||
const formData = new FormData()
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetchWithAuth('/documents/batch', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
const response = await axiosInstance.post('/documents/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress:
|
||||
onUploadProgress !== undefined
|
||||
? (progressEvent) => {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total!)
|
||||
onUploadProgress(percentCompleted)
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
return await response.json()
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Clear all documents API
|
||||
export const clearDocuments = async (): Promise<{
|
||||
status: string
|
||||
message: string
|
||||
document_count: number
|
||||
}> => {
|
||||
const response = await fetchWithAuth('/documents', {
|
||||
method: 'DELETE'
|
||||
})
|
||||
return await response.json()
|
||||
export const batchUploadDocuments = async (
|
||||
files: File[],
|
||||
onUploadProgress?: (fileName: string, percentCompleted: number) => void
|
||||
): Promise<DocumentActionResponse[]> => {
|
||||
return await Promise.all(
|
||||
files.map(async (file) => {
|
||||
return await uploadDocument(file, (percentCompleted) => {
|
||||
onUploadProgress?.(file.name, percentCompleted)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const clearDocuments = async (): Promise<DocumentActionResponse> => {
|
||||
const response = await axiosInstance.delete('/documents')
|
||||
return response.data
|
||||
}
|
||||
|
@@ -22,10 +22,11 @@ const MessageAlert = () => {
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant={health ? 'default' : 'destructive'}
|
||||
// variant={health ? 'default' : 'destructive'}
|
||||
className={cn(
|
||||
'bg-background/90 absolute top-2 left-1/2 flex w-auto -translate-x-1/2 transform items-center gap-4 shadow-md backdrop-blur-lg transition-all duration-500 ease-in-out',
|
||||
isMounted ? 'translate-y-0 opacity-100' : '-translate-y-20 opacity-0'
|
||||
'bg-background/90 absolute top-12 left-1/2 flex w-auto max-w-lg -translate-x-1/2 transform items-center gap-4 shadow-md backdrop-blur-lg transition-all duration-500 ease-in-out',
|
||||
isMounted ? 'translate-y-0 opacity-100' : '-translate-y-20 opacity-0',
|
||||
!health && 'bg-red-700 text-white'
|
||||
)}
|
||||
>
|
||||
{!health && (
|
||||
@@ -42,7 +43,7 @@ const MessageAlert = () => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant={controlButtonVariant}
|
||||
className="text-primary max-h-8 border !p-2 text-xs"
|
||||
className="border-primary max-h-8 border !p-2 text-xs"
|
||||
onClick={() => useBackendState.getState().clear()}
|
||||
>
|
||||
Close
|
||||
|
@@ -200,7 +200,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600">Relationship</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={'Id'} value={edge.id} />
|
||||
<PropertyRow name={'Type'} value={edge.type} />
|
||||
{edge.type && <PropertyRow name={'Type'} value={edge.type} />}
|
||||
<PropertyRow
|
||||
name={'Source'}
|
||||
value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source}
|
||||
|
@@ -0,0 +1,52 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/Dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { clearDocuments } from '@/api/lightrag'
|
||||
|
||||
import { EraserIcon } from 'lucide-react'
|
||||
|
||||
export default function ClearDocumentsDialog() {
|
||||
const [open, setOpen] = useState(false) // 添加状态控制
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
try {
|
||||
const result = await clearDocuments()
|
||||
if (result.status === 'success') {
|
||||
toast.success('Documents cleared successfully')
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(`Clear Documents Failed:\n${result.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Clear Documents Failed:\n' + errorMessage(err))
|
||||
}
|
||||
}, [setOpen])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" tooltip="Clear documents" side="bottom" size="icon">
|
||||
<EraserIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear documents</DialogTitle>
|
||||
<DialogDescription>Do you really want to clear all documents?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Button variant="destructive" onClick={handleClear}>
|
||||
YES
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
@@ -0,0 +1,91 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
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'
|
||||
|
||||
export default function UploadDocumentsDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [progresses, setProgresses] = useState<Record<string, number>>({})
|
||||
|
||||
const handleDocumentsUpload = useCallback(
|
||||
async (filesToUpload: File[]) => {
|
||||
setIsUploading(true)
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
filesToUpload.map(async (file) => {
|
||||
try {
|
||||
const result = await uploadDocument(file, (percentCompleted: number) => {
|
||||
console.debug(`Uploading ${file.name}: ${percentCompleted}%`)
|
||||
setProgresses((pre) => ({
|
||||
...pre,
|
||||
[file.name]: percentCompleted
|
||||
}))
|
||||
})
|
||||
if (result.status === 'success') {
|
||||
toast.success(`Upload Success:\n${file.name} uploaded successfully`)
|
||||
} else {
|
||||
toast.error(`Upload Failed:\n${file.name}\n${result.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Upload Failed:\n${file.name}\n${errorMessage(err)}`)
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
toast.error('Upload Failed\n' + errorMessage(err))
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
setOpen(false)
|
||||
}
|
||||
},
|
||||
[setIsUploading, setProgresses, setOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (isUploading && !open) {
|
||||
return
|
||||
}
|
||||
setOpen(open)
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" tooltip="Upload documents" side="bottom" size="icon">
|
||||
<UploadIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload documents</DialogTitle>
|
||||
<DialogDescription>
|
||||
Drag and drop your documents here or click to browse.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FileUploader
|
||||
maxFileCount={Infinity}
|
||||
maxSize={200 * 1024 * 1024}
|
||||
description="supported types: TXT, MD, DOC, PDF, PPTX"
|
||||
onUpload={handleDocumentsUpload}
|
||||
progresses={progresses}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
@@ -193,7 +193,7 @@ export function AsyncSearch<T>({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CommandList className="max-h-auto" hidden={!open || debouncedSearchTerm.length === 0}>
|
||||
<CommandList hidden={!open || debouncedSearchTerm.length === 0}>
|
||||
{error && <div className="text-destructive p-4 text-center">{error}</div>}
|
||||
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
|
||||
{!loading &&
|
||||
|
33
lightrag_webui/src/components/ui/Badge.tsx
Normal file
33
lightrag_webui/src/components/ui/Badge.tsx
Normal file
@@ -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
|
55
lightrag_webui/src/components/ui/Card.tsx
Normal file
55
lightrag_webui/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
64
lightrag_webui/src/components/ui/DataTable.tsx
Normal file
64
lightrag_webui/src/components/ui/DataTable.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/Table'
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
'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}
|
||||
@@ -65,7 +65,7 @@ const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
@@ -77,7 +77,7 @@ const DialogTitle = React.forwardRef<
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
|
38
lightrag_webui/src/components/ui/EmptyCard.tsx
Normal file
38
lightrag_webui/src/components/ui/EmptyCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardDescription, CardTitle } from '@/components/ui/Card'
|
||||
import { FilesIcon } from 'lucide-react'
|
||||
|
||||
interface EmptyCardProps extends React.ComponentPropsWithoutRef<typeof Card> {
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
export default function EmptyCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon = FilesIcon,
|
||||
action,
|
||||
className,
|
||||
...props
|
||||
}: EmptyCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'flex w-full flex-col items-center justify-center space-y-6 bg-transparent p-16',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mr-4 shrink-0 rounded-full border border-dashed p-4">
|
||||
<Icon className="text-muted-foreground size-8" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</div>
|
||||
{action ? action : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
322
lightrag_webui/src/components/ui/FileUploader.tsx
Normal file
322
lightrag_webui/src/components/ui/FileUploader.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @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 { cn } from '@/lib/utils'
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Progress from '@/components/ui/Progress'
|
||||
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>
|
||||
|
||||
/**
|
||||
* Progress of the uploaded files.
|
||||
* @type Record<string, number> | undefined
|
||||
* @default undefined
|
||||
* @example progresses={{ "file1.png": 50 }}
|
||||
*/
|
||||
progresses?: Record<string, number>
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
value: valueProp,
|
||||
onValueChange,
|
||||
onUpload,
|
||||
progresses,
|
||||
accept = supportedFileTypes,
|
||||
maxSize = 1024 * 1024 * 200,
|
||||
maxFileCount = 1,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
description,
|
||||
className,
|
||||
...dropzoneProps
|
||||
} = props
|
||||
|
||||
const [files, setFiles] = useControllableState({
|
||||
prop: valueProp,
|
||||
onChange: onValueChange
|
||||
})
|
||||
|
||||
const onDrop = React.useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
|
||||
toast.error('Cannot upload more than 1 file at a time')
|
||||
return
|
||||
}
|
||||
|
||||
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
|
||||
toast.error(`Cannot upload more than ${maxFileCount} files`)
|
||||
return
|
||||
}
|
||||
|
||||
const newFiles = acceptedFiles.map((file) =>
|
||||
Object.assign(file, {
|
||||
preview: URL.createObjectURL(file)
|
||||
})
|
||||
)
|
||||
|
||||
const updatedFiles = files ? [...files, ...newFiles] : newFiles
|
||||
|
||||
setFiles(updatedFiles)
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
rejectedFiles.forEach(({ file }) => {
|
||||
toast.error(`File ${file.name} was rejected`)
|
||||
})
|
||||
}
|
||||
|
||||
if (onUpload && updatedFiles.length > 0 && updatedFiles.length <= maxFileCount) {
|
||||
const target = updatedFiles.length > 0 ? `${updatedFiles.length} files` : 'file'
|
||||
|
||||
toast.promise(onUpload(updatedFiles), {
|
||||
loading: `Uploading ${target}...`,
|
||||
success: () => {
|
||||
setFiles([])
|
||||
return `${target} uploaded`
|
||||
},
|
||||
error: `Failed to upload ${target}`
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
[files, maxFileCount, multiple, onUpload, setFiles]
|
||||
)
|
||||
|
||||
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}
|
||||
accept={accept}
|
||||
maxSize={maxSize}
|
||||
maxFiles={maxFileCount}
|
||||
multiple={maxFileCount > 1 || multiple}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{({ 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">Drop the files here</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">
|
||||
Drag and drop files here, or click to select files
|
||||
</p>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground/70 text-sm">{description}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground/70 text-sm">
|
||||
You can upload
|
||||
{maxFileCount > 1
|
||||
? ` ${maxFileCount === Infinity ? 'multiple' : maxFileCount}
|
||||
files (up to ${formatBytes(maxSize)} each)`
|
||||
: ` a file with ${formatBytes(maxSize)}`}
|
||||
Supported formats: TXT, MD, DOC, PDF, PPTX
|
||||
</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]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: File
|
||||
onRemove: () => void
|
||||
progress?: number
|
||||
}
|
||||
|
||||
function FileCard({ file, progress, onRemove }: FileCardProps) {
|
||||
return (
|
||||
<div className="relative flex items-center gap-2.5">
|
||||
<div className="flex flex-1 gap-2.5">
|
||||
{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>
|
||||
{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">Remove file</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
|
23
lightrag_webui/src/components/ui/Progress.tsx
Normal file
23
lightrag_webui/src/components/ui/Progress.tsx
Normal file
@@ -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
|
44
lightrag_webui/src/components/ui/ScrollArea.tsx
Normal file
44
lightrag_webui/src/components/ui/ScrollArea.tsx
Normal file
@@ -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 }
|
94
lightrag_webui/src/components/ui/Table.tsx
Normal file
94
lightrag_webui/src/components/ui/Table.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
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>
|
||||
>(({ 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>
|
||||
>(({ 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 }
|
53
lightrag_webui/src/components/ui/Tabs.tsx
Normal file
53
lightrag_webui/src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
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 mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
@@ -16,7 +16,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 mx-1 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
166
lightrag_webui/src/features/DocumentManager.tsx
Normal file
166
lightrag_webui/src/features/DocumentManager.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/Table'
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/Card'
|
||||
import Progress from '@/components/ui/Progress'
|
||||
import EmptyCard from '@/components/ui/EmptyCard'
|
||||
import UploadDocumentsDialog from '@/components/document/UploadDocumentsDialog'
|
||||
import ClearDocumentsDialog from '@/components/document/ClearDocumentsDialog'
|
||||
|
||||
import {
|
||||
getDocuments,
|
||||
getDocumentsScanProgress,
|
||||
scanNewDocuments,
|
||||
LightragDocumentsScanProgress
|
||||
} from '@/api/lightrag'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
|
||||
import { RefreshCwIcon, TrashIcon } from 'lucide-react'
|
||||
|
||||
// type DocumentStatus = 'indexed' | 'pending' | 'indexing' | 'error'
|
||||
|
||||
export default function DocumentManager() {
|
||||
const health = useBackendState.use.health()
|
||||
const [files, setFiles] = useState<string[]>([])
|
||||
const [indexedFiles, setIndexedFiles] = useState<string[]>([])
|
||||
const [scanProgress, setScanProgress] = useState<LightragDocumentsScanProgress | null>(null)
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
try {
|
||||
const docs = await getDocuments()
|
||||
setFiles(docs)
|
||||
} catch (err) {
|
||||
toast.error('Failed to load documents\n' + errorMessage(err))
|
||||
}
|
||||
}, [setFiles])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments()
|
||||
}, [])
|
||||
|
||||
const scanDocuments = useCallback(async () => {
|
||||
try {
|
||||
const { status } = await scanNewDocuments()
|
||||
toast.message(status)
|
||||
} catch (err) {
|
||||
toast.error('Failed to load documents\n' + errorMessage(err))
|
||||
}
|
||||
}, [setFiles])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
if (!health) return
|
||||
const progress = await getDocumentsScanProgress()
|
||||
setScanProgress((pre) => {
|
||||
if (pre?.is_scanning === progress.is_scanning && progress.is_scanning === false) {
|
||||
return pre
|
||||
}
|
||||
return progress
|
||||
})
|
||||
console.log(progress)
|
||||
} catch (err) {
|
||||
toast.error('Failed to get scan progress\n' + errorMessage(err))
|
||||
}
|
||||
}, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [health])
|
||||
|
||||
const handleDelete = async (fileName: string) => {
|
||||
console.log(`deleting ${fileName}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="!size-full !rounded-none !border-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Document Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
tooltip="Scan Documents"
|
||||
onClick={scanDocuments}
|
||||
side="bottom"
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<ClearDocumentsDialog />
|
||||
<UploadDocumentsDialog />
|
||||
</div>
|
||||
|
||||
{scanProgress?.is_scanning && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Indexing {scanProgress.current_file}</span>
|
||||
<span>{scanProgress.progress}%</span>
|
||||
</div>
|
||||
<Progress value={scanProgress.progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Uploaded documents</CardTitle>
|
||||
<CardDescription>view the uploaded documents here</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{files.length == 0 && (
|
||||
<EmptyCard
|
||||
title="No documents uploades"
|
||||
description="upload documents to see them here"
|
||||
/>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Filename</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => (
|
||||
<TableRow key={file}>
|
||||
<TableCell>{file}</TableCell>
|
||||
<TableCell>
|
||||
{indexedFiles.includes(file) ? (
|
||||
<span className="text-green-600">Indexed</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">Pending</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(file)}
|
||||
// disabled={isUploading}
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
@@ -10,7 +10,7 @@ import EdgeCurveProgram, { EdgeCurvedArrowProgram } from '@sigma/edge-curve'
|
||||
import FocusOnNode from '@/components/FocusOnNode'
|
||||
import LayoutsControl from '@/components/LayoutsControl'
|
||||
import GraphControl from '@/components/GraphControl'
|
||||
import ThemeToggle from '@/components/ThemeToggle'
|
||||
// import ThemeToggle from '@/components/ThemeToggle'
|
||||
import ZoomControl from '@/components/ZoomControl'
|
||||
import FullScreenControl from '@/components/FullScreenControl'
|
||||
import Settings from '@/components/Settings'
|
||||
@@ -166,7 +166,7 @@ const GraphViewer = () => {
|
||||
<ZoomControl />
|
||||
<LayoutsControl />
|
||||
<FullScreenControl />
|
||||
<ThemeToggle />
|
||||
{/* <ThemeToggle /> */}
|
||||
</div>
|
||||
|
||||
{showPropertyPanel && (
|
51
lightrag_webui/src/features/SiteHeader.tsx
Normal file
51
lightrag_webui/src/features/SiteHeader.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import { SiteInfo } from '@/lib/constants'
|
||||
import ThemeToggle from '@/components/ThemeToggle'
|
||||
import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
|
||||
|
||||
import { ZapIcon, GithubIcon } from 'lucide-react'
|
||||
|
||||
export default function SiteHeader() {
|
||||
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">
|
||||
<a href="/" className="mr-6 flex items-center gap-2">
|
||||
<ZapIcon className="size-4 text-teal-400" aria-hidden="true" />
|
||||
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
|
||||
</a>
|
||||
|
||||
<div className="flex h-10 flex-1 justify-center">
|
||||
<div className="flex h-8 self-center">
|
||||
<TabsList className="h-full gap-2">
|
||||
<TabsTrigger
|
||||
value="documents"
|
||||
className="hover:bg-background/60 cursor-pointer px-2 py-1 transition-all"
|
||||
>
|
||||
Documents
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="knowledge-graph"
|
||||
className="hover:bg-background/60 cursor-pointer px-2 py-1 transition-all"
|
||||
>
|
||||
Knowledge Graph
|
||||
</TabsTrigger>
|
||||
{/* <TabsTrigger
|
||||
value="settings"
|
||||
className="hover:bg-background/60 cursor-pointer px-2 py-1 transition-all"
|
||||
>
|
||||
Settings
|
||||
</TabsTrigger> */}
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center">
|
||||
<Button variant="ghost" size="icon">
|
||||
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
|
||||
<GithubIcon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
@@ -24,7 +24,7 @@ const validateGraph = (graph: RawGraph) => {
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
if (!edge.id || !edge.source || !edge.target || !edge.type || !edge.properties) {
|
||||
if (!edge.id || !edge.source || !edge.target) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,14 @@ const fetchGraph = async (label: string) => {
|
||||
if (source !== undefined && source !== undefined) {
|
||||
const sourceNode = rawData.nodes[source]
|
||||
const targetNode = rawData.nodes[target]
|
||||
if (!sourceNode) {
|
||||
console.error(`Source node ${edge.source} is undefined`)
|
||||
continue
|
||||
}
|
||||
if (!targetNode) {
|
||||
console.error(`Target node ${edge.target} is undefined`)
|
||||
continue
|
||||
}
|
||||
sourceNode.degree += 1
|
||||
targetNode.degree += 1
|
||||
}
|
||||
@@ -146,7 +154,7 @@ const createSigmaGraph = (rawGraph: RawGraph | null) => {
|
||||
|
||||
for (const rawEdge of rawGraph?.edges ?? []) {
|
||||
rawEdge.dynamicId = graph.addDirectedEdge(rawEdge.source, rawEdge.target, {
|
||||
label: rawEdge.type
|
||||
label: rawEdge.type || undefined
|
||||
})
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin 'tailwindcss-animate';
|
||||
@plugin 'tailwind-scrollbar';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -142,3 +143,27 @@
|
||||
@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%);
|
||||
}
|
||||
}
|
||||
|
@@ -23,3 +23,18 @@ 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'],
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/msword': ['.doc'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx']
|
||||
}
|
||||
|
||||
export const SiteInfo = {
|
||||
name: 'LightRAG',
|
||||
home: '/',
|
||||
github: 'https://github.com/HKUDS/LightRAG'
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@ export type RawEdgeType = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
type: string
|
||||
type?: string
|
||||
properties: Record<string, any>
|
||||
|
||||
dynamicId: string
|
||||
|
Reference in New Issue
Block a user