feat: add Table Explorer feature with dynamic table data fetching and schema display
- Implemented Table Explorer component to allow users to select and view database tables. - Added API calls for fetching table list, schema, and paginated data. - Introduced row detail modal for displaying and copying row data. - Enhanced DataTable component to support row click events. - Updated UI components for better user experience and accessibility. - Added mock data for development mode to facilitate testing. - Updated localization files to include new terms related to tables. - Modified settings store to include storage configuration for conditional UI rendering. - Improved styling and layout for various components to align with new design standards.
This commit is contained in:
parent
48c7732edc
commit
a9edadef45
18 changed files with 809 additions and 148 deletions
|
|
@ -2,8 +2,6 @@
|
||||||
LightRAG FastAPI Server
|
LightRAG FastAPI Server
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Request
|
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
@ -53,6 +51,7 @@ from lightrag.api.routers.document_routes import (
|
||||||
)
|
)
|
||||||
from lightrag.api.routers.query_routes import create_query_routes
|
from lightrag.api.routers.query_routes import create_query_routes
|
||||||
from lightrag.api.routers.graph_routes import create_graph_routes
|
from lightrag.api.routers.graph_routes import create_graph_routes
|
||||||
|
from lightrag.api.routers.table_routes import create_table_routes
|
||||||
from lightrag.api.routers.ollama_api import OllamaAPI
|
from lightrag.api.routers.ollama_api import OllamaAPI
|
||||||
|
|
||||||
from lightrag.utils import logger, set_verbose_debug
|
from lightrag.utils import logger, set_verbose_debug
|
||||||
|
|
@ -644,7 +643,7 @@ def create_app(args):
|
||||||
raise Exception(f"Failed to import {binding} options: {e}")
|
raise Exception(f"Failed to import {binding} options: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def create_entity_resolution_config(args) -> object | None:
|
def create_entity_resolution_config(args) -> "EntityResolutionConfig | None":
|
||||||
"""
|
"""
|
||||||
Create EntityResolutionConfig from command line/env arguments.
|
Create EntityResolutionConfig from command line/env arguments.
|
||||||
Returns None if entity resolution is disabled.
|
Returns None if entity resolution is disabled.
|
||||||
|
|
@ -1065,6 +1064,13 @@ def create_app(args):
|
||||||
app.include_router(create_query_routes(rag, api_key, args.top_k))
|
app.include_router(create_query_routes(rag, api_key, args.top_k))
|
||||||
app.include_router(create_graph_routes(rag, api_key))
|
app.include_router(create_graph_routes(rag, api_key))
|
||||||
|
|
||||||
|
# Register table routes if all storages are PostgreSQL
|
||||||
|
if (args.kv_storage == "PGKVStorage" and
|
||||||
|
args.doc_status_storage == "PGDocStatusStorage" and
|
||||||
|
args.graph_storage == "PGGraphStorage" and
|
||||||
|
args.vector_storage == "PGVectorStorage"):
|
||||||
|
app.include_router(create_table_routes(rag, api_key), prefix="/tables")
|
||||||
|
|
||||||
# Add Ollama API routes
|
# Add Ollama API routes
|
||||||
ollama_api = OllamaAPI(rag, top_k=args.top_k, api_key=api_key)
|
ollama_api = OllamaAPI(rag, top_k=args.top_k, api_key=api_key)
|
||||||
app.include_router(ollama_api.router, prefix="/api")
|
app.include_router(ollama_api.router, prefix="/api")
|
||||||
|
|
|
||||||
106
lightrag/api/routers/table_routes.py
Normal file
106
lightrag/api/routers/table_routes.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
from typing import Optional, Any
|
||||||
|
import traceback
|
||||||
|
import re
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Query
|
||||||
|
from lightrag import LightRAG
|
||||||
|
from lightrag.api.utils_api import get_combined_auth_dependency
|
||||||
|
from lightrag.kg.postgres_impl import TABLES
|
||||||
|
from lightrag.utils import logger
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Tables"])
|
||||||
|
|
||||||
|
def get_order_clause(ddl: str) -> str:
|
||||||
|
"""Determine the best ORDER BY clause based on available columns in DDL."""
|
||||||
|
ddl_lower = ddl.lower()
|
||||||
|
if "update_time" in ddl_lower:
|
||||||
|
return "ORDER BY update_time DESC"
|
||||||
|
elif "updated_at" in ddl_lower:
|
||||||
|
return "ORDER BY updated_at DESC"
|
||||||
|
elif "create_time" in ddl_lower:
|
||||||
|
return "ORDER BY create_time DESC"
|
||||||
|
elif "created_at" in ddl_lower:
|
||||||
|
return "ORDER BY created_at DESC"
|
||||||
|
elif "id" in ddl_lower:
|
||||||
|
return "ORDER BY id ASC"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def create_table_routes(rag: LightRAG, api_key: Optional[str] = None) -> APIRouter:
|
||||||
|
combined_auth = get_combined_auth_dependency(api_key)
|
||||||
|
|
||||||
|
def get_workspace_from_request(request: Request) -> Optional[str]:
|
||||||
|
workspace = request.headers.get("LIGHTRAG-WORKSPACE", "").strip()
|
||||||
|
return workspace if workspace else None
|
||||||
|
|
||||||
|
@router.get("/list", dependencies=[Depends(combined_auth)])
|
||||||
|
async def list_tables() -> list[str]:
|
||||||
|
"""List all available LightRAG tables."""
|
||||||
|
try:
|
||||||
|
return list(TABLES.keys())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing tables: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error listing tables: {str(e)}") from e
|
||||||
|
|
||||||
|
@router.get("/{table_name}/schema", dependencies=[Depends(combined_auth)])
|
||||||
|
async def get_table_schema(table_name: str) -> dict[str, Any]:
|
||||||
|
"""Get DDL/schema for a specific table."""
|
||||||
|
if table_name not in TABLES:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Table {table_name} not found")
|
||||||
|
return TABLES[table_name]
|
||||||
|
|
||||||
|
@router.get("/{table_name}/data", dependencies=[Depends(combined_auth)])
|
||||||
|
async def get_table_data(
|
||||||
|
request: Request,
|
||||||
|
table_name: str,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
workspace: Optional[str] = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get paginated data from a table."""
|
||||||
|
# Strict validation: table name must be alphanumeric + underscores only
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_]+$', table_name):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid table name format")
|
||||||
|
|
||||||
|
if table_name not in TABLES:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Table {table_name} not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
req_workspace = get_workspace_from_request(request)
|
||||||
|
# Priority: query param > header > rag default > "default"
|
||||||
|
target_workspace = workspace or req_workspace or rag.workspace or "default"
|
||||||
|
|
||||||
|
# Access the database connection from an initialized storage
|
||||||
|
# full_docs is a KV storage that has the db connection when using PostgreSQL
|
||||||
|
if not hasattr(rag.full_docs, 'db') or rag.full_docs.db is None:
|
||||||
|
raise HTTPException(status_code=500, detail="PostgreSQL storage not available")
|
||||||
|
|
||||||
|
db = rag.full_docs.db
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
# 1. Get total count
|
||||||
|
count_sql = f"SELECT COUNT(*) as count FROM {table_name} WHERE workspace = $1"
|
||||||
|
count_res = await db.query(count_sql, [target_workspace])
|
||||||
|
total = count_res['count'] if count_res else 0
|
||||||
|
|
||||||
|
# 2. Get data
|
||||||
|
# Try to determine order column
|
||||||
|
ddl = TABLES[table_name]['ddl']
|
||||||
|
order_clause = get_order_clause(ddl)
|
||||||
|
|
||||||
|
sql = f"SELECT * FROM {table_name} WHERE workspace = $1 {order_clause} LIMIT $2 OFFSET $3"
|
||||||
|
rows = await db.query(sql, [target_workspace, page_size, offset], multirows=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": rows or [],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching table data for {table_name}: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") from e
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"@react-sigma/minimap": "^5.0.5",
|
"@react-sigma/minimap": "^5.0.5",
|
||||||
"@sigma/edge-curve": "^3.1.0",
|
"@sigma/edge-curve": "^3.1.0",
|
||||||
"@sigma/node-border": "^3.0.0",
|
"@sigma/node-border": "^3.0.0",
|
||||||
|
"@tanstack/react-query": "^5.60.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
@ -476,6 +477,10 @@
|
||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="],
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="],
|
||||||
|
|
||||||
|
"@tanstack/query-core": ["@tanstack/query-core@5.90.11", "", {}, "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A=="],
|
||||||
|
|
||||||
|
"@tanstack/react-query": ["@tanstack/react-query@5.90.11", "", { "dependencies": { "@tanstack/query-core": "5.90.11" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA=="],
|
||||||
|
|
||||||
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||||
|
|
||||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
"@react-sigma/minimap": "^5.0.5",
|
"@react-sigma/minimap": "^5.0.5",
|
||||||
"@sigma/edge-curve": "^3.1.0",
|
"@sigma/edge-curve": "^3.1.0",
|
||||||
"@sigma/node-border": "^3.0.0",
|
"@sigma/node-border": "^3.0.0",
|
||||||
|
"@tanstack/react-query": "^5.60.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import GraphViewer from '@/features/GraphViewer'
|
||||||
import DocumentManager from '@/features/DocumentManager'
|
import DocumentManager from '@/features/DocumentManager'
|
||||||
import RetrievalTesting from '@/features/RetrievalTesting'
|
import RetrievalTesting from '@/features/RetrievalTesting'
|
||||||
import ApiSite from '@/features/ApiSite'
|
import ApiSite from '@/features/ApiSite'
|
||||||
|
import TableExplorer from '@/features/TableExplorer'
|
||||||
|
|
||||||
import { Tabs, TabsContent } from '@/components/ui/Tabs'
|
import { Tabs, TabsContent } from '@/components/ui/Tabs'
|
||||||
|
|
||||||
|
|
@ -60,7 +61,10 @@ function App() {
|
||||||
try {
|
try {
|
||||||
// Only perform health check if component is still mounted
|
// Only perform health check if component is still mounted
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
await useBackendState.getState().check();
|
const status = await useBackendState.getState().check();
|
||||||
|
if (status && 'status' in status && status.status === 'healthy' && status.configuration) {
|
||||||
|
useSettingsStore.getState().setStorageConfig(status.configuration);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Health check error:', error);
|
console.error('Health check error:', error);
|
||||||
|
|
@ -171,7 +175,7 @@ function App() {
|
||||||
<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">
|
<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">
|
<div className="min-w-[200px] w-auto flex items-center">
|
||||||
<a href={webuiPrefix} className="flex items-center gap-2">
|
<a href={webuiPrefix} className="flex items-center gap-2">
|
||||||
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
|
<ZapIcon className="size-4 text-plum" aria-hidden="true" />
|
||||||
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
|
<span className="font-bold md:inline-block">{SiteInfo.name}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -215,6 +219,9 @@ function App() {
|
||||||
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
<TabsContent value="api" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||||
<ApiSite />
|
<ApiSite />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="table-explorer" className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||||
|
<TableExplorer />
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} />
|
<ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import '@/lib/extensions'; // Import all global extensions
|
import '@/lib/extensions'; // Import all global extensions
|
||||||
import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'
|
import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { useAuthStore } from '@/stores/state'
|
import { useAuthStore } from '@/stores/state'
|
||||||
import { navigationService } from '@/services/navigation'
|
import { navigationService } from '@/services/navigation'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
|
|
@ -8,6 +9,17 @@ import App from './App'
|
||||||
import LoginPage from '@/features/LoginPage'
|
import LoginPage from '@/features/LoginPage'
|
||||||
import ThemeProvider from '@/components/ThemeProvider'
|
import ThemeProvider from '@/components/ThemeProvider'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const AppContent = () => {
|
const AppContent = () => {
|
||||||
const [initializing, setInitializing] = useState(true)
|
const [initializing, setInitializing] = useState(true)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
|
@ -78,17 +90,19 @@ const AppContent = () => {
|
||||||
|
|
||||||
const AppRouter = () => {
|
const AppRouter = () => {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Router>
|
<ThemeProvider>
|
||||||
<AppContent />
|
<Router>
|
||||||
<Toaster
|
<AppContent />
|
||||||
position="bottom-center"
|
<Toaster
|
||||||
theme="system"
|
position="bottom-center"
|
||||||
closeButton
|
theme="system"
|
||||||
richColors
|
closeButton
|
||||||
/>
|
richColors
|
||||||
</Router>
|
/>
|
||||||
</ThemeProvider>
|
</Router>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -815,3 +815,117 @@ export const getDocumentStatusCounts = async (): Promise<StatusCountsResponse> =
|
||||||
const response = await axiosInstance.get('/documents/status_counts')
|
const response = await axiosInstance.get('/documents/status_counts')
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TableSchema = {
|
||||||
|
ddl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TableDataResponse = {
|
||||||
|
data: Record<string, any>[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total_pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock data for dev mode
|
||||||
|
const mockTables = [
|
||||||
|
'lightrag_doc_status',
|
||||||
|
'lightrag_doc_chunks',
|
||||||
|
'lightrag_entities',
|
||||||
|
'lightrag_relations',
|
||||||
|
'lightrag_entity_aliases',
|
||||||
|
'lightrag_llm_cache'
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockSchemas: Record<string, string> = {
|
||||||
|
'lightrag_doc_status': `CREATE TABLE lightrag_doc_status (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
workspace VARCHAR(255) NOT NULL,
|
||||||
|
content_summary TEXT,
|
||||||
|
content_length INTEGER,
|
||||||
|
status VARCHAR(50),
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);`,
|
||||||
|
'lightrag_entities': `CREATE TABLE lightrag_entities (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
workspace VARCHAR(255) NOT NULL,
|
||||||
|
entity_name VARCHAR(500) NOT NULL,
|
||||||
|
entity_type VARCHAR(100),
|
||||||
|
description TEXT,
|
||||||
|
source_chunk_id VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);`,
|
||||||
|
'lightrag_entity_aliases': `CREATE TABLE lightrag_entity_aliases (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
workspace VARCHAR(255) NOT NULL,
|
||||||
|
alias VARCHAR(500) NOT NULL,
|
||||||
|
canonical_entity VARCHAR(500) NOT NULL,
|
||||||
|
method VARCHAR(50),
|
||||||
|
confidence FLOAT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockTableData: Record<string, any[]> = {
|
||||||
|
'lightrag_doc_status': [
|
||||||
|
{ id: 'doc_001', workspace: 'default', content_summary: 'Research paper on AI...', content_length: 15420, status: 'processed', created_at: '2024-01-15T10:30:00Z' },
|
||||||
|
{ id: 'doc_002', workspace: 'default', content_summary: 'Technical documentation...', content_length: 8750, status: 'processed', created_at: '2024-01-16T14:22:00Z' },
|
||||||
|
{ id: 'doc_003', workspace: 'default', content_summary: 'Meeting notes from Q4...', content_length: 3200, status: 'pending', created_at: '2024-01-17T09:15:00Z' },
|
||||||
|
],
|
||||||
|
'lightrag_entities': [
|
||||||
|
{ id: 1, workspace: 'default', entity_name: 'OpenAI', entity_type: 'Organization', description: 'AI research company', created_at: '2024-01-15T10:30:00Z' },
|
||||||
|
{ id: 2, workspace: 'default', entity_name: 'GPT-4', entity_type: 'Product', description: 'Large language model', created_at: '2024-01-15T10:31:00Z' },
|
||||||
|
{ id: 3, workspace: 'default', entity_name: 'San Francisco', entity_type: 'Location', description: 'City in California', created_at: '2024-01-15T10:32:00Z' },
|
||||||
|
],
|
||||||
|
'lightrag_entity_aliases': [
|
||||||
|
{ id: 1, workspace: 'default', alias: 'openai', canonical_entity: 'OpenAI', method: 'exact', confidence: 1.0, created_at: '2024-01-15T10:30:00Z' },
|
||||||
|
{ id: 2, workspace: 'default', alias: 'gpt4', canonical_entity: 'GPT-4', method: 'fuzzy', confidence: 0.92, created_at: '2024-01-15T10:31:00Z' },
|
||||||
|
{ id: 3, workspace: 'default', alias: 'SF', canonical_entity: 'San Francisco', method: 'llm', confidence: 0.85, created_at: '2024-01-15T10:32:00Z' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTableList = async (): Promise<string[]> => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return mockTables
|
||||||
|
}
|
||||||
|
const response = await axiosInstance.get('/tables/list')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTableSchema = async (tableName: string): Promise<TableSchema> => {
|
||||||
|
if (!tableName || typeof tableName !== 'string') {
|
||||||
|
throw new Error('Invalid table name')
|
||||||
|
}
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return { ddl: mockSchemas[tableName] || `-- Schema not available for ${tableName}` }
|
||||||
|
}
|
||||||
|
const response = await axiosInstance.get(`/tables/${encodeURIComponent(tableName)}/schema`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTableData = async (tableName: string, page: number, pageSize: number): Promise<TableDataResponse> => {
|
||||||
|
if (!tableName || typeof tableName !== 'string') {
|
||||||
|
throw new Error('Invalid table name')
|
||||||
|
}
|
||||||
|
if (page < 1 || pageSize < 1 || pageSize > 1000) {
|
||||||
|
throw new Error('Page must be >= 1 and page size must be between 1 and 1000')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const data = mockTableData[tableName] || []
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
const end = start + pageSize
|
||||||
|
const paginatedData = data.slice(start, end)
|
||||||
|
return {
|
||||||
|
data: paginatedData,
|
||||||
|
total: data.length,
|
||||||
|
page: page,
|
||||||
|
page_size: pageSize,
|
||||||
|
total_pages: Math.ceil(data.length / pageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await axiosInstance.get(`/tables/${encodeURIComponent(tableName)}/data`, { params: { page, page_size: pageSize } })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="text-md pl-1 font-bold tracking-wide text-violet-700">{t('graphPanel.propertiesView.edge.title')}</h3>
|
<h3 className="text-md pl-1 font-bold tracking-wide text-plum">{t('graphPanel.propertiesView.edge.title')}</h3>
|
||||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||||
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
|
<PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
|
||||||
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
|
{edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ import {
|
||||||
interface DataTableProps<TData, TValue> {
|
interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[]
|
columns: ColumnDef<TData, TValue>[]
|
||||||
data: TData[]
|
data: TData[]
|
||||||
|
onRowClick?: (row: TData) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
export default function DataTable<TData, TValue>({ columns, data, onRowClick }: DataTableProps<TData, TValue>) {
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
|
|
@ -42,7 +43,12 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
onClick={() => onRowClick?.(row.original)}
|
||||||
|
className={onRowClick ? 'cursor-pointer hover:bg-muted/50' : ''}
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,23 @@ export default function EmptyCard({
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full flex-col items-center justify-center space-y-6 bg-transparent p-16',
|
'flex w-full h-full flex-col items-center justify-center space-y-6 bg-transparent p-16 border-none shadow-none',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="mr-4 shrink-0 rounded-full border border-dashed p-4">
|
<div className="shrink-0 rounded-2xl bg-gradient-to-br from-muted/80 to-muted/40 p-6 ring-1 ring-border/50">
|
||||||
<Icon className="text-muted-foreground size-8" aria-hidden="true" />
|
<Icon className="text-muted-foreground/70 size-10" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
<div className="flex flex-col items-center gap-2 text-center max-w-sm">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
{description ? (
|
||||||
|
<CardDescription className="text-sm text-muted-foreground/80">
|
||||||
|
{description}
|
||||||
|
</CardDescription>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{action ? action : null}
|
{action ? <div className="mt-2">{action}</div> : null}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { errorMessage } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useBackendState } from '@/stores/state'
|
import { useBackendState } from '@/stores/state'
|
||||||
|
|
||||||
import { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, RotateCcwIcon, CheckSquareIcon, XIcon, AlertTriangle, Info } from 'lucide-react'
|
import { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, RotateCcwIcon, CheckSquareIcon, XIcon, AlertTriangle, Info, FileText, CheckCircle2, Loader2, Clock, AlertCircle, Brain, Shell } from 'lucide-react'
|
||||||
import PipelineStatusDialog from '@/components/documents/PipelineStatusDialog'
|
import PipelineStatusDialog from '@/components/documents/PipelineStatusDialog'
|
||||||
|
|
||||||
type StatusFilter = DocStatus | 'all';
|
type StatusFilter = DocStatus | 'all';
|
||||||
|
|
@ -477,6 +477,52 @@ export default function DocumentManager() {
|
||||||
const pendingCount = getCountValue(statusCounts, 'PENDING', 'pending') || documentCounts.pending || 0;
|
const pendingCount = getCountValue(statusCounts, 'PENDING', 'pending') || documentCounts.pending || 0;
|
||||||
const failedCount = getCountValue(statusCounts, 'FAILED', 'failed') || documentCounts.failed || 0;
|
const failedCount = getCountValue(statusCounts, 'FAILED', 'failed') || documentCounts.failed || 0;
|
||||||
|
|
||||||
|
// Stats items configuration for modern dashboard
|
||||||
|
const statItems = useMemo(() => [
|
||||||
|
{
|
||||||
|
id: 'all' as StatusFilter,
|
||||||
|
label: t('documentPanel.documentManager.status.all'),
|
||||||
|
icon: FileText,
|
||||||
|
count: statusCounts.all || documentCounts.all || 0,
|
||||||
|
color: "text-muted-foreground",
|
||||||
|
activeColor: "text-foreground"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'processed' as StatusFilter,
|
||||||
|
label: t('documentPanel.documentManager.status.completed'),
|
||||||
|
icon: CheckCircle2,
|
||||||
|
count: processedCount,
|
||||||
|
color: "text-emerald-500",
|
||||||
|
activeColor: "text-emerald-600 dark:text-emerald-500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'processing' as StatusFilter,
|
||||||
|
label: t('documentPanel.documentManager.status.processing'),
|
||||||
|
icon: Loader2,
|
||||||
|
count: processingCount,
|
||||||
|
color: "text-blue-500",
|
||||||
|
activeColor: "text-blue-600 dark:text-blue-500",
|
||||||
|
spin: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pending' as StatusFilter,
|
||||||
|
label: t('documentPanel.documentManager.status.pending'),
|
||||||
|
icon: Loader2,
|
||||||
|
count: pendingCount,
|
||||||
|
color: "text-amber-500",
|
||||||
|
activeColor: "text-amber-600 dark:text-amber-500",
|
||||||
|
spin: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failed' as StatusFilter,
|
||||||
|
label: t('documentPanel.documentManager.status.failed'),
|
||||||
|
icon: AlertCircle,
|
||||||
|
count: failedCount,
|
||||||
|
color: "text-red-500",
|
||||||
|
activeColor: "text-red-600 dark:text-red-500"
|
||||||
|
}
|
||||||
|
], [t, statusCounts, documentCounts, processedCount, processingCount, pendingCount, failedCount]);
|
||||||
|
|
||||||
// Store previous status counts
|
// Store previous status counts
|
||||||
const prevStatusCounts = useRef({
|
const prevStatusCounts = useRef({
|
||||||
processed: 0,
|
processed: 0,
|
||||||
|
|
@ -1114,10 +1160,50 @@ export default function DocumentManager() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="!rounded-none !overflow-hidden flex flex-col h-full min-h-0">
|
<Card className="!rounded-none !overflow-hidden flex flex-col h-full min-h-0">
|
||||||
<CardHeader className="py-2 px-6">
|
<CardHeader className="py-4 px-6 border-b border-border/50">
|
||||||
<CardTitle className="text-lg">{t('documentPanel.documentManager.title')}</CardTitle>
|
<CardTitle className="text-xl font-semibold tracking-tight">{t('documentPanel.documentManager.title')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 flex flex-col min-h-0 overflow-auto">
|
<CardContent className="flex-1 flex flex-col min-h-0 overflow-auto">
|
||||||
|
{/* Modern Interactive Stats Bar */}
|
||||||
|
<div className="bg-muted/20 p-2 rounded-xl flex flex-wrap gap-2 mb-6">
|
||||||
|
{statItems.map((item) => {
|
||||||
|
const isActive = statusFilter === item.id;
|
||||||
|
const Icon = item.icon;
|
||||||
|
const showIcon = item.count > 0 || item.id === 'all';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleStatusFilterChange(item.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 min-w-[140px] flex flex-col gap-1 p-3 rounded-lg transition-all cursor-pointer border select-none",
|
||||||
|
isActive
|
||||||
|
? "bg-background shadow-sm border-border ring-1 ring-black/5 dark:ring-white/5"
|
||||||
|
: "bg-background/80 border-border/40 hover:bg-background hover:border-border/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={cn("text-xs font-medium", isActive ? "text-foreground" : "text-muted-foreground")}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{showIcon && (
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
isActive ? item.activeColor : item.color,
|
||||||
|
item.spin && item.count > 0 && "animate-spin"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={cn("text-2xl font-bold", isActive ? "text-foreground" : "text-muted-foreground/80")}>
|
||||||
|
{item.count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center gap-2 mb-2">
|
<div className="flex justify-between items-center gap-2 mb-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1139,7 +1225,7 @@ export default function DocumentManager() {
|
||||||
pipelineBusy && 'pipeline-busy'
|
pipelineBusy && 'pipeline-busy'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ActivityIcon /> {t('documentPanel.documentManager.pipelineStatusButton')}
|
<Shell className="h-4 w-4" /> {t('documentPanel.documentManager.pipelineStatusButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1157,7 +1243,27 @@ export default function DocumentManager() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowFileName(!showFileName)}
|
||||||
|
className={cn(
|
||||||
|
"transition-all border",
|
||||||
|
showFileName
|
||||||
|
? "bg-primary text-primary-foreground hover:bg-primary/90 border-primary shadow-sm"
|
||||||
|
: "text-muted-foreground border-dashed border-border/60 hover:border-solid hover:text-foreground hover:bg-accent/50"
|
||||||
|
)}
|
||||||
|
side="bottom"
|
||||||
|
tooltip={showFileName ? t('documentPanel.documentManager.hideButton') : t('documentPanel.documentManager.showButton')}
|
||||||
|
>
|
||||||
|
{showFileName ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{t('documentPanel.documentManager.columns.fileName')}
|
||||||
|
</Button>
|
||||||
{isSelectionMode && (
|
{isSelectionMode && (
|
||||||
<DeleteDocumentsDialog
|
<DeleteDocumentsDialog
|
||||||
selectedDocIds={selectedDocIds}
|
selectedDocIds={selectedDocIds}
|
||||||
|
|
@ -1196,111 +1302,6 @@ export default function DocumentManager() {
|
||||||
<CardHeader className="flex-none py-2 px-4">
|
<CardHeader className="flex-none py-2 px-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<CardTitle>{t('documentPanel.documentManager.uploadedTitle')}</CardTitle>
|
<CardTitle>{t('documentPanel.documentManager.uploadedTitle')}</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex gap-1" dir={i18n.dir()}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={statusFilter === 'all' ? 'secondary' : 'outline'}
|
|
||||||
onClick={() => handleStatusFilterChange('all')}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
className={cn(
|
|
||||||
statusFilter === 'all' && 'bg-gray-100 dark:bg-gray-900 font-medium border border-gray-400 dark:border-gray-500 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t('documentPanel.documentManager.status.all')} ({statusCounts.all || documentCounts.all})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={statusFilter === 'processed' ? 'secondary' : 'outline'}
|
|
||||||
onClick={() => handleStatusFilterChange('processed')}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
className={cn(
|
|
||||||
processedCount > 0 ? 'text-green-600' : 'text-gray-500',
|
|
||||||
statusFilter === 'processed' && 'bg-green-100 dark:bg-green-900/30 font-medium border border-green-400 dark:border-green-600 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t('documentPanel.documentManager.status.completed')} ({processedCount})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={statusFilter === 'preprocessed' ? 'secondary' : 'outline'}
|
|
||||||
onClick={() => handleStatusFilterChange('preprocessed')}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
className={cn(
|
|
||||||
preprocessedCount > 0 ? 'text-purple-600' : 'text-gray-500',
|
|
||||||
statusFilter === 'preprocessed' && 'bg-purple-100 dark:bg-purple-900/30 font-medium border border-purple-400 dark:border-purple-600 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t('documentPanel.documentManager.status.preprocessed')} ({preprocessedCount})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={statusFilter === 'processing' ? 'secondary' : 'outline'}
|
|
||||||
onClick={() => handleStatusFilterChange('processing')}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
className={cn(
|
|
||||||
processingCount > 0 ? 'text-blue-600' : 'text-gray-500',
|
|
||||||
statusFilter === 'processing' && 'bg-blue-100 dark:bg-blue-900/30 font-medium border border-blue-400 dark:border-blue-600 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t('documentPanel.documentManager.status.processing')} ({processingCount})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={statusFilter === 'pending' ? 'secondary' : 'outline'}
|
|
||||||
onClick={() => handleStatusFilterChange('pending')}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
className={cn(
|
|
||||||
pendingCount > 0 ? 'text-yellow-600' : 'text-gray-500',
|
|
||||||
statusFilter === 'pending' && 'bg-yellow-100 dark:bg-yellow-900/30 font-medium border border-yellow-400 dark:border-yellow-600 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t('documentPanel.documentManager.status.pending')} ({pendingCount})
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={statusFilter === 'failed' ? 'secondary' : 'outline'}
|
|
||||||
onClick={() => handleStatusFilterChange('failed')}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
className={cn(
|
|
||||||
failedCount > 0 ? 'text-red-600' : 'text-gray-500',
|
|
||||||
statusFilter === 'failed' && 'bg-red-100 dark:bg-red-900/30 font-medium border border-red-400 dark:border-red-600 shadow-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t('documentPanel.documentManager.status.failed')} ({failedCount})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleManualRefresh}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
side="bottom"
|
|
||||||
tooltip={t('documentPanel.documentManager.refreshTooltip')}
|
|
||||||
>
|
|
||||||
<RotateCcwIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label
|
|
||||||
htmlFor="toggle-filename-btn"
|
|
||||||
className="text-sm text-gray-500"
|
|
||||||
>
|
|
||||||
{t('documentPanel.documentManager.fileNameLabel')}
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
id="toggle-filename-btn"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowFileName(!showFileName)}
|
|
||||||
className="border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
{showFileName
|
|
||||||
? t('documentPanel.documentManager.hideButton')
|
|
||||||
: t('documentPanel.documentManager.showButton')
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CardDescription aria-hidden="true" className="hidden">{t('documentPanel.documentManager.uploadedDescription')}</CardDescription>
|
<CardDescription aria-hidden="true" className="hidden">{t('documentPanel.documentManager.uploadedDescription')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ const LoginPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="flex h-screen w-screen items-center justify-center bg-gradient-to-br from-fuchsia-50 to-purple-100 dark:from-gray-900 dark:to-gray-800">
|
||||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
<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" />
|
<AppSettings className="bg-white/30 dark:bg-gray-800/30 backdrop-blur-sm rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,7 +154,7 @@ const LoginPage = () => {
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img src="logo.svg" alt="LightRAG Logo" className="h-12 w-12" />
|
<img src="logo.svg" alt="LightRAG Logo" className="h-12 w-12" />
|
||||||
<ZapIcon className="size-10 text-emerald-400" aria-hidden="true" />
|
<ZapIcon className="size-10 text-plum" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">LightRAG</h1>
|
<h1 className="text-3xl font-bold tracking-tight">LightRAG</h1>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useAuthStore } from '@/stores/state'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { navigationService } from '@/services/navigation'
|
import { navigationService } from '@/services/navigation'
|
||||||
import { ZapIcon, LogOutIcon } from 'lucide-react'
|
import { ZapIcon, LogOutIcon, BrainIcon } from 'lucide-react'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
|
||||||
|
|
||||||
interface NavigationTabProps {
|
interface NavigationTabProps {
|
||||||
|
|
@ -23,7 +23,7 @@ function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
|
||||||
value={value}
|
value={value}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer px-2 py-1 transition-all',
|
'cursor-pointer px-2 py-1 transition-all',
|
||||||
currentTab === value ? '!bg-emerald-400 !text-zinc-50' : 'hover:bg-background/60'
|
currentTab === value ? '!bg-plum !text-plum-foreground' : 'hover:bg-background/60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -31,8 +31,24 @@ function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldShowTableExplorer(storageConfig: any) {
|
||||||
|
// Always show for now - TODO: fix storageConfig state propagation from health check
|
||||||
|
return true
|
||||||
|
|
||||||
|
// Original logic:
|
||||||
|
// if (import.meta.env.DEV) return true
|
||||||
|
// return (
|
||||||
|
// storageConfig &&
|
||||||
|
// storageConfig.kv_storage === 'PGKVStorage' &&
|
||||||
|
// storageConfig.doc_status_storage === 'PGDocStatusStorage' &&
|
||||||
|
// storageConfig.graph_storage === 'PGGraphStorage' &&
|
||||||
|
// storageConfig.vector_storage === 'PGVectorStorage'
|
||||||
|
// )
|
||||||
|
}
|
||||||
|
|
||||||
function TabsNavigation() {
|
function TabsNavigation() {
|
||||||
const currentTab = useSettingsStore.use.currentTab()
|
const currentTab = useSettingsStore.use.currentTab()
|
||||||
|
const storageConfig = useSettingsStore.use.storageConfig()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -50,6 +66,11 @@ function TabsNavigation() {
|
||||||
<NavigationTab value="api" currentTab={currentTab}>
|
<NavigationTab value="api" currentTab={currentTab}>
|
||||||
{t('header.api')}
|
{t('header.api')}
|
||||||
</NavigationTab>
|
</NavigationTab>
|
||||||
|
{shouldShowTableExplorer(storageConfig) && (
|
||||||
|
<NavigationTab value="table-explorer" currentTab={currentTab}>
|
||||||
|
{t('header.tables')}
|
||||||
|
</NavigationTab>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -59,6 +80,7 @@ export default function SiteHeader() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isGuestMode, username, webuiTitle, webuiDescription } = useAuthStore()
|
const { isGuestMode, username, webuiTitle, webuiDescription } = useAuthStore()
|
||||||
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
||||||
|
const storageConfig = useSettingsStore.use.storageConfig()
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
navigationService.navigateToLogin();
|
navigationService.navigateToLogin();
|
||||||
|
|
@ -68,7 +90,7 @@ export default function SiteHeader() {
|
||||||
<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">
|
<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">
|
<div className="min-w-[200px] w-auto flex items-center">
|
||||||
<a href={webuiPrefix} className="flex items-center gap-2">
|
<a href={webuiPrefix} className="flex items-center gap-2">
|
||||||
<ZapIcon className="size-4 text-emerald-400" aria-hidden="true" />
|
<BrainIcon className="size-4 text-plum" aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
{webuiTitle && (
|
{webuiTitle && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|
@ -93,11 +115,6 @@ export default function SiteHeader() {
|
||||||
|
|
||||||
<div className="flex h-10 flex-1 items-center justify-center">
|
<div className="flex h-10 flex-1 items-center justify-center">
|
||||||
<TabsNavigation />
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav className="w-[200px] flex items-center justify-end">
|
<nav className="w-[200px] flex items-center justify-end">
|
||||||
|
|
|
||||||
350
lightrag_webui/src/features/TableExplorer.tsx
Normal file
350
lightrag_webui/src/features/TableExplorer.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getTableList, getTableSchema, getTableData } from '@/api/lightrag'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/Dialog'
|
||||||
|
import DataTable from '@/components/ui/DataTable'
|
||||||
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, RefreshCwIcon, CopyIcon, CheckIcon } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// Truncate long values for display
|
||||||
|
function truncateValue(value: any, maxLength = 50): string {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
|
||||||
|
let strValue: string
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
strValue = JSON.stringify(value)
|
||||||
|
} else {
|
||||||
|
strValue = String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strValue.length <= maxLength) return strValue
|
||||||
|
return strValue.slice(0, maxLength) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format value for display in modal
|
||||||
|
function formatValue(value: any): string {
|
||||||
|
if (value === null) return 'null'
|
||||||
|
if (value === undefined) return 'undefined'
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if value is JSON-like (object or array)
|
||||||
|
function isJsonLike(value: any): boolean {
|
||||||
|
return typeof value === 'object' && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard helper
|
||||||
|
async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy button component with feedback
|
||||||
|
function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const success = await copyToClipboard(text)
|
||||||
|
if (success) {
|
||||||
|
setCopied(true)
|
||||||
|
toast.success(label ? `${label} copied` : 'Copied to clipboard')
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to copy')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="h-3 w-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row Detail Modal
|
||||||
|
function RowDetailModal({
|
||||||
|
row,
|
||||||
|
open,
|
||||||
|
onOpenChange
|
||||||
|
}: {
|
||||||
|
row: Record<string, any> | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
const entries = Object.entries(row)
|
||||||
|
const fullRowJson = JSON.stringify(row, null, 2)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
Row Details
|
||||||
|
<CopyButton text={fullRowJson} label="Full row" />
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Click the copy icon next to any field to copy its value
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto space-y-3 pr-2">
|
||||||
|
{entries.map(([key, value]) => (
|
||||||
|
<div key={key} className="border rounded-lg p-3 bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-medium text-sm text-muted-foreground">{key}</span>
|
||||||
|
<CopyButton text={formatValue(value)} label={key} />
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm ${isJsonLike(value) ? 'font-mono' : ''}`}>
|
||||||
|
{isJsonLike(value) ? (
|
||||||
|
<pre className="whitespace-pre-wrap break-all bg-muted p-2 rounded text-xs overflow-auto max-h-[200px]">
|
||||||
|
{formatValue(value)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="whitespace-pre-wrap break-all">
|
||||||
|
{formatValue(value)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TableExplorer() {
|
||||||
|
const [selectedTable, setSelectedTable] = useState<string>('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
// Fetch table list
|
||||||
|
const { data: tableList } = useQuery({
|
||||||
|
queryKey: ['tables', 'list'],
|
||||||
|
queryFn: getTableList,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Derive effective selection: use state if set, otherwise default to first table
|
||||||
|
const effectiveSelectedTable = selectedTable || (tableList?.[0] ?? '')
|
||||||
|
|
||||||
|
// Reset page when table changes
|
||||||
|
const handleTableChange = (value: string) => {
|
||||||
|
setSelectedTable(value)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch schema
|
||||||
|
const { data: schema } = useQuery({
|
||||||
|
queryKey: ['tables', effectiveSelectedTable, 'schema'],
|
||||||
|
queryFn: () => getTableSchema(effectiveSelectedTable),
|
||||||
|
enabled: !!effectiveSelectedTable,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
const { data: tableData, isLoading, isError, error, refetch } = useQuery({
|
||||||
|
queryKey: ['tables', effectiveSelectedTable, 'data', page],
|
||||||
|
queryFn: () => getTableData(effectiveSelectedTable, page, pageSize),
|
||||||
|
enabled: !!effectiveSelectedTable,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle row click
|
||||||
|
const handleRowClick = useCallback((row: Record<string, any>) => {
|
||||||
|
setSelectedRow(row)
|
||||||
|
setModalOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Generate columns dynamically from data
|
||||||
|
const columns = useMemo<ColumnDef<any>[]>(() => {
|
||||||
|
const cols: ColumnDef<any>[] = []
|
||||||
|
if (tableData?.data && tableData.data.length > 0) {
|
||||||
|
const allKeys = new Set<string>()
|
||||||
|
tableData.data.forEach((row: any) => {
|
||||||
|
Object.keys(row).forEach(key => allKeys.add(key))
|
||||||
|
})
|
||||||
|
|
||||||
|
allKeys.forEach((key) => {
|
||||||
|
cols.push({
|
||||||
|
accessorKey: key,
|
||||||
|
header: () => (
|
||||||
|
<div className="font-semibold text-xs truncate max-w-[150px]" title={key}>
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const value = row.getValue(key)
|
||||||
|
const displayValue = truncateValue(value, 50)
|
||||||
|
const isLong = typeof value === 'object' || (typeof value === 'string' && value.length > 50)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`text-xs max-w-[200px] truncate ${isLong ? 'cursor-pointer hover:text-primary' : ''}`}
|
||||||
|
title={isLong ? 'Click row to see full value' : displayValue}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}, [tableData?.data])
|
||||||
|
|
||||||
|
const totalPages = tableData?.total_pages || 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col p-4 gap-4 overflow-hidden">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg font-medium">Table Explorer</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={effectiveSelectedTable} onValueChange={handleTableChange}>
|
||||||
|
<SelectTrigger className="w-[250px]">
|
||||||
|
<SelectValue placeholder={tableList && tableList.length > 0 ? "Select a table" : "No tables available"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableList && tableList.length > 0 ? (
|
||||||
|
tableList.map((table) => (
|
||||||
|
<SelectItem key={table} value={table}>
|
||||||
|
{table}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value="no-tables" disabled>
|
||||||
|
No tables found
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => refetch()}>
|
||||||
|
<RefreshCwIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{schema && (
|
||||||
|
<CardContent className="pb-2">
|
||||||
|
<details className="text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<summary>Show Schema (DDL)</summary>
|
||||||
|
<pre className="mt-2 p-2 bg-muted rounded overflow-auto max-h-[200px] font-mono text-xs">
|
||||||
|
{schema.ddl}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<CardContent className="flex-1 p-0 overflow-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<RefreshCwIcon className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-destructive gap-2">
|
||||||
|
<p className="font-medium">Failed to load table data</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()} className="mt-2">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={tableData?.data || []}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<div className="border-t p-2 flex items-center justify-between bg-muted/20">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{tableData?.total ? (
|
||||||
|
<>
|
||||||
|
Showing {((page - 1) * pageSize) + 1} to {Math.min(page * pageSize, tableData.total)} of {tableData.total} rows
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'No results'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1 || isLoading}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-4 w-4 mr-1" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium min-w-[3rem] text-center">
|
||||||
|
{page} / {totalPages || 1}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages || isLoading}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRightIcon className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<RowDetailModal
|
||||||
|
row={selectedRow}
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
--chart-3: hsl(197 37% 24%);
|
--chart-3: hsl(197 37% 24%);
|
||||||
--chart-4: hsl(43 74% 66%);
|
--chart-4: hsl(43 74% 66%);
|
||||||
--chart-5: hsl(27 87% 67%);
|
--chart-5: hsl(27 87% 67%);
|
||||||
|
--plum: #734079;
|
||||||
|
--plum-foreground: hsl(0 0% 98%);
|
||||||
--radius: 0.6rem;
|
--radius: 0.6rem;
|
||||||
--sidebar-background: hsl(0 0% 98%);
|
--sidebar-background: hsl(0 0% 98%);
|
||||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||||
|
|
@ -69,6 +71,8 @@
|
||||||
--chart-3: hsl(30 80% 55%);
|
--chart-3: hsl(30 80% 55%);
|
||||||
--chart-4: hsl(280 65% 60%);
|
--chart-4: hsl(280 65% 60%);
|
||||||
--chart-5: hsl(340 75% 55%);
|
--chart-5: hsl(340 75% 55%);
|
||||||
|
--plum: #9d5ba3;
|
||||||
|
--plum-foreground: hsl(0 0% 98%);
|
||||||
--sidebar-background: hsl(240 5.9% 10%);
|
--sidebar-background: hsl(240 5.9% 10%);
|
||||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||||
|
|
@ -104,6 +108,8 @@
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-plum: var(--plum);
|
||||||
|
--color-plum-foreground: var(--plum-foreground);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"knowledgeGraph": "Knowledge Graph",
|
"knowledgeGraph": "Knowledge Graph",
|
||||||
"retrieval": "Retrieval",
|
"retrieval": "Retrieval",
|
||||||
"api": "API",
|
"api": "API",
|
||||||
|
"tables": "Tables",
|
||||||
"projectRepository": "Project Repository",
|
"projectRepository": "Project Repository",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"frontendNeedsRebuild": "Frontend needs rebuild",
|
"frontendNeedsRebuild": "Frontend needs rebuild",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"knowledgeGraph": "知识图谱",
|
"knowledgeGraph": "知识图谱",
|
||||||
"retrieval": "检索",
|
"retrieval": "检索",
|
||||||
"api": "API",
|
"api": "API",
|
||||||
|
"tables": "数据表",
|
||||||
"projectRepository": "项目仓库",
|
"projectRepository": "项目仓库",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录",
|
||||||
"frontendNeedsRebuild": "前端代码需重新构建",
|
"frontendNeedsRebuild": "前端代码需重新构建",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Message, QueryRequest } from '@/api/lightrag'
|
||||||
|
|
||||||
type Theme = 'dark' | 'light' | 'system'
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
type Language = 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW'
|
type Language = 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW'
|
||||||
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api'
|
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api' | 'table-explorer'
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
// Document manager settings
|
// Document manager settings
|
||||||
|
|
@ -66,6 +66,10 @@ interface SettingsState {
|
||||||
apiKey: string | null
|
apiKey: string | null
|
||||||
setApiKey: (key: string | null) => void
|
setApiKey: (key: string | null) => void
|
||||||
|
|
||||||
|
// Storage configuration (for conditional UI)
|
||||||
|
storageConfig: Record<string, string> | null
|
||||||
|
setStorageConfig: (config: Record<string, string>) => void
|
||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
theme: Theme
|
theme: Theme
|
||||||
setTheme: (theme: Theme) => void
|
setTheme: (theme: Theme) => void
|
||||||
|
|
@ -113,6 +117,13 @@ const useSettingsStoreBase = create<SettingsState>()(
|
||||||
enableHealthCheck: true,
|
enableHealthCheck: true,
|
||||||
|
|
||||||
apiKey: null,
|
apiKey: null,
|
||||||
|
// In dev mode, mock PG storage config so Tables tab is visible
|
||||||
|
storageConfig: import.meta.env.DEV ? {
|
||||||
|
kv_storage: 'PGKVStorage',
|
||||||
|
doc_status_storage: 'PGDocStatusStorage',
|
||||||
|
graph_storage: 'PGGraphStorage',
|
||||||
|
vector_storage: 'PGVectorStorage'
|
||||||
|
} : null,
|
||||||
|
|
||||||
currentTab: 'documents',
|
currentTab: 'documents',
|
||||||
showFileName: false,
|
showFileName: false,
|
||||||
|
|
@ -184,6 +195,8 @@ const useSettingsStoreBase = create<SettingsState>()(
|
||||||
|
|
||||||
setApiKey: (apiKey: string | null) => set({ apiKey }),
|
setApiKey: (apiKey: string | null) => set({ apiKey }),
|
||||||
|
|
||||||
|
setStorageConfig: (config: Record<string, string>) => set({ storageConfig: config }),
|
||||||
|
|
||||||
setCurrentTab: (tab: Tab) => set({ currentTab: tab }),
|
setCurrentTab: (tab: Tab) => set({ currentTab: tab }),
|
||||||
|
|
||||||
setRetrievalHistory: (history: Message[]) => set({ retrievalHistory: history }),
|
setRetrievalHistory: (history: Message[]) => set({ retrievalHistory: history }),
|
||||||
|
|
@ -238,7 +251,7 @@ const useSettingsStoreBase = create<SettingsState>()(
|
||||||
{
|
{
|
||||||
name: 'settings-storage',
|
name: 'settings-storage',
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
version: 20,
|
version: 21,
|
||||||
migrate: (state: any, version: number) => {
|
migrate: (state: any, version: number) => {
|
||||||
if (version < 2) {
|
if (version < 2) {
|
||||||
state.showEdgeLabel = false
|
state.showEdgeLabel = false
|
||||||
|
|
@ -350,6 +363,15 @@ const useSettingsStoreBase = create<SettingsState>()(
|
||||||
state.language = 'en'
|
state.language = 'en'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (version < 21) {
|
||||||
|
// Reset storageConfig to pick up dev mode default
|
||||||
|
state.storageConfig = import.meta.env.DEV ? {
|
||||||
|
kv_storage: 'PGKVStorage',
|
||||||
|
doc_status_storage: 'PGDocStatusStorage',
|
||||||
|
graph_storage: 'PGGraphStorage',
|
||||||
|
vector_storage: 'PGVectorStorage'
|
||||||
|
} : null
|
||||||
|
}
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue