diff --git a/lightrag/api/lightrag_server.py b/lightrag/api/lightrag_server.py index f6052dcc..3428a63b 100644 --- a/lightrag/api/lightrag_server.py +++ b/lightrag/api/lightrag_server.py @@ -2,8 +2,6 @@ LightRAG FastAPI Server """ -from __future__ import annotations - from fastapi import FastAPI, Depends, HTTPException, Request from fastapi.exceptions import RequestValidationError 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.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.utils import logger, set_verbose_debug @@ -644,7 +643,7 @@ def create_app(args): raise Exception(f"Failed to import {binding} options: {e}") return {} - def create_entity_resolution_config(args) -> object | None: + def create_entity_resolution_config(args) -> "EntityResolutionConfig | None": """ Create EntityResolutionConfig from command line/env arguments. 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_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 ollama_api = OllamaAPI(rag, top_k=args.top_k, api_key=api_key) app.include_router(ollama_api.router, prefix="/api") diff --git a/lightrag/api/routers/table_routes.py b/lightrag/api/routers/table_routes.py new file mode 100644 index 00000000..07887cfa --- /dev/null +++ b/lightrag/api/routers/table_routes.py @@ -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 diff --git a/lightrag_webui/bun.lock b/lightrag_webui/bun.lock index 661837c4..b1cd828b 100644 --- a/lightrag_webui/bun.lock +++ b/lightrag_webui/bun.lock @@ -28,6 +28,7 @@ "@react-sigma/minimap": "^5.0.5", "@sigma/edge-curve": "^3.1.0", "@sigma/node-border": "^3.0.0", + "@tanstack/react-query": "^5.60.0", "@tanstack/react-table": "^8.21.3", "axios": "^1.12.2", "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=="], + "@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/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], diff --git a/lightrag_webui/package.json b/lightrag_webui/package.json index e2891a7f..e3e34bc0 100644 --- a/lightrag_webui/package.json +++ b/lightrag_webui/package.json @@ -40,6 +40,7 @@ "@react-sigma/minimap": "^5.0.5", "@sigma/edge-curve": "^3.1.0", "@sigma/node-border": "^3.0.0", + "@tanstack/react-query": "^5.60.0", "@tanstack/react-table": "^8.21.3", "axios": "^1.12.2", "class-variance-authority": "^0.7.1", diff --git a/lightrag_webui/src/App.tsx b/lightrag_webui/src/App.tsx index 83c5d10e..fd16ed0a 100644 --- a/lightrag_webui/src/App.tsx +++ b/lightrag_webui/src/App.tsx @@ -14,6 +14,7 @@ import GraphViewer from '@/features/GraphViewer' import DocumentManager from '@/features/DocumentManager' import RetrievalTesting from '@/features/RetrievalTesting' import ApiSite from '@/features/ApiSite' +import TableExplorer from '@/features/TableExplorer' import { Tabs, TabsContent } from '@/components/ui/Tabs' @@ -60,7 +61,10 @@ function App() { try { // Only perform health check if component is still mounted 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) { console.error('Health check error:', error); @@ -171,7 +175,7 @@ function App() {
-
@@ -215,6 +219,9 @@ function App() { + + + diff --git a/lightrag_webui/src/AppRouter.tsx b/lightrag_webui/src/AppRouter.tsx index 3d474d2a..7df6a54a 100644 --- a/lightrag_webui/src/AppRouter.tsx +++ b/lightrag_webui/src/AppRouter.tsx @@ -1,6 +1,7 @@ import '@/lib/extensions'; // Import all global extensions import { HashRouter as Router, Routes, Route, useNavigate } from 'react-router-dom' import { useEffect, useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useAuthStore } from '@/stores/state' import { navigationService } from '@/services/navigation' import { Toaster } from 'sonner' @@ -8,6 +9,17 @@ import App from './App' import LoginPage from '@/features/LoginPage' 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 [initializing, setInitializing] = useState(true) const { isAuthenticated } = useAuthStore() @@ -78,17 +90,19 @@ const AppContent = () => { const AppRouter = () => { return ( - - - - - - + + + + + + + + ) } diff --git a/lightrag_webui/src/api/lightrag.ts b/lightrag_webui/src/api/lightrag.ts index 7cf1aec6..b20fbdf1 100644 --- a/lightrag_webui/src/api/lightrag.ts +++ b/lightrag_webui/src/api/lightrag.ts @@ -815,3 +815,117 @@ export const getDocumentStatusCounts = async (): Promise = const response = await axiosInstance.get('/documents/status_counts') return response.data } + +export type TableSchema = { + ddl: string +} + +export type TableDataResponse = { + data: Record[] + 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 = { + '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 = { + '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 => { + if (import.meta.env.DEV) { + return mockTables + } + const response = await axiosInstance.get('/tables/list') + return response.data +} + +export const getTableSchema = async (tableName: string): Promise => { + 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 => { + 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 +} diff --git a/lightrag_webui/src/components/graph/PropertiesView.tsx b/lightrag_webui/src/components/graph/PropertiesView.tsx index 463b49da..17c66d4f 100644 --- a/lightrag_webui/src/components/graph/PropertiesView.tsx +++ b/lightrag_webui/src/components/graph/PropertiesView.tsx @@ -360,7 +360,7 @@ const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => { const { t } = useTranslation() return (
-

{t('graphPanel.propertiesView.edge.title')}

+

{t('graphPanel.propertiesView.edge.title')}

{edge.type && } diff --git a/lightrag_webui/src/components/ui/DataTable.tsx b/lightrag_webui/src/components/ui/DataTable.tsx index 2966eecc..1c9c0cab 100644 --- a/lightrag_webui/src/components/ui/DataTable.tsx +++ b/lightrag_webui/src/components/ui/DataTable.tsx @@ -12,9 +12,10 @@ import { interface DataTableProps { columns: ColumnDef[] data: TData[] + onRowClick?: (row: TData) => void } -export default function DataTable({ columns, data }: DataTableProps) { +export default function DataTable({ columns, data, onRowClick }: DataTableProps) { const table = useReactTable({ data, columns, @@ -42,7 +43,12 @@ export default function DataTable({ columns, data }: DataTablePro {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - + onRowClick?.(row.original)} + className={onRowClick ? 'cursor-pointer hover:bg-muted/50' : ''} + > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/lightrag_webui/src/components/ui/EmptyCard.tsx b/lightrag_webui/src/components/ui/EmptyCard.tsx index 16db3120..32f87b8b 100644 --- a/lightrag_webui/src/components/ui/EmptyCard.tsx +++ b/lightrag_webui/src/components/ui/EmptyCard.tsx @@ -20,19 +20,23 @@ export default function EmptyCard({ return ( -
-