feat(lightrag,lightrag_webui): add S3 storage integration and UI

Add S3 storage client and API routes for document management:
- Implement s3_routes.py with file upload, download, delete endpoints
- Enhance s3_client.py with improved error handling and operations
- Add S3 browser UI component with file viewing and management
- Implement FileViewer and PDFViewer components for storage preview
- Add Resizable and Sheet UI components for layout control
Update backend infrastructure:
- Add bulk operations and parameterized queries to postgres_impl.py
- Enhance document routes with improved type hints
- Update API server registration for new S3 routes
- Refine upload routes and utility functions
Modernize web UI:
- Integrate S3 browser into main application layout
- Update localization files for storage UI strings
- Add storage settings to application configuration
- Sync package dependencies and lock files
Remove obsolete reproduction script:
- Delete reproduce_citation.py (replaced by test suite)
Update configuration:
- Enhance pyrightconfig.json for stricter type checking
This commit is contained in:
clssck 2025-12-07 11:04:38 +01:00
parent 082a5a8fad
commit 95c83abcf8
35 changed files with 10753 additions and 145 deletions

View file

@ -39,6 +39,7 @@ from lightrag.api.routers.document_routes import (
from lightrag.api.routers.graph_routes import create_graph_routes from lightrag.api.routers.graph_routes import create_graph_routes
from lightrag.api.routers.ollama_api import OllamaAPI from lightrag.api.routers.ollama_api import OllamaAPI
from lightrag.api.routers.query_routes import create_query_routes from lightrag.api.routers.query_routes import create_query_routes
from lightrag.api.routers.s3_routes import create_s3_routes
from lightrag.api.routers.search_routes import create_search_routes from lightrag.api.routers.search_routes import create_search_routes
from lightrag.api.routers.table_routes import create_table_routes from lightrag.api.routers.table_routes import create_table_routes
from lightrag.api.routers.upload_routes import create_upload_routes from lightrag.api.routers.upload_routes import create_upload_routes
@ -62,6 +63,7 @@ from lightrag.kg.shared_storage import (
get_default_workspace, get_default_workspace,
get_namespace_data, get_namespace_data,
) )
from lightrag.kg.postgres_impl import PGKVStorage
from lightrag.storage.s3_client import S3Client, S3Config from lightrag.storage.s3_client import S3Client, S3Config
from lightrag.types import GPTKeywordExtractionFormat from lightrag.types import GPTKeywordExtractionFormat
from lightrag.utils import EmbeddingFunc, get_env_value, logger, set_verbose_debug from lightrag.utils import EmbeddingFunc, get_env_value, logger, set_verbose_debug
@ -396,7 +398,7 @@ def create_app(args):
'tryItOutEnabled': True, 'tryItOutEnabled': True,
} }
app = FastAPI(**cast(Any, app_kwargs)) app = FastAPI(**cast(dict[str, Any], app_kwargs))
# Add custom validation error handler for /query/data endpoint # Add custom validation error handler for /query/data endpoint
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
@ -1049,17 +1051,19 @@ def create_app(args):
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')
# Register upload routes if S3 is configured # Register upload routes and S3 browser if S3 is configured
if s3_client is not None: if s3_client is not None:
app.include_router(create_upload_routes(rag, s3_client, api_key)) app.include_router(create_upload_routes(rag, s3_client, api_key))
logger.info('S3 upload routes registered at /upload') logger.info('S3 upload routes registered at /upload')
app.include_router(create_s3_routes(s3_client, api_key), prefix='/s3')
logger.info('S3 browser routes registered at /s3')
else: else:
logger.info('S3 not configured - upload routes disabled') logger.info('S3 not configured - upload and browser routes disabled')
# Register BM25 search routes if PostgreSQL storage is configured # Register BM25 search routes if PostgreSQL storage is configured
# Full-text search requires PostgreSQLDB for ts_rank queries # Full-text search requires PostgreSQLDB for ts_rank queries
if args.kv_storage == 'PGKVStorage' and hasattr(rag, 'text_chunks') and hasattr(rag.text_chunks, 'db'): if args.kv_storage == 'PGKVStorage' and hasattr(rag, 'text_chunks') and hasattr(rag.text_chunks, 'db'):
app.include_router(create_search_routes(rag.text_chunks.db, api_key)) app.include_router(create_search_routes(cast(PGKVStorage, rag.text_chunks).db, api_key))
logger.info('BM25 search routes registered at /search') logger.info('BM25 search routes registered at /search')
else: else:
logger.info('PostgreSQL not configured - BM25 search routes disabled') logger.info('PostgreSQL not configured - BM25 search routes disabled')
@ -1466,8 +1470,8 @@ def main():
} }
) )
print(f'Starting Uvicorn server in single-process mode on {global_args.host}:{global_args.port}') update_uvicorn_mode_config()
uvicorn.run(**cast(Any, uvicorn_config)) uvicorn.run(**cast(dict[str, Any], uvicorn_config))
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -6,6 +6,7 @@ from .document_routes import router as document_router
from .graph_routes import router as graph_router from .graph_routes import router as graph_router
from .ollama_api import OllamaAPI from .ollama_api import OllamaAPI
from .query_routes import router as query_router from .query_routes import router as query_router
from .s3_routes import create_s3_routes
from .search_routes import create_search_routes from .search_routes import create_search_routes
from .upload_routes import create_upload_routes from .upload_routes import create_upload_routes
@ -14,6 +15,7 @@ __all__ = [
'document_router', 'document_router',
'graph_router', 'graph_router',
'query_router', 'query_router',
'create_s3_routes',
'create_search_routes', 'create_search_routes',
'create_upload_routes', 'create_upload_routes',
] ]

View file

@ -430,6 +430,7 @@ class DocStatusResponse(BaseModel):
error_msg: str | None = Field(default=None, description='Error message if processing failed') error_msg: str | None = Field(default=None, description='Error message if processing failed')
metadata: dict[str, Any] | None = Field(default=None, description='Additional metadata about the document') metadata: dict[str, Any] | None = Field(default=None, description='Additional metadata about the document')
file_path: str | None = Field(default=None, description='Path to the document file') file_path: str | None = Field(default=None, description='Path to the document file')
s3_key: str | None = Field(default=None, description='S3 storage key for archived documents')
class Config: class Config:
json_schema_extra: ClassVar[dict[str, Any]] = { json_schema_extra: ClassVar[dict[str, Any]] = {
@ -444,7 +445,8 @@ class DocStatusResponse(BaseModel):
'chunks_count': 12, 'chunks_count': 12,
'error_msg': None, 'error_msg': None,
'metadata': {'author': 'John Doe', 'year': 2025}, 'metadata': {'author': 'John Doe', 'year': 2025},
'file_path': 'research_paper.pdf', 'file_path': 's3://lightrag/archive/default/doc_123456/research_paper.pdf',
's3_key': 'archive/default/doc_123456/research_paper.pdf',
} }
} }
@ -1045,7 +1047,7 @@ def _extract_pptx(file_bytes: bytes) -> str:
for slide in prs.slides: for slide in prs.slides:
for shape in slide.shapes: for shape in slide.shapes:
if hasattr(shape, 'text'): if hasattr(shape, 'text'):
content += cast(Any, shape).text + '\n' content += shape.text + '\n' # type: ignore
return content return content
@ -2463,6 +2465,7 @@ def create_document_routes(rag: LightRAG, doc_manager: DocumentManager, api_key:
error_msg=doc_status.error_msg, error_msg=doc_status.error_msg,
metadata=doc_status.metadata, metadata=doc_status.metadata,
file_path=doc_status.file_path, file_path=doc_status.file_path,
s3_key=doc_status.s3_key,
) )
) )
@ -2721,6 +2724,7 @@ def create_document_routes(rag: LightRAG, doc_manager: DocumentManager, api_key:
error_msg=doc_status.error_msg, error_msg=doc_status.error_msg,
metadata=doc_status.metadata, metadata=doc_status.metadata,
file_path=doc_status.file_path, file_path=doc_status.file_path,
s3_key=doc_status.s3_key,
) )
) )
@ -2800,6 +2804,7 @@ def create_document_routes(rag: LightRAG, doc_manager: DocumentManager, api_key:
error_msg=doc.error_msg, error_msg=doc.error_msg,
metadata=doc.metadata, metadata=doc.metadata,
file_path=doc.file_path, file_path=doc.file_path,
s3_key=doc.s3_key,
) )
) )

View file

@ -505,7 +505,11 @@ class OllamaAPI:
if user_prompt is not None: if user_prompt is not None:
param_dict['user_prompt'] = user_prompt param_dict['user_prompt'] = user_prompt
query_param = QueryParam(**cast(Any, param_dict)) # Create QueryParam object from the parsed parameters
query_param = QueryParam(**cast(dict[str, Any], param_dict))
# Execute query using the configured RAG instance
# If stream is enabled, return StreamingResponse
if request.stream: if request.stream:
# Determine if the request is prefix with "/bypass" # Determine if the request is prefix with "/bypass"

View file

@ -0,0 +1,312 @@
"""
S3 Browser routes for object storage management.
This module provides endpoints for browsing, uploading, downloading,
and deleting objects in the S3/RustFS bucket.
Endpoints:
- GET /s3/list - List objects and folders under a prefix
- GET /s3/download/{key:path} - Get presigned download URL
- POST /s3/upload - Upload file to a prefix
- DELETE /s3/object/{key:path} - Delete an object
"""
import mimetypes
import traceback
from typing import Annotated, Any, ClassVar
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Query,
UploadFile,
)
from pydantic import BaseModel, Field
from lightrag.api.utils_api import get_combined_auth_dependency
from lightrag.storage.s3_client import S3Client
from lightrag.utils import logger
class S3ObjectInfo(BaseModel):
"""Information about an S3 object."""
key: str = Field(description='S3 object key')
size: int = Field(description='File size in bytes')
last_modified: str = Field(description='Last modified timestamp (ISO format)')
content_type: str | None = Field(default=None, description='MIME content type')
class S3ListResponse(BaseModel):
"""Response model for listing S3 objects."""
bucket: str = Field(description='Bucket name')
prefix: str = Field(description='Current prefix path')
folders: list[str] = Field(description='Virtual folders (common prefixes)')
objects: list[S3ObjectInfo] = Field(description='Objects at this level')
class Config:
json_schema_extra: ClassVar[dict[str, Any]] = {
'example': {
'bucket': 'lightrag',
'prefix': 'staging/default/',
'folders': ['doc_abc123/', 'doc_def456/'],
'objects': [
{
'key': 'staging/default/readme.txt',
'size': 1024,
'last_modified': '2025-12-06T10:30:00Z',
'content_type': 'text/plain',
}
],
}
}
class S3DownloadResponse(BaseModel):
"""Response model for download URL generation."""
key: str = Field(description='S3 object key')
url: str = Field(description='Presigned download URL')
expiry_seconds: int = Field(description='URL expiry time in seconds')
class S3UploadResponse(BaseModel):
"""Response model for file upload."""
key: str = Field(description='S3 object key where file was uploaded')
size: int = Field(description='File size in bytes')
url: str = Field(description='Presigned URL for immediate access')
class S3DeleteResponse(BaseModel):
"""Response model for object deletion."""
key: str = Field(description='Deleted S3 object key')
status: str = Field(description='Deletion status')
router = APIRouter(tags=['S3 Storage'])
def create_s3_routes(s3_client: S3Client, api_key: str | None = None) -> APIRouter:
"""
Create S3 browser routes with the given S3 client.
Args:
s3_client: Initialized S3Client instance
api_key: Optional API key for authentication
Returns:
APIRouter with S3 browser endpoints
"""
combined_auth = get_combined_auth_dependency(api_key)
@router.get('/list', response_model=S3ListResponse, dependencies=[Depends(combined_auth)])
async def list_objects(
prefix: str = Query(default='', description='S3 prefix to list (e.g., "staging/default/")'),
) -> S3ListResponse:
"""
List objects and folders under a prefix.
This endpoint enables folder-style navigation of the S3 bucket by using
the delimiter to group objects into virtual folders (common prefixes).
Args:
prefix: S3 prefix to list under. Use empty string for root.
Example: "staging/default/" lists contents of that folder.
Returns:
S3ListResponse with folders (common prefixes) and objects at this level
"""
try:
result = await s3_client.list_objects(prefix=prefix, delimiter='/')
# Convert to response model
objects = [
S3ObjectInfo(
key=obj['key'],
size=obj['size'],
last_modified=obj['last_modified'],
content_type=obj.get('content_type'),
)
for obj in result['objects']
]
return S3ListResponse(
bucket=result['bucket'],
prefix=result['prefix'],
folders=result['folders'],
objects=objects,
)
except Exception as e:
logger.error(f'Error listing S3 objects at prefix "{prefix}": {e!s}')
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500,
detail=f'Error listing objects: {e!s}',
) from e
@router.get(
'/download/{key:path}',
response_model=S3DownloadResponse,
dependencies=[Depends(combined_auth)],
)
async def get_download_url(
key: str,
expiry: int = Query(default=3600, description='URL expiry in seconds', ge=60, le=86400),
) -> S3DownloadResponse:
"""
Generate a presigned URL for downloading an object.
The presigned URL allows direct download from S3 without going through
the API server, which is efficient for large files.
Args:
key: Full S3 object key (e.g., "staging/default/doc_123/file.pdf")
expiry: URL expiry time in seconds (default: 3600, max: 86400)
Returns:
S3DownloadResponse with presigned URL
"""
try:
# Check if object exists first
exists = await s3_client.object_exists(key)
if not exists:
raise HTTPException(
status_code=404,
detail=f'Object not found: {key}',
)
url = await s3_client.get_presigned_url(key, expiry=expiry)
return S3DownloadResponse(
key=key,
url=url,
expiry_seconds=expiry,
)
except HTTPException:
raise
except Exception as e:
logger.error(f'Error generating download URL for "{key}": {e!s}')
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500,
detail=f'Error generating download URL: {e!s}',
) from e
@router.post('/upload', response_model=S3UploadResponse, dependencies=[Depends(combined_auth)])
async def upload_file(
file: Annotated[UploadFile, File(description='File to upload')],
prefix: Annotated[str, Form(description='S3 prefix path (e.g., "staging/default/")')] = '',
) -> S3UploadResponse:
"""
Upload a file to the specified prefix.
The file will be uploaded to: {prefix}{filename}
If prefix is empty, file is uploaded to bucket root.
Args:
file: File to upload (multipart form data)
prefix: S3 prefix path. Should end with "/" for folder-like structure.
Returns:
S3UploadResponse with the key where file was uploaded
"""
try:
# Read file content
content = await file.read()
if not content:
raise HTTPException(
status_code=400,
detail='Empty file uploaded',
)
# Sanitize filename
filename = file.filename or 'unnamed'
safe_filename = filename.replace('/', '_').replace('\\', '_')
# Construct key
key = f'{prefix}{safe_filename}' if prefix else safe_filename
# Detect content type
content_type = file.content_type
if not content_type or content_type == 'application/octet-stream':
guessed_type, _ = mimetypes.guess_type(filename)
content_type = guessed_type or 'application/octet-stream'
# Upload to S3
await s3_client.upload_object(
key=key,
data=content,
content_type=content_type,
)
# Generate presigned URL for immediate access
url = await s3_client.get_presigned_url(key)
logger.info(f'Uploaded file to S3: {key} ({len(content)} bytes)')
return S3UploadResponse(
key=key,
size=len(content),
url=url,
)
except HTTPException:
raise
except Exception as e:
logger.error(f'Error uploading file to S3: {e!s}')
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500,
detail=f'Error uploading file: {e!s}',
) from e
@router.delete(
'/object/{key:path}',
response_model=S3DeleteResponse,
dependencies=[Depends(combined_auth)],
)
async def delete_object(key: str) -> S3DeleteResponse:
"""
Delete an object from S3.
This operation is permanent and cannot be undone.
Args:
key: Full S3 object key to delete (e.g., "staging/default/doc_123/file.pdf")
Returns:
S3DeleteResponse confirming deletion
"""
try:
# Check if object exists first
exists = await s3_client.object_exists(key)
if not exists:
raise HTTPException(
status_code=404,
detail=f'Object not found: {key}',
)
await s3_client.delete_object(key)
logger.info(f'Deleted S3 object: {key}')
return S3DeleteResponse(
key=key,
status='deleted',
)
except HTTPException:
raise
except Exception as e:
logger.error(f'Error deleting S3 object "{key}": {e!s}')
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500,
detail=f'Error deleting object: {e!s}',
) from e
return router

View file

@ -8,7 +8,7 @@ This module provides endpoints for:
""" """
import mimetypes import mimetypes
from typing import Annotated, Any, ClassVar from typing import Annotated, Any, ClassVar, cast
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
@ -22,6 +22,7 @@ from pydantic import BaseModel, Field
from lightrag import LightRAG from lightrag import LightRAG
from lightrag.api.utils_api import get_combined_auth_dependency from lightrag.api.utils_api import get_combined_auth_dependency
from lightrag.kg.postgres_impl import PGDocStatusStorage, PGKVStorage
from lightrag.storage.s3_client import S3Client from lightrag.storage.s3_client import S3Client
from lightrag.utils import compute_mdhash_id, logger from lightrag.utils import compute_mdhash_id, logger
@ -176,10 +177,10 @@ def create_upload_routes(
doc_id = compute_mdhash_id(content, prefix='doc_') doc_id = compute_mdhash_id(content, prefix='doc_')
# Determine content type # Determine content type
content_type = file.content_type final_content_type = file.content_type
if not content_type: if not final_content_type:
content_type, _ = mimetypes.guess_type(file.filename or '') guessed_type, encoding = mimetypes.guess_type(file.filename or '')
content_type = content_type or 'application/octet-stream' final_content_type = guessed_type or 'application/octet-stream'
# Upload to S3 staging # Upload to S3 staging
s3_key = await s3_client.upload_to_staging( s3_key = await s3_client.upload_to_staging(
@ -187,10 +188,10 @@ def create_upload_routes(
doc_id=doc_id, doc_id=doc_id,
content=content, content=content,
filename=file.filename or f'{doc_id}.bin', filename=file.filename or f'{doc_id}.bin',
content_type=content_type, content_type=final_content_type,
metadata={ metadata={
'original_size': str(len(content)), 'original_size': str(len(content)),
'content_type': content_type, 'content_type': final_content_type,
}, },
) )
@ -407,12 +408,16 @@ def create_upload_routes(
# Update database chunks with archive s3_key # Update database chunks with archive s3_key
archive_url = s3_client.get_s3_url(archive_key) archive_url = s3_client.get_s3_url(archive_key)
updated_count = await rag.text_chunks.update_s3_key_by_doc_id( updated_count = await cast(PGKVStorage, rag.text_chunks).update_s3_key_by_doc_id(
full_doc_id=doc_id, full_doc_id=doc_id,
s3_key=archive_key, s3_key=archive_key,
archive_url=archive_url, archive_url=archive_url,
) )
logger.info(f'Updated {updated_count} chunks with archive s3_key: {archive_key}') logger.info(f'Updated {updated_count} chunks with archive s3_key: {archive_key}')
# Update doc_status with archive s3_key
await cast(PGDocStatusStorage, rag.doc_status).update_s3_key(doc_id, archive_key)
logger.info(f'Updated doc_status with archive s3_key: {archive_key}')
except Exception as e: except Exception as e:
logger.warning(f'Failed to archive document: {e}') logger.warning(f'Failed to archive document: {e}')
# Don't fail the request, processing succeeded # Don't fail the request, processing succeeded

View file

@ -716,6 +716,8 @@ class DocProcessingStatus:
"""Error message if failed""" """Error message if failed"""
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
"""Additional metadata""" """Additional metadata"""
s3_key: str | None = None
"""S3 storage key for archived documents"""
multimodal_processed: bool | None = field(default=None, repr=False) multimodal_processed: bool | None = field(default=None, repr=False)
"""Internal field: indicates if multimodal processing is complete. Not shown in repr() but accessible for debugging.""" """Internal field: indicates if multimodal processing is complete. Not shown in repr() but accessible for debugging."""

View file

@ -836,10 +836,11 @@ class PostgreSQLDB:
logger.warning(f'Failed to add llm_cache_list column to LIGHTRAG_DOC_CHUNKS: {e}') logger.warning(f'Failed to add llm_cache_list column to LIGHTRAG_DOC_CHUNKS: {e}')
async def _migrate_add_s3_key_columns(self): async def _migrate_add_s3_key_columns(self):
"""Add s3_key column to LIGHTRAG_DOC_FULL and LIGHTRAG_DOC_CHUNKS tables if they don't exist""" """Add s3_key column to LIGHTRAG_DOC_FULL, LIGHTRAG_DOC_CHUNKS, and LIGHTRAG_DOC_STATUS tables if they don't exist"""
tables = [ tables = [
('lightrag_doc_full', 'LIGHTRAG_DOC_FULL'), ('lightrag_doc_full', 'LIGHTRAG_DOC_FULL'),
('lightrag_doc_chunks', 'LIGHTRAG_DOC_CHUNKS'), ('lightrag_doc_chunks', 'LIGHTRAG_DOC_CHUNKS'),
('lightrag_doc_status', 'LIGHTRAG_DOC_STATUS'),
] ]
for table_name_lower, table_name in tables: for table_name_lower, table_name in tables:
@ -3049,6 +3050,7 @@ class PGDocStatusStorage(DocStatusStorage):
metadata=metadata, metadata=metadata,
error_msg=element.get('error_msg'), error_msg=element.get('error_msg'),
track_id=element.get('track_id'), track_id=element.get('track_id'),
s3_key=element.get('s3_key'),
) )
return docs_by_status return docs_by_status
@ -3101,6 +3103,7 @@ class PGDocStatusStorage(DocStatusStorage):
track_id=element.get('track_id'), track_id=element.get('track_id'),
metadata=metadata, metadata=metadata,
error_msg=element.get('error_msg'), error_msg=element.get('error_msg'),
s3_key=element.get('s3_key'),
) )
return docs_by_track_id return docs_by_track_id
@ -3213,6 +3216,7 @@ class PGDocStatusStorage(DocStatusStorage):
track_id=element.get('track_id'), track_id=element.get('track_id'),
metadata=metadata, metadata=metadata,
error_msg=element.get('error_msg'), error_msg=element.get('error_msg'),
s3_key=element.get('s3_key'),
) )
documents.append((doc_id, doc_status)) documents.append((doc_id, doc_status))
@ -3328,10 +3332,10 @@ class PGDocStatusStorage(DocStatusStorage):
logger.warning(f'[{self.workspace}] Unable to parse datetime string: {dt_str}') logger.warning(f'[{self.workspace}] Unable to parse datetime string: {dt_str}')
return None return None
# Modified SQL to include created_at, updated_at, chunks_list, track_id, metadata, and error_msg in both INSERT and UPDATE operations # Modified SQL to include created_at, updated_at, chunks_list, track_id, metadata, error_msg, and s3_key in both INSERT and UPDATE operations
# All fields are updated from the input data in both INSERT and UPDATE cases # All fields are updated from the input data in both INSERT and UPDATE cases
sql = """insert into LIGHTRAG_DOC_STATUS(workspace,id,content_summary,content_length,chunks_count,status,file_path,chunks_list,track_id,metadata,error_msg,created_at,updated_at) sql = """insert into LIGHTRAG_DOC_STATUS(workspace,id,content_summary,content_length,chunks_count,status,file_path,chunks_list,track_id,metadata,error_msg,s3_key,created_at,updated_at)
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
on conflict(id,workspace) do update set on conflict(id,workspace) do update set
content_summary = EXCLUDED.content_summary, content_summary = EXCLUDED.content_summary,
content_length = EXCLUDED.content_length, content_length = EXCLUDED.content_length,
@ -3342,6 +3346,7 @@ class PGDocStatusStorage(DocStatusStorage):
track_id = EXCLUDED.track_id, track_id = EXCLUDED.track_id,
metadata = EXCLUDED.metadata, metadata = EXCLUDED.metadata,
error_msg = EXCLUDED.error_msg, error_msg = EXCLUDED.error_msg,
s3_key = EXCLUDED.s3_key,
created_at = EXCLUDED.created_at, created_at = EXCLUDED.created_at,
updated_at = EXCLUDED.updated_at""" updated_at = EXCLUDED.updated_at"""
@ -3364,6 +3369,7 @@ class PGDocStatusStorage(DocStatusStorage):
v.get('track_id'), v.get('track_id'),
json.dumps(v.get('metadata', {})), json.dumps(v.get('metadata', {})),
v.get('error_msg'), v.get('error_msg'),
v.get('s3_key'),
created_at, created_at,
updated_at, updated_at,
) )
@ -3372,6 +3378,26 @@ class PGDocStatusStorage(DocStatusStorage):
if batch_data: if batch_data:
await self.db.executemany(sql, batch_data) await self.db.executemany(sql, batch_data)
async def update_s3_key(self, doc_id: str, s3_key: str) -> bool:
"""Update s3_key for a document after archiving.
Args:
doc_id: Document ID to update
s3_key: S3 storage key (e.g., 'archive/default/doc123/file.pdf')
Returns:
True if update was successful
"""
sql = """
UPDATE LIGHTRAG_DOC_STATUS
SET s3_key = $1, updated_at = CURRENT_TIMESTAMP
WHERE workspace = $2 AND id = $3
"""
params = {'s3_key': s3_key, 'workspace': self.workspace, 'id': doc_id}
await self.db.execute(sql, params)
logger.debug(f'[{self.workspace}] Updated s3_key for doc {doc_id}: {s3_key}')
return True
async def drop(self) -> dict[str, str]: async def drop(self) -> dict[str, str]:
"""Drop the storage""" """Drop the storage"""
try: try:
@ -5305,6 +5331,7 @@ TABLES = {
track_id varchar(255) NULL, track_id varchar(255) NULL,
metadata JSONB NULL DEFAULT '{}'::jsonb, metadata JSONB NULL DEFAULT '{}'::jsonb,
error_msg TEXT NULL, error_msg TEXT NULL,
s3_key TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT LIGHTRAG_DOC_STATUS_PK PRIMARY KEY (workspace, id) CONSTRAINT LIGHTRAG_DOC_STATUS_PK PRIMARY KEY (workspace, id)

View file

@ -77,8 +77,8 @@ def _handle_bedrock_exception(e: Exception, operation: str = 'Bedrock operation'
# Handle botocore ClientError with specific error codes # Handle botocore ClientError with specific error codes
if isinstance(e, ClientError): if isinstance(e, ClientError):
error_code = cast(Any, e).response.get('Error', {}).get('Code', '') error_code = cast(ClientError, e).response.get('Error', {}).get('Code', '')
error_msg = cast(Any, e).response.get('Error', {}).get('Message', error_message) error_msg = cast(ClientError, e).response.get('Error', {}).get('Message', error_message)
# Rate limiting and throttling errors (retryable) # Rate limiting and throttling errors (retryable)
if error_code in [ if error_code in [
@ -94,7 +94,7 @@ def _handle_bedrock_exception(e: Exception, operation: str = 'Bedrock operation'
raise BedrockConnectionError(f'Service error: {error_msg}') raise BedrockConnectionError(f'Service error: {error_msg}')
# Check for 5xx HTTP status codes (retryable) # Check for 5xx HTTP status codes (retryable)
elif cast(Any, e).response.get('ResponseMetadata', {}).get('HTTPStatusCode', 0) >= 500: elif cast(ClientError, e).response.get('ResponseMetadata', {}).get('HTTPStatusCode', 0) >= 500:
logging.error(f'{operation} server error: {error_msg}') logging.error(f'{operation} server error: {error_msg}')
raise BedrockConnectionError(f'Server error: {error_msg}') raise BedrockConnectionError(f'Server error: {error_msg}')

View file

@ -121,7 +121,7 @@ def create_openai_async_client(
if timeout is not None: if timeout is not None:
merged_configs['timeout'] = timeout merged_configs['timeout'] = timeout
return AsyncAzureOpenAI(**cast(Any, merged_configs)) return AsyncAzureOpenAI(**cast(dict[str, Any], merged_configs))
else: else:
if not api_key: if not api_key:
api_key = os.environ['OPENAI_API_KEY'] api_key = os.environ['OPENAI_API_KEY']
@ -316,12 +316,9 @@ async def openai_complete_if_cache(
raise raise
if hasattr(response, '__aiter__'): if hasattr(response, '__aiter__'):
response = cast(Any, response) async def collected_messages(response):
async for chunk in response:
async def inner(): chunk_message = chunk.choices[0].delta.content or ""
# Track if we've started iterating
iteration_started = False
final_chunk_usage = None
# COT (Chain of Thought) state tracking # COT (Chain of Thought) state tracking
cot_active = False cot_active = False

View file

@ -3211,9 +3211,9 @@ async def extract_entities(
nonlocal processed_chunks nonlocal processed_chunks
chunk_key = chunk_key_dp[0] chunk_key = chunk_key_dp[0]
chunk_dp = chunk_key_dp[1] chunk_dp = chunk_key_dp[1]
content = chunk_dp['content'] content = chunk_dp.get('content', '')
# Get file path from chunk data or use default # Get file path from chunk data or use default
file_path = chunk_dp.get('file_path', 'unknown_source') file_path = chunk_dp.get('file_path') or 'unknown_source'
# Create cache keys collector for batch processing # Create cache keys collector for batch processing
cache_keys_collector = [] cache_keys_collector = []

View file

@ -140,7 +140,7 @@ class S3Client:
""" """
config: S3Config config: S3Config
_session: aioboto3.Session = field(default=None, init=False, repr=False) _session: aioboto3.Session | None = field(default=None, init=False, repr=False)
_initialized: bool = field(default=False, init=False, repr=False) _initialized: bool = field(default=False, init=False, repr=False)
async def initialize(self): async def initialize(self):
@ -166,13 +166,16 @@ class S3Client:
@asynccontextmanager @asynccontextmanager
async def _get_client(self): async def _get_client(self):
"""Get an S3 client from the session.""" """Get an S3 client from the session."""
if self._session is None:
raise RuntimeError("S3Client not initialized")
boto_config = BotoConfig( boto_config = BotoConfig(
connect_timeout=self.config.connect_timeout, connect_timeout=self.config.connect_timeout,
read_timeout=self.config.read_timeout, read_timeout=self.config.read_timeout,
retries={"max_attempts": S3_RETRY_ATTEMPTS}, retries={"max_attempts": S3_RETRY_ATTEMPTS},
) )
async with self._session.client( async with self._session.client( # type: ignore
"s3", "s3",
endpoint_url=self.config.endpoint_url if self.config.endpoint_url else None, endpoint_url=self.config.endpoint_url if self.config.endpoint_url else None,
config=boto_config, config=boto_config,
@ -388,3 +391,94 @@ class S3Client:
def get_s3_url(self, s3_key: str) -> str: def get_s3_url(self, s3_key: str) -> str:
"""Get the S3 URL for an object (not presigned, for reference).""" """Get the S3 URL for an object (not presigned, for reference)."""
return f"s3://{self.config.bucket_name}/{s3_key}" return f"s3://{self.config.bucket_name}/{s3_key}"
@s3_retry
async def list_objects(
self, prefix: str = "", delimiter: str = "/"
) -> dict[str, Any]:
"""
List objects and common prefixes (virtual folders) under a prefix.
Uses delimiter to group objects into virtual folders. This enables
folder-style navigation in the bucket browser.
Args:
prefix: S3 prefix to list under (e.g., "staging/default/")
delimiter: Delimiter for grouping (default "/" for folder navigation)
Returns:
Dict with:
- bucket: Bucket name
- prefix: The prefix that was listed
- folders: List of common prefixes (virtual folders)
- objects: List of dicts with key, size, last_modified, content_type
"""
folders: list[str] = []
objects: list[dict[str, Any]] = []
async with self._get_client() as client:
paginator = client.get_paginator("list_objects_v2")
async for page in paginator.paginate(
Bucket=self.config.bucket_name,
Prefix=prefix,
Delimiter=delimiter,
):
# Get common prefixes (virtual folders)
for cp in page.get("CommonPrefixes", []):
folders.append(cp["Prefix"])
# Get objects at this level
for obj in page.get("Contents", []):
# Skip the prefix itself if it's a "folder marker"
if obj["Key"] == prefix:
continue
objects.append(
{
"key": obj["Key"],
"size": obj["Size"],
"last_modified": obj["LastModified"].isoformat(),
"content_type": None, # Would need HEAD request for each
}
)
return {
"bucket": self.config.bucket_name,
"prefix": prefix,
"folders": folders,
"objects": objects,
}
@s3_retry
async def upload_object(
self,
key: str,
data: bytes,
content_type: str = "application/octet-stream",
metadata: dict[str, str] | None = None,
) -> str:
"""
Upload object to an arbitrary key path.
Unlike upload_to_staging which enforces a path structure, this method
allows uploading to any path in the bucket.
Args:
key: Full S3 key path (e.g., "staging/workspace/doc_id/file.txt")
data: File content as bytes
content_type: MIME type (default: application/octet-stream)
metadata: Optional metadata dict
Returns:
The S3 key where the object was uploaded
"""
async with self._get_client() as client:
await client.put_object(
Bucket=self.config.bucket_name,
Key=key,
Body=data,
ContentType=content_type,
Metadata=metadata or {},
)
logger.info(f"Uploaded object: {key} ({len(data)} bytes)")
return key

View file

@ -1,15 +1,17 @@
import colorsys import colorsys
import os import os
import tkinter as tk import tkinter as tk
import traceback
from tkinter import filedialog from tkinter import filedialog
import traceback
from typing import cast
import community import community
import glm import glm
from imgui_bundle import hello_imgui, imgui, immapp
import moderngl import moderngl
import networkx as nx import networkx as nx
from networkx.classes.reportviews import DegreeView
import numpy as np import numpy as np
from imgui_bundle import hello_imgui, imgui, immapp
CUSTOM_FONT = 'font.ttf' CUSTOM_FONT = 'font.ttf'
@ -23,6 +25,7 @@ class Node3D:
def __init__(self, position: glm.vec3, color: glm.vec3, label: str, size: float, idx: int): def __init__(self, position: glm.vec3, color: glm.vec3, label: str, size: float, idx: int):
self.position = position self.position = position
self.color = color self.color = color
self.base_color = color # Initialize base_color
self.label = label self.label = label
self.size = size self.size = size
self.idx = idx self.idx = idx
@ -32,7 +35,7 @@ class GraphViewer:
"""Main class for 3D graph visualization""" """Main class for 3D graph visualization"""
def __init__(self): def __init__(self):
self.glctx = None # ModernGL context self.glctx: moderngl.Context | None = None # ModernGL context
self.graph: nx.Graph | None = None self.graph: nx.Graph | None = None
self.nodes: list[Node3D] = [] self.nodes: list[Node3D] = []
self.id_node_map: dict[str, Node3D] = {} self.id_node_map: dict[str, Node3D] = {}
@ -81,7 +84,7 @@ class GraphViewer:
self.node_id_fbo = None self.node_id_fbo = None
self.node_id_texture = None self.node_id_texture = None
self.node_id_depth = None self.node_id_depth = None
self.node_id_texture_np: np.ndarray = None self.node_id_texture_np: np.ndarray | None = None
# Static data # Static data
self.sphere_data = create_sphere() self.sphere_data = create_sphere()
@ -141,7 +144,7 @@ class GraphViewer:
return return
# Handle mouse movement for camera rotation # Handle mouse movement for camera rotation
if self.mouse_pressed and self.mouse_button == 1: # Right mouse button if self.mouse_pressed and self.mouse_button == 1 and self.last_mouse_pos: # Right mouse button
dx = self.last_mouse_pos[0] - mouse_pos[0] dx = self.last_mouse_pos[0] - mouse_pos[0]
dy = self.last_mouse_pos[1] - mouse_pos[1] # Reversed for intuitive control dy = self.last_mouse_pos[1] - mouse_pos[1] # Reversed for intuitive control
@ -192,6 +195,9 @@ class GraphViewer:
def update_layout(self): def update_layout(self):
"""Update the graph layout""" """Update the graph layout"""
if not self.graph:
return
pos = nx.spring_layout( pos = nx.spring_layout(
self.graph, self.graph,
dim=3, dim=3,
@ -215,7 +221,7 @@ class GraphViewer:
node_data = self.graph.nodes[self.selected_node.label] node_data = self.graph.nodes[self.selected_node.label]
imgui.text(f'Type: {node_data.get("type", "default")}') imgui.text(f'Type: {node_data.get("type", "default")}')
degree = self.graph.degree[self.selected_node.label] degree = cast(DegreeView, self.graph.degree)[self.selected_node.label]
imgui.text(f'Degree: {degree}') imgui.text(f'Degree: {degree}')
for key, value in node_data.items(): for key, value in node_data.items():
@ -246,7 +252,7 @@ class GraphViewer:
for neighbor, edge_data in connections.items(): for neighbor, edge_data in connections.items():
imgui.table_next_row() imgui.table_next_row()
imgui.table_set_column_index(0) imgui.table_set_column_index(0)
if imgui.selectable(str(neighbor), True)[0]: if imgui.selectable(str(neighbor), True):
# Select neighbor node # Select neighbor node
self.selected_node = self.id_node_map[neighbor] self.selected_node = self.id_node_map[neighbor]
self.position = self.selected_node.position - self.front self.position = self.selected_node.position - self.front
@ -263,11 +269,16 @@ class GraphViewer:
def setup_render_context(self): def setup_render_context(self):
"""Initialize ModernGL context""" """Initialize ModernGL context"""
self.glctx = moderngl.create_context() self.glctx = moderngl.create_context()
if not self.glctx:
return
self.glctx.enable(moderngl.DEPTH_TEST | moderngl.CULL_FACE) self.glctx.enable(moderngl.DEPTH_TEST | moderngl.CULL_FACE)
self.glctx.clear_color = self.background_color self.glctx.clear_color = self.background_color
def setup_shaders(self): def setup_shaders(self):
"""Setup vertex and fragment shaders for node and edge rendering""" """Setup vertex and fragment shaders for node and edge rendering"""
if not self.glctx:
return
# Node shader program # Node shader program
self.node_prog = self.glctx.program( self.node_prog = self.glctx.program(
vertex_shader=""" vertex_shader="""
@ -544,7 +555,7 @@ class GraphViewer:
pos = {node: coords * scale for node, coords in pos.items()} pos = {node: coords * scale for node, coords in pos.items()}
# Calculate degree-based sizes # Calculate degree-based sizes
degrees = dict(self.graph.degree()) degrees = dict(cast(DegreeView, self.graph.degree)())
max_degree = max(degrees.values()) if degrees else 1 max_degree = max(degrees.values()) if degrees else 1
min_degree = min(degrees.values()) if degrees else 1 min_degree = min(degrees.values()) if degrees else 1
@ -577,7 +588,7 @@ class GraphViewer:
def get_node_color(self, node_id: str) -> glm.vec3: def get_node_color(self, node_id: str) -> glm.vec3:
"""Get RGBA color based on community""" """Get RGBA color based on community"""
if self.communities and node_id in self.communities: if self.communities and node_id in self.communities and self.community_colors:
comm_id = self.communities[node_id] comm_id = self.communities[node_id]
color = self.community_colors[comm_id] color = self.community_colors[comm_id]
return color return color
@ -585,7 +596,7 @@ class GraphViewer:
def update_buffers(self): def update_buffers(self):
"""Update vertex buffers with current node and edge data using batch rendering""" """Update vertex buffers with current node and edge data using batch rendering"""
if not self.graph: if not self.graph or not self.glctx:
return return
# Update node buffers # Update node buffers
@ -773,16 +784,22 @@ class GraphViewer:
# Convert to a PIL Image and save as PNG # Convert to a PIL Image and save as PNG
from PIL import Image from PIL import Image
if self.node_id_texture_np is None:
return
scaled_array = self.node_id_texture_np * 255 scaled_array = self.node_id_texture_np * 255
img = Image.fromarray( img = Image.fromarray(
scaled_array.astype(np.uint8), scaled_array.astype(np.uint8),
'RGBA', 'RGBA',
) )
img = img.transpose(method=Image.FLIP_TOP_BOTTOM) img = img.transpose(method=Image.Transpose.FLIP_TOP_BOTTOM) # type: ignore
img.save(filename) img.save(filename)
def render_id_map(self, mvp: glm.mat4): def render_id_map(self, mvp: glm.mat4):
"""Render an offscreen id map where each node is drawn with a unique id color.""" """Render an offscreen id map where each node is drawn with a unique id color."""
if not self.glctx:
return
# Lazy initialization of id framebuffer # Lazy initialization of id framebuffer
if self.node_id_texture is not None and ( if self.node_id_texture is not None and (
self.node_id_texture.width != self.window_width or self.node_id_texture.height != self.window_height self.node_id_texture.width != self.window_width or self.node_id_texture.height != self.window_height
@ -802,7 +819,8 @@ class GraphViewer:
self.node_id_texture_np = np.zeros((self.window_height, self.window_width, 4), dtype=np.float32) self.node_id_texture_np = np.zeros((self.window_height, self.window_width, 4), dtype=np.float32)
# Bind the offscreen framebuffer # Bind the offscreen framebuffer
self.node_id_fbo.use() if self.node_id_fbo:
self.node_id_fbo.use()
self.glctx.clear(0, 0, 0, 0) self.glctx.clear(0, 0, 0, 0)
# Render nodes # Render nodes
@ -813,10 +831,14 @@ class GraphViewer:
# Revert to default framebuffer # Revert to default framebuffer
self.glctx.screen.use() self.glctx.screen.use()
self.node_id_texture.read_into(self.node_id_texture_np.data) if self.node_id_texture and self.node_id_texture_np is not None:
self.node_id_texture.read_into(self.node_id_texture_np.data)
def render(self): def render(self):
"""Render the graph""" """Render the graph"""
if not self.glctx:
return
# Clear screen # Clear screen
self.glctx.clear(*self.background_color, depth=1) self.glctx.clear(*self.background_color, depth=1)

View file

@ -456,12 +456,14 @@ def compute_args_hash(*args: Any) -> str:
return md5(safe_bytes).hexdigest() return md5(safe_bytes).hexdigest()
def compute_mdhash_id(content: str, prefix: str = '') -> str: def compute_mdhash_id(content: str | bytes, prefix: str = '') -> str:
""" """
Compute a unique ID for a given content string. Compute a unique ID for a given content string or bytes.
The ID is a combination of the given prefix and the MD5 hash of the content string. The ID is a combination of the given prefix and the MD5 hash of the content string.
""" """
if isinstance(content, bytes):
return prefix + md5(content).hexdigest()
return prefix + compute_args_hash(content) return prefix + compute_args_hash(content)

View file

@ -53,6 +53,8 @@
"react-i18next": "^16.3.5", "react-i18next": "^16.3.5",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-number-format": "^5.4.4", "react-number-format": "^5.4.4",
"react-pdf": "^10.2.0",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.9.6", "react-router-dom": "^7.9.6",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
@ -299,6 +301,28 @@
"@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.84", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.84", "@napi-rs/canvas-darwin-arm64": "0.1.84", "@napi-rs/canvas-darwin-x64": "0.1.84", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.84", "@napi-rs/canvas-linux-arm64-gnu": "0.1.84", "@napi-rs/canvas-linux-arm64-musl": "0.1.84", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-gnu": "0.1.84", "@napi-rs/canvas-linux-x64-musl": "0.1.84", "@napi-rs/canvas-win32-x64-msvc": "0.1.84" } }, "sha512-88FTNFs4uuiFKP0tUrPsEXhpe9dg7za9ILZJE08pGdUveMIDeana1zwfVkqRHJDPJFAmGY3dXmJ99dzsy57YnA=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.84", "", { "os": "android", "cpu": "arm64" }, "sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww=="],
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.84", "", { "os": "darwin", "cpu": "arm64" }, "sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww=="],
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.84", "", { "os": "darwin", "cpu": "x64" }, "sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A=="],
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.84", "", { "os": "linux", "cpu": "arm" }, "sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A=="],
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA=="],
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ=="],
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.84", "", { "os": "linux", "cpu": "none" }, "sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg=="],
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-N1GY3noO1oqgEo3rYQIwY44kfM11vA0lDbN0orTOHfCSUZTUyiYCY0nZ197QMahZBm1aR/vYgsWpV74MMMDuNA=="],
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-vUZmua6ADqTWyHyei81aXIt9wp0yjeNwTH0KdhdeoBb6azHmFR8uKTukZMXfLCC3bnsW0t4lW7K78KNMknmtjg=="],
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.84", "", { "os": "win32", "cpu": "x64" }, "sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, ""], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, ""],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, ""], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, ""],
@ -1237,6 +1261,10 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"make-cancellable-promise": ["make-cancellable-promise@2.0.0", "", {}, "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw=="],
"make-event-props": ["make-event-props@2.0.0", "", {}, "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, ""], "markdown-table": ["markdown-table@3.0.4", "", {}, ""],
"marked": ["marked@16.4.0", "", { "bin": "bin/marked.js" }, ""], "marked": ["marked@16.4.0", "", { "bin": "bin/marked.js" }, ""],
@ -1277,6 +1305,8 @@
"memoize-one": ["memoize-one@6.0.0", "", {}, ""], "memoize-one": ["memoize-one@6.0.0", "", {}, ""],
"merge-refs": ["merge-refs@2.0.0", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg=="],
"mermaid": ["mermaid@11.12.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.3", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w=="], "mermaid": ["mermaid@11.12.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^0.6.3", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w=="],
"micromark": ["micromark@4.0.1", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, ""], "micromark": ["micromark@4.0.1", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, ""],
@ -1409,6 +1439,8 @@
"pathe": ["pathe@2.0.3", "", {}, ""], "pathe": ["pathe@2.0.3", "", {}, ""],
"pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, ""], "picocolors": ["picocolors@1.1.1", "", {}, ""],
"picomatch": ["picomatch@4.0.3", "", {}, ""], "picomatch": ["picomatch@4.0.3", "", {}, ""],
@ -1461,10 +1493,14 @@
"react-number-format": ["react-number-format@5.4.4", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""], "react-number-format": ["react-number-format@5.4.4", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"react-pdf": ["react-pdf@10.2.0", "", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-zk0DIL31oCh8cuQycM0SJKfwh4Onz0/Nwi6wTOjgtEjWGUY6eM+/vuzvOP3j70qtEULn7m1JtaeGzud1w5fY2Q=="],
"react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""], "react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, ""],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, ""],
"react-resizable-panels": ["react-resizable-panels@3.0.6", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew=="],
"react-router": ["react-router@7.10.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-FVyCOH4IZ0eDDRycODfUqoN8ZSR2LbTvtx6RPsBgzvJ8xAXlMZNCrOFpu+jb8QbtZnpAd/cEki2pwE848pNGxw=="], "react-router": ["react-router@7.10.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-FVyCOH4IZ0eDDRycODfUqoN8ZSR2LbTvtx6RPsBgzvJ8xAXlMZNCrOFpu+jb8QbtZnpAd/cEki2pwE848pNGxw=="],
"react-router-dom": ["react-router-dom@7.10.0", "", { "dependencies": { "react-router": "7.10.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Q4haR150pN/5N75O30iIsRJcr3ef7p7opFaKpcaREy0GQit6uCRu1NEiIFIwnHJQy0bsziRFBweR/5EkmHgVUQ=="], "react-router-dom": ["react-router-dom@7.10.0", "", { "dependencies": { "react-router": "7.10.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Q4haR150pN/5N75O30iIsRJcr3ef7p7opFaKpcaREy0GQit6uCRu1NEiIFIwnHJQy0bsziRFBweR/5EkmHgVUQ=="],
@ -1589,6 +1625,8 @@
"tapable": ["tapable@2.2.1", "", {}, ""], "tapable": ["tapable@2.2.1", "", {}, ""],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyexec": ["tinyexec@1.0.1", "", {}, ""], "tinyexec": ["tinyexec@1.0.1", "", {}, ""],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, ""], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, ""],
@ -1681,6 +1719,8 @@
"vscode-uri": ["vscode-uri@3.0.8", "", {}, ""], "vscode-uri": ["vscode-uri@3.0.8", "", {}, ""],
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, ""], "web-namespaces": ["web-namespaces@2.0.1", "", {}, ""],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, ""], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, ""],
@ -1809,8 +1849,6 @@
"dom-helpers/csstype": ["csstype@3.1.3", "", {}, ""], "dom-helpers/csstype": ["csstype@3.1.3", "", {}, ""],
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, ""],
"hast-util-from-parse5/property-information": ["property-information@7.1.0", "", {}, ""], "hast-util-from-parse5/property-information": ["property-information@7.1.0", "", {}, ""],
"hast-util-to-jsx-runtime/@types/estree": ["@types/estree@1.0.6", "", {}, ""], "hast-util-to-jsx-runtime/@types/estree": ["@types/estree@1.0.6", "", {}, ""],
@ -1839,8 +1877,6 @@
"rehype-katex/katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": "cli.js" }, ""], "rehype-katex/katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": "cli.js" }, ""],
"stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, ""],
"@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, ""], "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, ""],
"@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, ""], "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, ""],
@ -1859,8 +1895,6 @@
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, ""], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, ""],
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, ""],
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, ""], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, ""],
"object.entries/call-bound/call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, ""], "object.entries/call-bound/call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, ""],

View file

@ -55,8 +55,8 @@
"graphology-layout-noverlap": "^0.4.2", "graphology-layout-noverlap": "^0.4.2",
"i18next": "^25.6.3", "i18next": "^25.6.3",
"katex": "^0.16.25", "katex": "^0.16.25",
"mermaid": "^11.12.1",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"mermaid": "^11.12.1",
"minisearch": "^7.2.0", "minisearch": "^7.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@ -65,6 +65,8 @@
"react-i18next": "^16.3.5", "react-i18next": "^16.3.5",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-number-format": "^5.4.4", "react-number-format": "^5.4.4",
"react-pdf": "^10.2.0",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.9.6", "react-router-dom": "^7.9.6",
"react-select": "^5.10.2", "react-select": "^5.10.2",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
@ -106,10 +108,10 @@
"graphology-types": "^0.24.8", "graphology-types": "^0.24.8",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
"typescript-eslint": "^8.48.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.2.4" "vite": "^7.2.4"
} }
} }

8266
lightrag_webui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@ import ApiSite from '@/features/ApiSite'
import DocumentManager from '@/features/DocumentManager' import DocumentManager from '@/features/DocumentManager'
import GraphViewer from '@/features/GraphViewer' import GraphViewer from '@/features/GraphViewer'
import RetrievalTesting from '@/features/RetrievalTesting' import RetrievalTesting from '@/features/RetrievalTesting'
import S3Browser from '@/features/S3Browser'
import TableExplorer from '@/features/TableExplorer' import TableExplorer from '@/features/TableExplorer'
import { Tabs, TabsContent } from '@/components/ui/Tabs' import { Tabs, TabsContent } from '@/components/ui/Tabs'
@ -244,6 +245,12 @@ function App() {
> >
<TableExplorer /> <TableExplorer />
</TabsContent> </TabsContent>
<TabsContent
value="storage"
className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden"
>
<S3Browser />
</TabsContent>
</div> </div>
</Tabs> </Tabs>
<ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} /> <ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} />

View file

@ -240,6 +240,7 @@ export type DocStatusResponse = {
error_msg?: string error_msg?: string
metadata?: Record<string, any> metadata?: Record<string, any>
file_path: string file_path: string
s3_key?: string
} }
export type DocsStatusesResponse = { export type DocsStatusesResponse = {
@ -1180,3 +1181,91 @@ export const getTableData = async (
}) })
return response.data return response.data
} }
// =====================
// S3 Storage Browser API
// =====================
export type S3ObjectInfo = {
key: string
size: number
last_modified: string
content_type: string | null
}
export type S3ListResponse = {
bucket: string
prefix: string
folders: string[]
objects: S3ObjectInfo[]
}
export type S3DownloadResponse = {
key: string
url: string
expiry_seconds: number
}
export type S3UploadResponse = {
key: string
size: number
url: string
}
export type S3DeleteResponse = {
key: string
status: string
}
/**
* List objects and folders under a prefix in the S3 bucket.
* @param prefix - S3 prefix to list (e.g., "staging/default/")
* @returns List of folders and objects at the prefix
*/
export const s3List = async (prefix = ''): Promise<S3ListResponse> => {
const response = await axiosInstance.get('/s3/list', {
params: { prefix },
})
return response.data
}
/**
* Get a presigned download URL for an S3 object.
* @param key - Full S3 object key
* @param expiry - URL expiry time in seconds (default: 3600)
* @returns Presigned download URL
*/
export const s3Download = async (key: string, expiry = 3600): Promise<S3DownloadResponse> => {
const response = await axiosInstance.get(`/s3/download/${encodeURIComponent(key)}`, {
params: { expiry },
})
return response.data
}
/**
* Upload a file to the S3 bucket.
* @param prefix - S3 prefix path (e.g., "staging/default/")
* @param file - File to upload
* @returns Upload result with key and presigned URL
*/
export const s3Upload = async (prefix: string, file: File): Promise<S3UploadResponse> => {
const formData = new FormData()
formData.append('file', file)
formData.append('prefix', prefix)
const response = await axiosInstance.post('/s3/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
/**
* Delete an object from the S3 bucket.
* @param key - Full S3 object key to delete
* @returns Deletion confirmation
*/
export const s3Delete = async (key: string): Promise<S3DeleteResponse> => {
const response = await axiosInstance.delete(`/s3/object/${encodeURIComponent(key)}`)
return response.data
}

View file

@ -0,0 +1,431 @@
import { s3Download } from '@/api/lightrag'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/Sheet'
import useTheme from '@/hooks/useTheme'
import { cn } from '@/lib/utils'
import {
DownloadIcon,
FileIcon,
FileTextIcon,
ImageIcon,
Loader2Icon,
FileCodeIcon,
GripVerticalIcon,
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import Button from '@/components/ui/Button'
import { ScrollArea } from '@/components/ui/ScrollArea'
import PDFViewer from './PDFViewer'
interface FileViewerProps {
open: boolean
onOpenChange: (open: boolean) => void
fileKey: string | null
fileName: string
fileSize: number
}
type FileType = 'text' | 'markdown' | 'json' | 'code' | 'image' | 'pdf' | 'unknown'
// Storage key for persisted width
const VIEWER_WIDTH_KEY = 'lightrag-viewer-width'
const DEFAULT_WIDTH = 672 // ~42rem
const MIN_WIDTH = 400
const MAX_WIDTH = 1200
// Get file type from extension
function getFileType(fileName: string): FileType {
const ext = fileName.split('.').pop()?.toLowerCase() || ''
// Text files
if (['txt', 'log', 'csv', 'tsv'].includes(ext)) return 'text'
// Markdown
if (['md', 'markdown', 'mdx'].includes(ext)) return 'markdown'
// JSON/YAML/Config
if (['json', 'yaml', 'yml', 'toml', 'ini', 'conf', 'config'].includes(ext)) return 'json'
// Code files
if (
[
'js',
'ts',
'jsx',
'tsx',
'py',
'java',
'c',
'cpp',
'h',
'hpp',
'go',
'rs',
'rb',
'php',
'swift',
'kt',
'scala',
'sql',
'sh',
'bash',
'zsh',
'css',
'scss',
'less',
'html',
'htm',
'xml',
].includes(ext)
)
return 'code'
// Images
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext)) return 'image'
// PDF
if (ext === 'pdf') return 'pdf'
return 'unknown'
}
// Get syntax highlighter language from extension
function getLanguage(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || ''
const langMap: Record<string, string> = {
js: 'javascript',
jsx: 'jsx',
ts: 'typescript',
tsx: 'tsx',
py: 'python',
java: 'java',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
go: 'go',
rs: 'rust',
rb: 'ruby',
php: 'php',
swift: 'swift',
kt: 'kotlin',
scala: 'scala',
sql: 'sql',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
css: 'css',
scss: 'scss',
less: 'less',
html: 'html',
htm: 'html',
xml: 'xml',
json: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'toml',
ini: 'ini',
md: 'markdown',
markdown: 'markdown',
}
return langMap[ext] || 'text'
}
// Get icon for file type
function FileTypeIcon({ fileType, className }: { fileType: FileType; className?: string }) {
switch (fileType) {
case 'text':
return <FileTextIcon className={className} />
case 'markdown':
return <FileTextIcon className={className} />
case 'code':
case 'json':
return <FileCodeIcon className={className} />
case 'image':
return <ImageIcon className={className} />
default:
return <FileIcon className={className} />
}
}
// Format bytes to human readable
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
export default function FileViewer({
open,
onOpenChange,
fileKey,
fileName,
fileSize,
}: FileViewerProps) {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [content, setContent] = useState<string | null>(null)
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Resizable width state
const [width, setWidth] = useState(() => {
const saved = localStorage.getItem(VIEWER_WIDTH_KEY)
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH
})
const [isResizing, setIsResizing] = useState(false)
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null)
const fileType = getFileType(fileName)
const language = getLanguage(fileName)
// Handle resize start
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
resizeRef.current = { startX: e.clientX, startWidth: width }
}, [width])
// Handle resize move
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
if (!resizeRef.current) return
const delta = resizeRef.current.startX - e.clientX
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, resizeRef.current.startWidth + delta))
setWidth(newWidth)
}
const handleMouseUp = () => {
setIsResizing(false)
localStorage.setItem(VIEWER_WIDTH_KEY, String(width))
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing, width])
// Fetch file content when opened
useEffect(() => {
if (!open || !fileKey) {
setContent(null)
setImageUrl(null)
setError(null)
return
}
const fetchContent = async () => {
setLoading(true)
setError(null)
try {
const response = await s3Download(fileKey)
const presignedUrl = response.url
if (fileType === 'image') {
setImageUrl(presignedUrl)
} else if (fileType === 'pdf') {
// For PDF, we just set the URL - user can open in new tab
setImageUrl(presignedUrl)
} else if (fileType !== 'unknown') {
// Fetch text content
const textResponse = await fetch(presignedUrl)
if (!textResponse.ok) {
throw new Error(`Failed to fetch: ${textResponse.statusText}`)
}
const text = await textResponse.text()
setContent(text)
} else {
// Unknown file type - just provide download link
setImageUrl(presignedUrl)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load file')
} finally {
setLoading(false)
}
}
fetchContent()
}, [open, fileKey, fileType])
const handleDownload = useCallback(async () => {
if (!fileKey) return
try {
const response = await s3Download(fileKey)
window.open(response.url, '_blank')
} catch {
// Error handled silently
}
}, [fileKey])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex flex-col p-0"
style={{ width: `${width}px`, maxWidth: '90vw' }}
>
{/* Resize handle */}
<div
className={cn(
'absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors z-50 group',
isResizing && 'bg-primary/50'
)}
onMouseDown={handleResizeStart}
>
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
<GripVerticalIcon className="h-6 w-6 text-muted-foreground" />
</div>
</div>
<div className="p-6 pb-0 flex-shrink-0">
<SheetHeader>
<div className="flex items-center gap-2 pr-8">
<FileTypeIcon fileType={fileType} className="h-5 w-5 text-muted-foreground" />
<SheetTitle className="truncate">{fileName}</SheetTitle>
</div>
<SheetDescription className="flex items-center justify-between">
<span>
{formatBytes(fileSize)} {fileType.toUpperCase()}
</span>
<Button variant="outline" size="sm" onClick={handleDownload}>
<DownloadIcon className="h-4 w-4 mr-1" />
{t('storagePanel.actions.download')}
</Button>
</SheetDescription>
</SheetHeader>
</div>
<div className="flex-1 mt-4 min-h-0 overflow-hidden px-6 pb-6">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full text-destructive gap-2">
<p>{error}</p>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t('common.close')}
</Button>
</div>
) : fileType === 'image' && imageUrl ? (
<div className="flex items-center justify-center h-full bg-muted/30 rounded-lg p-4">
<img
src={imageUrl}
alt={fileName}
className="max-w-full max-h-full object-contain rounded"
/>
</div>
) : fileType === 'pdf' && imageUrl ? (
<PDFViewer url={imageUrl} />
) : fileType === 'unknown' && imageUrl ? (
<div className="flex flex-col items-center justify-center h-full gap-4">
<FileIcon className="h-16 w-16 text-muted-foreground" />
<p className="text-muted-foreground">{t('storagePanel.viewer.noPreview')}</p>
<Button variant="default" onClick={handleDownload}>
<DownloadIcon className="h-4 w-4 mr-1" />
{t('storagePanel.actions.download')}
</Button>
</div>
) : fileType === 'markdown' && content ? (
<ScrollArea className="h-full">
<div className="prose prose-sm dark:prose-invert max-w-none p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
const inline = !match
return !inline ? (
<SyntaxHighlighter
style={isDark ? oneDark : oneLight}
language={match?.[1] || 'text'}
PreTag="div"
customStyle={{
margin: 0,
borderRadius: '0.375rem',
fontSize: '0.875rem',
}}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={cn('bg-muted px-1 py-0.5 rounded', className)} {...props}>
{children}
</code>
)
},
// Allow images from external sources
img({ src, alt, ...props }) {
return (
<img
src={src}
alt={alt}
className="max-w-full h-auto rounded"
loading="lazy"
{...props}
/>
)
},
}}
>
{content}
</ReactMarkdown>
</div>
</ScrollArea>
) : (fileType === 'code' || fileType === 'json') && content ? (
<ScrollArea className="h-full">
<SyntaxHighlighter
style={isDark ? oneDark : oneLight}
language={language}
showLineNumbers
customStyle={{
margin: 0,
borderRadius: '0.375rem',
fontSize: '0.8125rem',
minHeight: '100%',
}}
>
{content}
</SyntaxHighlighter>
</ScrollArea>
) : content ? (
<ScrollArea className="h-full">
<pre className="p-4 text-sm whitespace-pre-wrap break-words font-mono bg-muted/30 rounded-lg min-h-full">
{content}
</pre>
</ScrollArea>
) : null}
</div>
</SheetContent>
</Sheet>
)
}

View file

@ -0,0 +1,618 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
import type { PDFDocumentProxy } from 'pdfjs-dist'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
ChevronDownIcon,
MinusIcon,
PlusIcon,
Loader2Icon,
Maximize2Icon,
MaximizeIcon,
MinimizeIcon,
SearchIcon,
XIcon,
} from 'lucide-react'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import { cn } from '@/lib/utils'
// Configure pdf.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`
// Storage keys
const ZOOM_KEY = 'lightrag-pdf-zoom'
// Escape special regex characters
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
interface SearchMatch {
pageNum: number
matchIndex: number
}
interface PDFViewerProps {
url: string
}
export default function PDFViewer({ url }: PDFViewerProps) {
const [numPages, setNumPages] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [inputValue, setInputValue] = useState('1')
const [pdfWidth, setPdfWidth] = useState<number | null>(null)
const [fitMode, setFitMode] = useState<'manual' | 'width'>('manual')
const [isFullscreen, setIsFullscreen] = useState(false)
// Search state
const [showSearch, setShowSearch] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [textContent, setTextContent] = useState<Map<number, string>>(new Map())
const [matches, setMatches] = useState<SearchMatch[]>([])
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
// Persisted zoom
const [scale, setScale] = useState(() => {
const saved = localStorage.getItem(ZOOM_KEY)
return saved ? parseFloat(saved) : 1.0
})
const viewerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const pageRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Persist zoom to localStorage
useEffect(() => {
if (fitMode === 'manual') {
localStorage.setItem(ZOOM_KEY, String(scale))
}
}, [scale, fitMode])
// Calculate fit-to-width scale
const calculateFitWidth = useCallback(() => {
if (!containerRef.current || !pdfWidth) return 1.0
const containerWidth = containerRef.current.clientWidth - 48 // padding
return Math.min(2.0, Math.max(0.5, containerWidth / pdfWidth))
}, [pdfWidth])
// Update scale when fit mode is active and window resizes
useEffect(() => {
if (fitMode !== 'width') return
const updateFitWidth = () => {
setScale(calculateFitWidth())
}
updateFitWidth()
window.addEventListener('resize', updateFitWidth)
return () => window.removeEventListener('resize', updateFitWidth)
}, [fitMode, calculateFitWidth])
// Track which page is visible via IntersectionObserver
useEffect(() => {
if (numPages === 0) return
const observer = new IntersectionObserver(
(entries) => {
let maxVisibility = 0
let mostVisiblePage = currentPage
entries.forEach((entry) => {
if (entry.intersectionRatio > maxVisibility) {
maxVisibility = entry.intersectionRatio
const pageNum = parseInt(entry.target.getAttribute('data-page') || '1')
mostVisiblePage = pageNum
}
})
if (maxVisibility > 0.3) {
setCurrentPage(mostVisiblePage)
setInputValue(String(mostVisiblePage))
}
},
{
root: containerRef.current,
threshold: [0, 0.25, 0.5, 0.75, 1],
}
)
pageRefs.current.forEach((ref) => {
if (ref) observer.observe(ref)
})
return () => observer.disconnect()
}, [numPages, currentPage])
// Fullscreen change listener
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement)
document.addEventListener('fullscreenchange', handler)
return () => document.removeEventListener('fullscreenchange', handler)
}, [])
// Search: Find matches when query changes
useEffect(() => {
if (!searchQuery.trim() || textContent.size === 0) {
setMatches([])
setCurrentMatchIndex(0)
return
}
const results: SearchMatch[] = []
const query = searchQuery.toLowerCase()
textContent.forEach((text, pageNum) => {
const lowerText = text.toLowerCase()
let startIndex = 0
let matchIndex = 0
while ((startIndex = lowerText.indexOf(query, startIndex)) !== -1) {
results.push({ pageNum, matchIndex })
startIndex += query.length
matchIndex++
}
})
setMatches(results)
setCurrentMatchIndex(0)
// Navigate to first match
if (results.length > 0) {
goToPage(results[0].pageNum)
}
}, [searchQuery, textContent])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+F / Cmd+F to open search
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
setShowSearch(true)
setTimeout(() => searchInputRef.current?.focus(), 0)
return
}
// If search is open, handle search-specific shortcuts
if (showSearch && searchInputRef.current === document.activeElement) {
if (e.key === 'Enter') {
e.preventDefault()
if (e.shiftKey) {
prevMatch()
} else {
nextMatch()
}
return
}
if (e.key === 'Escape') {
e.preventDefault()
closeSearch()
return
}
return // Don't handle other shortcuts when search input is focused
}
// Only handle if viewer is focused or in fullscreen
if (!viewerRef.current?.contains(document.activeElement) && !isFullscreen) return
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault()
goToPage(currentPage - 1)
break
case 'ArrowRight':
case 'ArrowDown':
case ' ':
e.preventDefault()
goToPage(currentPage + 1)
break
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
e.preventDefault()
zoomOut()
break
case 'Home':
e.preventDefault()
goToPage(1)
break
case 'End':
e.preventDefault()
goToPage(numPages)
break
case 'Escape':
if (showSearch) {
e.preventDefault()
closeSearch()
} else if (isFullscreen) {
e.preventDefault()
toggleFullscreen()
}
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [currentPage, numPages, isFullscreen, showSearch, matches, currentMatchIndex])
const goToPage = (page: number) => {
const targetPage = Math.max(1, Math.min(page, numPages))
const pageElement = pageRefs.current.get(targetPage)
if (pageElement) {
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
setCurrentPage(targetPage)
setInputValue(String(targetPage))
}
const zoomIn = () => {
setFitMode('manual')
setScale((s) => Math.min(2, s + 0.25))
}
const zoomOut = () => {
setFitMode('manual')
setScale((s) => Math.max(0.5, s - 0.25))
}
const toggleFitWidth = () => {
if (fitMode === 'width') {
setFitMode('manual')
const saved = localStorage.getItem(ZOOM_KEY)
if (saved) setScale(parseFloat(saved))
} else {
setFitMode('width')
setScale(calculateFitWidth())
}
}
const toggleFullscreen = async () => {
if (!document.fullscreenElement) {
await viewerRef.current?.requestFullscreen()
} else {
await document.exitFullscreen()
}
}
// Search navigation
const nextMatch = () => {
if (matches.length === 0) return
const nextIndex = (currentMatchIndex + 1) % matches.length
setCurrentMatchIndex(nextIndex)
goToPage(matches[nextIndex].pageNum)
}
const prevMatch = () => {
if (matches.length === 0) return
const prevIndex = (currentMatchIndex - 1 + matches.length) % matches.length
setCurrentMatchIndex(prevIndex)
goToPage(matches[prevIndex].pageNum)
}
const closeSearch = () => {
setShowSearch(false)
setSearchQuery('')
setMatches([])
setCurrentMatchIndex(0)
viewerRef.current?.focus()
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}
const handleInputBlur = () => {
const page = parseInt(inputValue) || 1
goToPage(page)
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const page = parseInt(inputValue) || 1
goToPage(page)
}
}
// Extract text content from PDF for search
const extractTextContent = async (pdf: PDFDocumentProxy) => {
const textMap = new Map<number, string>()
for (let i = 1; i <= pdf.numPages; i++) {
try {
const page = await pdf.getPage(i)
const content = await page.getTextContent()
const text = content.items
.map((item) => ('str' in item ? item.str : ''))
.join(' ')
textMap.set(i, text)
} catch {
// Skip pages that fail to extract
}
}
setTextContent(textMap)
}
const handleLoadSuccess = (pdf: PDFDocumentProxy) => {
setNumPages(pdf.numPages)
setLoading(false)
// Extract text content for search
extractTextContent(pdf)
}
const handlePageLoadSuccess = (page: { width: number }) => {
if (!pdfWidth) {
setPdfWidth(page.width)
}
}
const handleLoadError = (err: Error) => {
setError(err.message)
setLoading(false)
}
// Custom text renderer for highlighting search matches
const customTextRenderer = useCallback(
({ str }: { str: string }) => {
if (!searchQuery.trim()) return str
const regex = new RegExp(`(${escapeRegExp(searchQuery)})`, 'gi')
const parts = str.split(regex)
if (parts.length === 1) return str
return parts
.map((part, i) => {
if (part.toLowerCase() === searchQuery.toLowerCase()) {
return `<mark class="bg-yellow-300 dark:bg-yellow-600 rounded px-0.5">${part}</mark>`
}
return part
})
.join('')
},
[searchQuery]
)
return (
<div
ref={viewerRef}
className={cn('flex flex-col h-full', isFullscreen && 'bg-background')}
tabIndex={0}
>
{/* Search bar */}
{showSearch && (
<div className="flex items-center gap-2 p-2 border-b bg-background flex-shrink-0">
<SearchIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search in document..."
className="flex-1 h-8"
autoFocus
/>
{matches.length > 0 ? (
<>
<Button
size="icon"
variant="ghost"
onClick={prevMatch}
className="h-8 w-8 flex-shrink-0"
title="Previous match (Shift+Enter)"
>
<ChevronUpIcon className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground whitespace-nowrap min-w-[4rem] text-center">
{currentMatchIndex + 1} of {matches.length}
</span>
<Button
size="icon"
variant="ghost"
onClick={nextMatch}
className="h-8 w-8 flex-shrink-0"
title="Next match (Enter)"
>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</>
) : searchQuery.trim() ? (
<span className="text-sm text-muted-foreground whitespace-nowrap">No matches</span>
) : null}
<Button
size="icon"
variant="ghost"
onClick={closeSearch}
className="h-8 w-8 flex-shrink-0"
title="Close (Escape)"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
)}
{/* Scrollable PDF pages */}
<div
ref={containerRef}
className={cn(
'flex-1 overflow-auto bg-muted/30',
'scrollbar-thin scrollbar-track-transparent',
'scrollbar-thumb-transparent hover:scrollbar-thumb-muted-foreground/40'
)}
>
{loading && (
<div className="flex items-center justify-center h-full">
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="flex items-center justify-center h-full text-destructive">
<p>Failed to load PDF: {error}</p>
</div>
)}
<Document
file={url}
onLoadSuccess={handleLoadSuccess}
onLoadError={handleLoadError}
loading={null}
error={null}
>
<div className="flex flex-col items-center py-4 gap-4 min-w-fit">
{Array.from({ length: numPages }, (_, i) => (
<div
key={i}
ref={(el) => {
if (el) pageRefs.current.set(i + 1, el)
}}
data-page={i + 1}
className="shadow-lg"
>
<Page
pageNumber={i + 1}
scale={scale}
renderTextLayer
renderAnnotationLayer
onLoadSuccess={i === 0 ? handlePageLoadSuccess : undefined}
customTextRenderer={searchQuery.trim() ? customTextRenderer : undefined}
loading={
<div className="flex items-center justify-center p-8">
<Loader2Icon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
}
/>
</div>
))}
</div>
</Document>
</div>
{/* Bottom toolbar */}
{numPages > 0 && (
<div className="flex items-center justify-center gap-2 p-2 border-t bg-background flex-shrink-0">
{/* Search button */}
<Button
size="icon"
variant={showSearch ? 'secondary' : 'ghost'}
onClick={() => {
setShowSearch(!showSearch)
if (!showSearch) {
setTimeout(() => searchInputRef.current?.focus(), 0)
}
}}
className="h-8 w-8"
title="Search (Ctrl+F)"
>
<SearchIcon className="h-4 w-4" />
</Button>
<div className="border-l h-6 mx-1" />
{/* Page navigation */}
<Button
size="icon"
variant="ghost"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
className="h-8 w-8"
title="Previous page (←)"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Page</span>
<Input
type="number"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className="w-14 h-8 text-center"
min={1}
max={numPages}
/>
<span className="text-sm text-muted-foreground">of {numPages}</span>
</div>
<Button
size="icon"
variant="ghost"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= numPages}
className="h-8 w-8"
title="Next page (→)"
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<div className="border-l h-6 mx-2" />
{/* Zoom controls */}
<Button
size="icon"
variant="ghost"
onClick={zoomOut}
disabled={scale <= 0.5}
className="h-8 w-8"
title="Zoom out ()"
>
<MinusIcon className="h-4 w-4" />
</Button>
<span className="text-sm w-12 text-center">{Math.round(scale * 100)}%</span>
<Button
size="icon"
variant="ghost"
onClick={zoomIn}
disabled={scale >= 2}
className="h-8 w-8"
title="Zoom in (+)"
>
<PlusIcon className="h-4 w-4" />
</Button>
{/* Fit to width */}
<Button
size="icon"
variant={fitMode === 'width' ? 'secondary' : 'ghost'}
onClick={toggleFitWidth}
className="h-8 w-8"
title="Fit to width"
>
<Maximize2Icon className="h-4 w-4" />
</Button>
<div className="border-l h-6 mx-2" />
{/* Fullscreen toggle */}
<Button
size="icon"
variant="ghost"
onClick={toggleFullscreen}
className="h-8 w-8"
title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Enter fullscreen'}
>
{isFullscreen ? (
<MinimizeIcon className="h-4 w-4" />
) : (
<MaximizeIcon className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,40 @@
import { GripVertical } from 'lucide-react'
import * as ResizablePrimitive from 'react-resizable-panels'
import { cn } from '@/lib/utils'
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View file

@ -0,0 +1,123 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
const Sheet = DialogPrimitive.Root
const SheetTrigger = DialogPrimitive.Trigger
const SheetClose = DialogPrimitive.Close
const SheetPortal = DialogPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
},
},
defaultVariants: {
side: 'right',
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{children}
</DialogPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = DialogPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
)
SheetHeader.displayName = 'SheetHeader'
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
SheetFooter.displayName = 'SheetFooter'
const SheetTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
))
SheetTitle.displayName = DialogPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
SheetDescription.displayName = DialogPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View file

@ -1419,6 +1419,7 @@ export default function DocumentManager() {
<TableHead>{t('documentPanel.documentManager.columns.status')}</TableHead> <TableHead>{t('documentPanel.documentManager.columns.status')}</TableHead>
<TableHead>{t('documentPanel.documentManager.columns.length')}</TableHead> <TableHead>{t('documentPanel.documentManager.columns.length')}</TableHead>
<TableHead>{t('documentPanel.documentManager.columns.chunks')}</TableHead> <TableHead>{t('documentPanel.documentManager.columns.chunks')}</TableHead>
<TableHead>{t('documentPanel.documentManager.columns.s3Key')}</TableHead>
<TableHead <TableHead
onClick={() => handleSort('created_at')} onClick={() => handleSort('created_at')}
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none" className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none"
@ -1546,6 +1547,20 @@ export default function DocumentManager() {
</TableCell> </TableCell>
<TableCell>{doc.content_length ?? '-'}</TableCell> <TableCell>{doc.content_length ?? '-'}</TableCell>
<TableCell>{doc.chunks_count ?? '-'}</TableCell> <TableCell>{doc.chunks_count ?? '-'}</TableCell>
<TableCell className="max-w-[150px] truncate overflow-visible">
{doc.s3_key ? (
<div className="group relative overflow-visible tooltip-container">
<div className="truncate text-xs text-muted-foreground font-mono">
{doc.s3_key.split('/').slice(-2).join('/')}
</div>
<div className="invisible group-hover:visible tooltip">
{doc.s3_key}
</div>
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="truncate"> <TableCell className="truncate">
{new Date(doc.created_at).toLocaleString()} {new Date(doc.created_at).toLocaleString()}
</TableCell> </TableCell>

View file

@ -0,0 +1,399 @@
import type { S3ObjectInfo } from '@/api/lightrag'
import { s3Delete, s3Download, s3List, s3Upload } from '@/api/lightrag'
import FileViewer from '@/components/storage/FileViewer'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/AlertDialog'
import Button from '@/components/ui/Button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ChevronRightIcon,
DownloadIcon,
EyeIcon,
FileIcon,
FolderIcon,
HomeIcon,
RefreshCwIcon,
Trash2Icon,
UploadIcon,
} from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
// Format bytes to human readable size
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
// Format ISO date to localized string
function formatDate(isoDate: string): string {
try {
return new Date(isoDate).toLocaleString()
} catch {
return isoDate
}
}
// Extract display name from full key or folder path
function getDisplayName(keyOrPath: string, prefix: string): string {
// Remove prefix to get relative path
const relative = keyOrPath.startsWith(prefix) ? keyOrPath.slice(prefix.length) : keyOrPath
// For folders, remove trailing slash
return relative.endsWith('/') ? relative.slice(0, -1) : relative
}
// Parse prefix into breadcrumb segments
function parseBreadcrumbs(prefix: string): { name: string; path: string }[] {
const segments: { name: string; path: string }[] = [{ name: 'Root', path: '' }]
if (!prefix) return segments
const parts = prefix.split('/').filter(Boolean)
let currentPath = ''
for (const part of parts) {
currentPath += part + '/'
segments.push({ name: part, path: currentPath })
}
return segments
}
export default function S3Browser() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
// Current prefix for navigation
const [prefix, setPrefix] = useState('')
// Delete confirmation state
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
// File viewer state
const [viewTarget, setViewTarget] = useState<S3ObjectInfo | null>(null)
// Fetch objects at current prefix
const {
data: listData,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ['s3', 'list', prefix],
queryFn: () => s3List(prefix),
})
// Upload mutation
const uploadMutation = useMutation({
mutationFn: async (file: File) => s3Upload(prefix, file),
onSuccess: (data) => {
toast.success(t('storagePanel.uploadSuccess', { name: getDisplayName(data.key, prefix) }))
queryClient.invalidateQueries({ queryKey: ['s3', 'list', prefix] })
},
onError: (err: Error) => {
toast.error(t('storagePanel.uploadFailed', { error: err.message }))
},
})
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (key: string) => s3Delete(key),
onSuccess: (data) => {
toast.success(t('storagePanel.deleteSuccess', { name: getDisplayName(data.key, prefix) }))
queryClient.invalidateQueries({ queryKey: ['s3', 'list', prefix] })
setDeleteTarget(null)
},
onError: (err: Error) => {
toast.error(t('storagePanel.deleteFailed', { error: err.message }))
setDeleteTarget(null)
},
})
// Navigate to a folder
const navigateToFolder = useCallback((folderPath: string) => {
setPrefix(folderPath)
}, [])
// Handle download click
const handleDownload = useCallback(async (key: string) => {
try {
const response = await s3Download(key)
// Open presigned URL in new tab
window.open(response.url, '_blank')
} catch (err) {
toast.error(t('storagePanel.downloadFailed', { error: err instanceof Error ? err.message : 'Unknown error' }))
}
}, [t])
// Handle file upload
const handleUpload = useCallback(() => {
fileInputRef.current?.click()
}, [])
// Handle file selection
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (files && files.length > 0) {
uploadMutation.mutate(files[0])
}
// Reset input so the same file can be uploaded again
event.target.value = ''
},
[uploadMutation]
)
// Handle view click
const handleView = useCallback((obj: S3ObjectInfo) => {
setViewTarget(obj)
}, [])
// Handle delete click
const handleDelete = useCallback((key: string) => {
setDeleteTarget(key)
}, [])
// Confirm delete
const confirmDelete = useCallback(() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget)
}
}, [deleteTarget, deleteMutation])
// Cancel delete
const cancelDelete = useCallback(() => {
setDeleteTarget(null)
}, [])
const breadcrumbs = parseBreadcrumbs(prefix)
const folders = listData?.folders || []
const objects = listData?.objects || []
return (
<div className="h-full flex flex-col p-4 gap-4 overflow-hidden">
<Card className="flex-1 overflow-hidden flex flex-col">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-medium">{t('storagePanel.title')}</CardTitle>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleUpload}
disabled={uploadMutation.isPending}
>
<UploadIcon className="h-4 w-4 mr-1" />
{t('storagePanel.actions.upload')}
</Button>
<Button variant="outline" size="icon" onClick={() => refetch()}>
<RefreshCwIcon className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Breadcrumb navigation */}
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-2 flex-wrap">
{breadcrumbs.map((segment, index) => (
<div key={segment.path} className="flex items-center">
{index > 0 && <ChevronRightIcon className="h-4 w-4 mx-1" />}
<button
type="button"
className={`hover:text-foreground transition-colors ${
index === breadcrumbs.length - 1
? 'font-medium text-foreground'
: 'hover:underline'
}`}
onClick={() => navigateToFolder(segment.path)}
>
{index === 0 ? (
<span className="flex items-center gap-1">
<HomeIcon className="h-4 w-4" />
{listData?.bucket || segment.name}
</span>
) : (
segment.name
)}
</button>
</div>
))}
</div>
</CardHeader>
<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">{t('storagePanel.loadFailed')}</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">
{t('storagePanel.actions.retry')}
</Button>
</div>
) : folders.length === 0 && objects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<FolderIcon className="h-12 w-12 mb-2 opacity-50" />
<p>{t('storagePanel.empty')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50%]">{t('storagePanel.table.name')}</TableHead>
<TableHead className="w-[15%]">{t('storagePanel.table.size')}</TableHead>
<TableHead className="w-[20%]">{t('storagePanel.table.modified')}</TableHead>
<TableHead className="w-[15%] text-right">{t('storagePanel.table.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* Folders */}
{folders.map((folder) => (
<TableRow
key={folder}
className="cursor-pointer hover:bg-muted/50"
onClick={() => navigateToFolder(folder)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<FolderIcon className="h-4 w-4 text-yellow-500" />
{getDisplayName(folder, prefix)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">-</TableCell>
<TableCell className="text-muted-foreground">-</TableCell>
<TableCell></TableCell>
</TableRow>
))}
{/* Objects */}
{objects.map((obj: S3ObjectInfo) => (
<TableRow key={obj.key}>
<TableCell className="font-medium">
<button
type="button"
className="flex items-center gap-2 hover:text-primary transition-colors text-left"
onClick={() => handleView(obj)}
>
<FileIcon className="h-4 w-4 text-blue-500 flex-shrink-0" />
<span className="truncate">{getDisplayName(obj.key, prefix)}</span>
</button>
</TableCell>
<TableCell>{formatBytes(obj.size)}</TableCell>
<TableCell>{formatDate(obj.last_modified)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleView(obj)}
title={t('storagePanel.actions.view')}
>
<EyeIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleDownload(obj.key)}
title={t('storagePanel.actions.download')}
>
<DownloadIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDelete(obj.key)}
title={t('storagePanel.actions.delete')}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
{/* Footer with object count */}
<div className="border-t p-2 flex items-center justify-between bg-muted/20">
<div className="text-sm text-muted-foreground">
{t('storagePanel.count', {
folders: folders.length,
objects: objects.length,
})}
</div>
</div>
</Card>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileChange}
/>
{/* Delete confirmation dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && cancelDelete()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('storagePanel.confirmDelete.title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('storagePanel.confirmDelete.description', {
name: deleteTarget ? getDisplayName(deleteTarget, prefix) : '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={cancelDelete}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('storagePanel.actions.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* File viewer */}
<FileViewer
open={!!viewTarget}
onOpenChange={(open) => !open && setViewTarget(null)}
fileKey={viewTarget?.key ?? null}
fileName={viewTarget ? getDisplayName(viewTarget.key, prefix) : ''}
fileSize={viewTarget?.size ?? 0}
/>
</div>
)
}

View file

@ -70,6 +70,9 @@ function TabsNavigation() {
{t('header.tables')} {t('header.tables')}
</NavigationTab> </NavigationTab>
)} )}
<NavigationTab value="storage" currentTab={currentTab}>
{t('header.storage')}
</NavigationTab>
</TabsList> </TabsList>
</div> </div>
) )

View file

@ -11,6 +11,8 @@
"knowledgeGraph": "شبكة المعرفة", "knowledgeGraph": "شبكة المعرفة",
"retrieval": "الاسترجاع", "retrieval": "الاسترجاع",
"api": "واجهة برمجة التطبيقات", "api": "واجهة برمجة التطبيقات",
"tables": "الجداول",
"storage": "التخزين",
"projectRepository": "مستودع المشروع", "projectRepository": "مستودع المشروع",
"logout": "تسجيل الخروج", "logout": "تسجيل الخروج",
"frontendNeedsRebuild": "الواجهة الأمامية تحتاج إلى إعادة البناء", "frontendNeedsRebuild": "الواجهة الأمامية تحتاج إلى إعادة البناء",
@ -134,7 +136,8 @@
"created": "تم الإنشاء", "created": "تم الإنشاء",
"updated": "تم التحديث", "updated": "تم التحديث",
"metadata": "البيانات الوصفية", "metadata": "البيانات الوصفية",
"select": "اختيار" "select": "اختيار",
"s3Key": "مفتاح S3"
}, },
"status": { "status": {
"all": "الكل", "all": "الكل",
@ -453,6 +456,39 @@
"placeholder": "أدخل مفتاح واجهة برمجة التطبيقات", "placeholder": "أدخل مفتاح واجهة برمجة التطبيقات",
"save": "حفظ" "save": "حفظ"
}, },
"storagePanel": {
"title": "متصفح التخزين",
"empty": "هذا المجلد فارغ",
"loadFailed": "فشل تحميل محتويات التخزين",
"count": "{{folders}} مجلد(ات)، {{objects}} ملف(ات)",
"uploadSuccess": "تم رفع {{name}} بنجاح",
"uploadFailed": "فشل الرفع: {{error}}",
"deleteSuccess": "تم حذف {{name}} بنجاح",
"deleteFailed": "فشل الحذف: {{error}}",
"downloadFailed": "فشل التحميل: {{error}}",
"table": {
"name": "الاسم",
"size": "الحجم",
"modified": "تاريخ التعديل",
"actions": "الإجراءات"
},
"actions": {
"upload": "رفع",
"download": "تحميل",
"delete": "حذف",
"retry": "إعادة المحاولة",
"view": "عرض"
},
"viewer": {
"pdfPreview": "معاينة PDF غير متاحة في المتصفح",
"openPdf": "فتح PDF",
"noPreview": "المعاينة غير متاحة لهذا النوع من الملفات"
},
"confirmDelete": {
"title": "تأكيد الحذف",
"description": "هل أنت متأكد من حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء."
}
},
"pagination": { "pagination": {
"showing": "عرض {{start}} إلى {{end}} من أصل {{total}} إدخالات", "showing": "عرض {{start}} إلى {{end}} من أصل {{total}} إدخالات",
"page": "الصفحة", "page": "الصفحة",

View file

@ -12,6 +12,7 @@
"retrieval": "Retrieval", "retrieval": "Retrieval",
"api": "API", "api": "API",
"tables": "Tables", "tables": "Tables",
"storage": "Storage",
"projectRepository": "Project Repository", "projectRepository": "Project Repository",
"logout": "Logout", "logout": "Logout",
"frontendNeedsRebuild": "Frontend needs rebuild", "frontendNeedsRebuild": "Frontend needs rebuild",
@ -142,7 +143,8 @@
"created": "Created", "created": "Created",
"updated": "Updated", "updated": "Updated",
"metadata": "Metadata", "metadata": "Metadata",
"select": "Select" "select": "Select",
"s3Key": "S3 Key"
}, },
"status": { "status": {
"all": "All", "all": "All",
@ -515,6 +517,39 @@
"placeholder": "Enter your API key", "placeholder": "Enter your API key",
"save": "Save" "save": "Save"
}, },
"storagePanel": {
"title": "Storage Browser",
"empty": "This folder is empty",
"loadFailed": "Failed to load storage contents",
"count": "{{folders}} folder(s), {{objects}} file(s)",
"uploadSuccess": "{{name}} uploaded successfully",
"uploadFailed": "Upload failed: {{error}}",
"deleteSuccess": "{{name}} deleted successfully",
"deleteFailed": "Delete failed: {{error}}",
"downloadFailed": "Download failed: {{error}}",
"table": {
"name": "Name",
"size": "Size",
"modified": "Modified",
"actions": "Actions"
},
"actions": {
"upload": "Upload",
"download": "Download",
"delete": "Delete",
"retry": "Retry",
"view": "View"
},
"viewer": {
"pdfPreview": "PDF preview is not available in the browser",
"openPdf": "Open PDF",
"noPreview": "Preview is not available for this file type"
},
"confirmDelete": {
"title": "Confirm Delete",
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone."
}
},
"pagination": { "pagination": {
"showing": "Showing {{start}} to {{end}} of {{total}} entries", "showing": "Showing {{start}} to {{end}} of {{total}} entries",
"page": "Page", "page": "Page",

View file

@ -11,6 +11,8 @@
"knowledgeGraph": "Graphe de connaissances", "knowledgeGraph": "Graphe de connaissances",
"retrieval": "Récupération", "retrieval": "Récupération",
"api": "API", "api": "API",
"tables": "Tables",
"storage": "Stockage",
"projectRepository": "Référentiel du projet", "projectRepository": "Référentiel du projet",
"logout": "Déconnexion", "logout": "Déconnexion",
"frontendNeedsRebuild": "Le frontend nécessite une reconstruction", "frontendNeedsRebuild": "Le frontend nécessite une reconstruction",
@ -134,7 +136,8 @@
"created": "Créé", "created": "Créé",
"updated": "Mis à jour", "updated": "Mis à jour",
"metadata": "Métadonnées", "metadata": "Métadonnées",
"select": "Sélectionner" "select": "Sélectionner",
"s3Key": "Clé S3"
}, },
"status": { "status": {
"all": "Tous", "all": "Tous",
@ -453,6 +456,39 @@
"placeholder": "Entrez votre clé API", "placeholder": "Entrez votre clé API",
"save": "Sauvegarder" "save": "Sauvegarder"
}, },
"storagePanel": {
"title": "Explorateur de stockage",
"empty": "Ce dossier est vide",
"loadFailed": "Échec du chargement du contenu de stockage",
"count": "{{folders}} dossier(s), {{objects}} fichier(s)",
"uploadSuccess": "{{name}} téléchargé avec succès",
"uploadFailed": "Échec du téléchargement : {{error}}",
"deleteSuccess": "{{name}} supprimé avec succès",
"deleteFailed": "Échec de la suppression : {{error}}",
"downloadFailed": "Échec du téléchargement : {{error}}",
"table": {
"name": "Nom",
"size": "Taille",
"modified": "Modifié",
"actions": "Actions"
},
"actions": {
"upload": "Télécharger",
"download": "Télécharger",
"delete": "Supprimer",
"retry": "Réessayer",
"view": "Voir"
},
"viewer": {
"pdfPreview": "L'aperçu PDF n'est pas disponible dans le navigateur",
"openPdf": "Ouvrir le PDF",
"noPreview": "L'aperçu n'est pas disponible pour ce type de fichier"
},
"confirmDelete": {
"title": "Confirmer la suppression",
"description": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible."
}
},
"pagination": { "pagination": {
"showing": "Affichage de {{start}} à {{end}} sur {{total}} entrées", "showing": "Affichage de {{start}} à {{end}} sur {{total}} entrées",
"page": "Page", "page": "Page",

View file

@ -12,6 +12,7 @@
"retrieval": "检索", "retrieval": "检索",
"api": "API", "api": "API",
"tables": "数据表", "tables": "数据表",
"storage": "存储",
"projectRepository": "项目仓库", "projectRepository": "项目仓库",
"logout": "退出登录", "logout": "退出登录",
"frontendNeedsRebuild": "前端代码需重新构建", "frontendNeedsRebuild": "前端代码需重新构建",
@ -135,7 +136,8 @@
"created": "创建时间", "created": "创建时间",
"updated": "更新时间", "updated": "更新时间",
"metadata": "元数据", "metadata": "元数据",
"select": "选择" "select": "选择",
"s3Key": "S3 存储路径"
}, },
"status": { "status": {
"all": "全部", "all": "全部",
@ -460,6 +462,39 @@
"placeholder": "请输入 API Key", "placeholder": "请输入 API Key",
"save": "保存" "save": "保存"
}, },
"storagePanel": {
"title": "存储浏览器",
"empty": "此文件夹为空",
"loadFailed": "加载存储内容失败",
"count": "{{folders}} 个文件夹,{{objects}} 个文件",
"uploadSuccess": "{{name}} 上传成功",
"uploadFailed": "上传失败:{{error}}",
"deleteSuccess": "{{name}} 删除成功",
"deleteFailed": "删除失败:{{error}}",
"downloadFailed": "下载失败:{{error}}",
"table": {
"name": "名称",
"size": "大小",
"modified": "修改时间",
"actions": "操作"
},
"actions": {
"upload": "上传",
"download": "下载",
"delete": "删除",
"retry": "重试",
"view": "查看"
},
"viewer": {
"pdfPreview": "浏览器中无法预览PDF",
"openPdf": "打开PDF",
"noPreview": "此文件类型不支持预览"
},
"confirmDelete": {
"title": "确认删除",
"description": "确定要删除 \"{{name}}\" 吗?此操作无法撤销。"
}
},
"pagination": { "pagination": {
"showing": "显示第 {{start}} 到 {{end}} 条,共 {{total}} 条记录", "showing": "显示第 {{start}} 到 {{end}} 条,共 {{total}} 条记录",
"page": "页", "page": "页",

View file

@ -11,6 +11,8 @@
"knowledgeGraph": "知識圖譜", "knowledgeGraph": "知識圖譜",
"retrieval": "檢索", "retrieval": "檢索",
"api": "API", "api": "API",
"tables": "資料表",
"storage": "儲存空間",
"projectRepository": "專案庫", "projectRepository": "專案庫",
"logout": "登出", "logout": "登出",
"frontendNeedsRebuild": "前端程式碼需重新建置", "frontendNeedsRebuild": "前端程式碼需重新建置",
@ -134,7 +136,8 @@
"created": "建立時間", "created": "建立時間",
"updated": "更新時間", "updated": "更新時間",
"metadata": "元資料", "metadata": "元資料",
"select": "選擇" "select": "選擇",
"s3Key": "S3 儲存路徑"
}, },
"status": { "status": {
"all": "全部", "all": "全部",
@ -453,6 +456,39 @@
"placeholder": "請輸入 API key", "placeholder": "請輸入 API key",
"save": "儲存" "save": "儲存"
}, },
"storagePanel": {
"title": "儲存空間瀏覽器",
"empty": "此資料夾為空",
"loadFailed": "載入儲存內容失敗",
"count": "{{folders}} 個資料夾,{{objects}} 個檔案",
"uploadSuccess": "{{name}} 上傳成功",
"uploadFailed": "上傳失敗:{{error}}",
"deleteSuccess": "{{name}} 刪除成功",
"deleteFailed": "刪除失敗:{{error}}",
"downloadFailed": "下載失敗:{{error}}",
"table": {
"name": "名稱",
"size": "大小",
"modified": "修改時間",
"actions": "操作"
},
"actions": {
"upload": "上傳",
"download": "下載",
"delete": "刪除",
"retry": "重試",
"view": "檢視"
},
"viewer": {
"pdfPreview": "瀏覽器中無法預覽PDF",
"openPdf": "開啟PDF",
"noPreview": "此檔案類型不支援預覽"
},
"confirmDelete": {
"title": "確認刪除",
"description": "確定要刪除 \"{{name}}\" 嗎?此操作無法復原。"
}
},
"pagination": { "pagination": {
"showing": "顯示第 {{start}} 到 {{end}} 筆,共 {{total}} 筆記錄", "showing": "顯示第 {{start}} 到 {{end}} 筆,共 {{total}} 筆記錄",
"page": "頁", "page": "頁",

View file

@ -15,7 +15,7 @@ const DEV_STORAGE_CONFIG = import.meta.env.DEV
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' | 'table-explorer' type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api' | 'table-explorer' | 'storage'
interface SettingsState { interface SettingsState {
// Document manager settings // Document manager settings

View file

@ -11,9 +11,9 @@ export default defineConfig({
alias: { alias: {
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src')
}, },
// Force all modules to use the same katex instance // Force all modules to use the same React and katex instances
// This ensures mhchem extension registered in main.tsx is available to rehype-katex // This prevents "Invalid hook call" errors from duplicate React copies
dedupe: ['katex'] dedupe: ['react', 'react-dom', 'katex']
}, },
// base: import.meta.env.VITE_BASE_URL || '/webui/', // base: import.meta.env.VITE_BASE_URL || '/webui/',
base: webuiPrefix, base: webuiPrefix,

View file

@ -1,5 +1,6 @@
{ {
// Expand scope to core package while keeping heavy optional providers excluded for now. "venv": ".venv",
"venvPath": ".",
"include": ["lightrag"], "include": ["lightrag"],
"exclude": [ "exclude": [
"examples", "examples",
@ -13,7 +14,7 @@
"lightrag/llm", "lightrag/llm",
"lightrag/evaluation" "lightrag/evaluation"
], ],
"reportMissingImports": "error", "reportMissingImports": "none",
"reportMissingModuleSource": "error", "reportMissingModuleSource": "none",
"reportMissingTypeStubs": "error" "reportMissingTypeStubs": "none"
} }

View file

@ -1,74 +0,0 @@
import asyncio
import numpy as np
from lightrag.citation import CitationResult, extract_citations_from_response
# Mock embedding function
async def mock_embedding_func(texts: list[str]) -> list[list[float]]:
embeddings = []
for text in texts:
text = text.lower()
vec = [0.0, 0.0, 0.0]
if 'sky' in text:
vec[0] = 1.0
if 'grass' in text:
vec[1] = 1.0
if 'water' in text:
vec[2] = 1.0
# Normalize
norm = np.linalg.norm(vec)
if norm > 0:
vec = [v / norm for v in vec]
embeddings.append(vec)
return embeddings
async def main():
# 1. Setup Chunks (The knowledge base)
chunks = [
{'id': 'chunk_1', 'content': 'The sky is usually blue during the day.', 'file_path': 'nature.txt'},
{'id': 'chunk_2', 'content': 'The grass is green because of chlorophyll.', 'file_path': 'biology.txt'},
{'id': 'chunk_3', 'content': 'Water is wet and essential for life.', 'file_path': 'chemistry.txt'},
]
# 2. Setup References (Map files/chunks to IDs)
references = [
{'reference_id': '1', 'file_path': 'nature.txt'},
{'reference_id': '2', 'file_path': 'biology.txt'},
{'reference_id': '3', 'file_path': 'chemistry.txt'},
]
# 3. Mock LLM Response
# Sentence 1: Matches chunk 1 (sky)
# Sentence 2: Matches chunk 2 (grass)
# Sentence 3: Matches chunk 3 (water) partially?
# Sentence 4: No match
response = 'The sky is blue. The grass is green. Water is wet. Computers are silicon.'
print('--- Running Citation Extraction ---')
result: CitationResult = await extract_citations_from_response(
response=response, chunks=chunks, references=references, embedding_func=mock_embedding_func, min_similarity=0.5
)
print(f'Original Response: {result.original_response}')
print(f'Annotated Response: {result.annotated_response}')
print('\nCitations Found:')
for cit in result.citations:
print(f" - Text: '{cit.text}'")
print(f' Refs: {cit.reference_ids} (Conf: {cit.confidence:.2f})')
print(f' Pos: {cit.start_char}-{cit.end_char}')
print('\nFootnotes:')
for fn in result.footnotes:
print(f' {fn}')
print('\nUncited Claims:')
for claim in result.uncited_claims:
print(f" '{claim}'")
if __name__ == '__main__':
asyncio.run(main())