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:
parent
082a5a8fad
commit
95c83abcf8
35 changed files with 10753 additions and 145 deletions
|
|
@ -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.ollama_api import OllamaAPI
|
||||
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.table_routes import create_table_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_namespace_data,
|
||||
)
|
||||
from lightrag.kg.postgres_impl import PGKVStorage
|
||||
from lightrag.storage.s3_client import S3Client, S3Config
|
||||
from lightrag.types import GPTKeywordExtractionFormat
|
||||
from lightrag.utils import EmbeddingFunc, get_env_value, logger, set_verbose_debug
|
||||
|
|
@ -396,7 +398,7 @@ def create_app(args):
|
|||
'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
|
||||
@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)
|
||||
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:
|
||||
app.include_router(create_upload_routes(rag, s3_client, api_key))
|
||||
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:
|
||||
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
|
||||
# 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'):
|
||||
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')
|
||||
else:
|
||||
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}')
|
||||
uvicorn.run(**cast(Any, uvicorn_config))
|
||||
update_uvicorn_mode_config()
|
||||
uvicorn.run(**cast(dict[str, Any], uvicorn_config))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from .document_routes import router as document_router
|
|||
from .graph_routes import router as graph_router
|
||||
from .ollama_api import OllamaAPI
|
||||
from .query_routes import router as query_router
|
||||
from .s3_routes import create_s3_routes
|
||||
from .search_routes import create_search_routes
|
||||
from .upload_routes import create_upload_routes
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ __all__ = [
|
|||
'document_router',
|
||||
'graph_router',
|
||||
'query_router',
|
||||
'create_s3_routes',
|
||||
'create_search_routes',
|
||||
'create_upload_routes',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -430,6 +430,7 @@ class DocStatusResponse(BaseModel):
|
|||
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')
|
||||
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:
|
||||
json_schema_extra: ClassVar[dict[str, Any]] = {
|
||||
|
|
@ -444,7 +445,8 @@ class DocStatusResponse(BaseModel):
|
|||
'chunks_count': 12,
|
||||
'error_msg': None,
|
||||
'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 shape in slide.shapes:
|
||||
if hasattr(shape, 'text'):
|
||||
content += cast(Any, shape).text + '\n'
|
||||
content += shape.text + '\n' # type: ignore
|
||||
return content
|
||||
|
||||
|
||||
|
|
@ -2463,6 +2465,7 @@ def create_document_routes(rag: LightRAG, doc_manager: DocumentManager, api_key:
|
|||
error_msg=doc_status.error_msg,
|
||||
metadata=doc_status.metadata,
|
||||
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,
|
||||
metadata=doc_status.metadata,
|
||||
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,
|
||||
metadata=doc.metadata,
|
||||
file_path=doc.file_path,
|
||||
s3_key=doc.s3_key,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -505,7 +505,11 @@ class OllamaAPI:
|
|||
if user_prompt is not None:
|
||||
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:
|
||||
# Determine if the request is prefix with "/bypass"
|
||||
|
|
|
|||
312
lightrag/api/routers/s3_routes.py
Normal file
312
lightrag/api/routers/s3_routes.py
Normal 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
|
||||
|
|
@ -8,7 +8,7 @@ This module provides endpoints for:
|
|||
"""
|
||||
|
||||
import mimetypes
|
||||
from typing import Annotated, Any, ClassVar
|
||||
from typing import Annotated, Any, ClassVar, cast
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -22,6 +22,7 @@ from pydantic import BaseModel, Field
|
|||
|
||||
from lightrag import LightRAG
|
||||
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.utils import compute_mdhash_id, logger
|
||||
|
||||
|
|
@ -176,10 +177,10 @@ def create_upload_routes(
|
|||
doc_id = compute_mdhash_id(content, prefix='doc_')
|
||||
|
||||
# Determine content type
|
||||
content_type = file.content_type
|
||||
if not content_type:
|
||||
content_type, _ = mimetypes.guess_type(file.filename or '')
|
||||
content_type = content_type or 'application/octet-stream'
|
||||
final_content_type = file.content_type
|
||||
if not final_content_type:
|
||||
guessed_type, encoding = mimetypes.guess_type(file.filename or '')
|
||||
final_content_type = guessed_type or 'application/octet-stream'
|
||||
|
||||
# Upload to S3 staging
|
||||
s3_key = await s3_client.upload_to_staging(
|
||||
|
|
@ -187,10 +188,10 @@ def create_upload_routes(
|
|||
doc_id=doc_id,
|
||||
content=content,
|
||||
filename=file.filename or f'{doc_id}.bin',
|
||||
content_type=content_type,
|
||||
content_type=final_content_type,
|
||||
metadata={
|
||||
'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
|
||||
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,
|
||||
s3_key=archive_key,
|
||||
archive_url=archive_url,
|
||||
)
|
||||
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:
|
||||
logger.warning(f'Failed to archive document: {e}')
|
||||
# Don't fail the request, processing succeeded
|
||||
|
|
|
|||
|
|
@ -716,6 +716,8 @@ class DocProcessingStatus:
|
|||
"""Error message if failed"""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"""Additional metadata"""
|
||||
s3_key: str | None = None
|
||||
"""S3 storage key for archived documents"""
|
||||
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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -836,10 +836,11 @@ class PostgreSQLDB:
|
|||
logger.warning(f'Failed to add llm_cache_list column to LIGHTRAG_DOC_CHUNKS: {e}')
|
||||
|
||||
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 = [
|
||||
('lightrag_doc_full', 'LIGHTRAG_DOC_FULL'),
|
||||
('lightrag_doc_chunks', 'LIGHTRAG_DOC_CHUNKS'),
|
||||
('lightrag_doc_status', 'LIGHTRAG_DOC_STATUS'),
|
||||
]
|
||||
|
||||
for table_name_lower, table_name in tables:
|
||||
|
|
@ -3049,6 +3050,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
|||
metadata=metadata,
|
||||
error_msg=element.get('error_msg'),
|
||||
track_id=element.get('track_id'),
|
||||
s3_key=element.get('s3_key'),
|
||||
)
|
||||
|
||||
return docs_by_status
|
||||
|
|
@ -3101,6 +3103,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
|||
track_id=element.get('track_id'),
|
||||
metadata=metadata,
|
||||
error_msg=element.get('error_msg'),
|
||||
s3_key=element.get('s3_key'),
|
||||
)
|
||||
|
||||
return docs_by_track_id
|
||||
|
|
@ -3213,6 +3216,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
|||
track_id=element.get('track_id'),
|
||||
metadata=metadata,
|
||||
error_msg=element.get('error_msg'),
|
||||
s3_key=element.get('s3_key'),
|
||||
)
|
||||
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}')
|
||||
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
|
||||
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)
|
||||
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
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,$14)
|
||||
on conflict(id,workspace) do update set
|
||||
content_summary = EXCLUDED.content_summary,
|
||||
content_length = EXCLUDED.content_length,
|
||||
|
|
@ -3342,6 +3346,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
|||
track_id = EXCLUDED.track_id,
|
||||
metadata = EXCLUDED.metadata,
|
||||
error_msg = EXCLUDED.error_msg,
|
||||
s3_key = EXCLUDED.s3_key,
|
||||
created_at = EXCLUDED.created_at,
|
||||
updated_at = EXCLUDED.updated_at"""
|
||||
|
||||
|
|
@ -3364,6 +3369,7 @@ class PGDocStatusStorage(DocStatusStorage):
|
|||
v.get('track_id'),
|
||||
json.dumps(v.get('metadata', {})),
|
||||
v.get('error_msg'),
|
||||
v.get('s3_key'),
|
||||
created_at,
|
||||
updated_at,
|
||||
)
|
||||
|
|
@ -3372,6 +3378,26 @@ class PGDocStatusStorage(DocStatusStorage):
|
|||
if 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]:
|
||||
"""Drop the storage"""
|
||||
try:
|
||||
|
|
@ -5305,6 +5331,7 @@ TABLES = {
|
|||
track_id varchar(255) NULL,
|
||||
metadata JSONB NULL DEFAULT '{}'::jsonb,
|
||||
error_msg TEXT NULL,
|
||||
s3_key TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT LIGHTRAG_DOC_STATUS_PK PRIMARY KEY (workspace, id)
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ def _handle_bedrock_exception(e: Exception, operation: str = 'Bedrock operation'
|
|||
|
||||
# Handle botocore ClientError with specific error codes
|
||||
if isinstance(e, ClientError):
|
||||
error_code = cast(Any, e).response.get('Error', {}).get('Code', '')
|
||||
error_msg = cast(Any, e).response.get('Error', {}).get('Message', error_message)
|
||||
error_code = cast(ClientError, e).response.get('Error', {}).get('Code', '')
|
||||
error_msg = cast(ClientError, e).response.get('Error', {}).get('Message', error_message)
|
||||
|
||||
# Rate limiting and throttling errors (retryable)
|
||||
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}')
|
||||
|
||||
# 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}')
|
||||
raise BedrockConnectionError(f'Server error: {error_msg}')
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def create_openai_async_client(
|
|||
if timeout is not None:
|
||||
merged_configs['timeout'] = timeout
|
||||
|
||||
return AsyncAzureOpenAI(**cast(Any, merged_configs))
|
||||
return AsyncAzureOpenAI(**cast(dict[str, Any], merged_configs))
|
||||
else:
|
||||
if not api_key:
|
||||
api_key = os.environ['OPENAI_API_KEY']
|
||||
|
|
@ -316,12 +316,9 @@ async def openai_complete_if_cache(
|
|||
raise
|
||||
|
||||
if hasattr(response, '__aiter__'):
|
||||
response = cast(Any, response)
|
||||
|
||||
async def inner():
|
||||
# Track if we've started iterating
|
||||
iteration_started = False
|
||||
final_chunk_usage = None
|
||||
async def collected_messages(response):
|
||||
async for chunk in response:
|
||||
chunk_message = chunk.choices[0].delta.content or ""
|
||||
|
||||
# COT (Chain of Thought) state tracking
|
||||
cot_active = False
|
||||
|
|
|
|||
|
|
@ -3211,9 +3211,9 @@ async def extract_entities(
|
|||
nonlocal processed_chunks
|
||||
chunk_key = chunk_key_dp[0]
|
||||
chunk_dp = chunk_key_dp[1]
|
||||
content = chunk_dp['content']
|
||||
content = chunk_dp.get('content', '')
|
||||
# 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
|
||||
cache_keys_collector = []
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ class S3Client:
|
|||
"""
|
||||
|
||||
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)
|
||||
|
||||
async def initialize(self):
|
||||
|
|
@ -166,13 +166,16 @@ class S3Client:
|
|||
@asynccontextmanager
|
||||
async def _get_client(self):
|
||||
"""Get an S3 client from the session."""
|
||||
if self._session is None:
|
||||
raise RuntimeError("S3Client not initialized")
|
||||
|
||||
boto_config = BotoConfig(
|
||||
connect_timeout=self.config.connect_timeout,
|
||||
read_timeout=self.config.read_timeout,
|
||||
retries={"max_attempts": S3_RETRY_ATTEMPTS},
|
||||
)
|
||||
|
||||
async with self._session.client(
|
||||
async with self._session.client( # type: ignore
|
||||
"s3",
|
||||
endpoint_url=self.config.endpoint_url if self.config.endpoint_url else None,
|
||||
config=boto_config,
|
||||
|
|
@ -388,3 +391,94 @@ class S3Client:
|
|||
def get_s3_url(self, s3_key: str) -> str:
|
||||
"""Get the S3 URL for an object (not presigned, for reference)."""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import colorsys
|
||||
import os
|
||||
import tkinter as tk
|
||||
import traceback
|
||||
from tkinter import filedialog
|
||||
import traceback
|
||||
from typing import cast
|
||||
|
||||
import community
|
||||
import glm
|
||||
from imgui_bundle import hello_imgui, imgui, immapp
|
||||
import moderngl
|
||||
import networkx as nx
|
||||
from networkx.classes.reportviews import DegreeView
|
||||
import numpy as np
|
||||
from imgui_bundle import hello_imgui, imgui, immapp
|
||||
|
||||
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):
|
||||
self.position = position
|
||||
self.color = color
|
||||
self.base_color = color # Initialize base_color
|
||||
self.label = label
|
||||
self.size = size
|
||||
self.idx = idx
|
||||
|
|
@ -32,7 +35,7 @@ class GraphViewer:
|
|||
"""Main class for 3D graph visualization"""
|
||||
|
||||
def __init__(self):
|
||||
self.glctx = None # ModernGL context
|
||||
self.glctx: moderngl.Context | None = None # ModernGL context
|
||||
self.graph: nx.Graph | None = None
|
||||
self.nodes: list[Node3D] = []
|
||||
self.id_node_map: dict[str, Node3D] = {}
|
||||
|
|
@ -81,7 +84,7 @@ class GraphViewer:
|
|||
self.node_id_fbo = None
|
||||
self.node_id_texture = 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
|
||||
self.sphere_data = create_sphere()
|
||||
|
|
@ -141,7 +144,7 @@ class GraphViewer:
|
|||
return
|
||||
|
||||
# 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]
|
||||
dy = self.last_mouse_pos[1] - mouse_pos[1] # Reversed for intuitive control
|
||||
|
||||
|
|
@ -192,6 +195,9 @@ class GraphViewer:
|
|||
|
||||
def update_layout(self):
|
||||
"""Update the graph layout"""
|
||||
if not self.graph:
|
||||
return
|
||||
|
||||
pos = nx.spring_layout(
|
||||
self.graph,
|
||||
dim=3,
|
||||
|
|
@ -215,7 +221,7 @@ class GraphViewer:
|
|||
node_data = self.graph.nodes[self.selected_node.label]
|
||||
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}')
|
||||
|
||||
for key, value in node_data.items():
|
||||
|
|
@ -246,7 +252,7 @@ class GraphViewer:
|
|||
for neighbor, edge_data in connections.items():
|
||||
imgui.table_next_row()
|
||||
imgui.table_set_column_index(0)
|
||||
if imgui.selectable(str(neighbor), True)[0]:
|
||||
if imgui.selectable(str(neighbor), True):
|
||||
# Select neighbor node
|
||||
self.selected_node = self.id_node_map[neighbor]
|
||||
self.position = self.selected_node.position - self.front
|
||||
|
|
@ -263,11 +269,16 @@ class GraphViewer:
|
|||
def setup_render_context(self):
|
||||
"""Initialize ModernGL context"""
|
||||
self.glctx = moderngl.create_context()
|
||||
if not self.glctx:
|
||||
return
|
||||
self.glctx.enable(moderngl.DEPTH_TEST | moderngl.CULL_FACE)
|
||||
self.glctx.clear_color = self.background_color
|
||||
|
||||
def setup_shaders(self):
|
||||
"""Setup vertex and fragment shaders for node and edge rendering"""
|
||||
if not self.glctx:
|
||||
return
|
||||
|
||||
# Node shader program
|
||||
self.node_prog = self.glctx.program(
|
||||
vertex_shader="""
|
||||
|
|
@ -544,7 +555,7 @@ class GraphViewer:
|
|||
pos = {node: coords * scale for node, coords in pos.items()}
|
||||
|
||||
# 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
|
||||
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:
|
||||
"""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]
|
||||
color = self.community_colors[comm_id]
|
||||
return color
|
||||
|
|
@ -585,7 +596,7 @@ class GraphViewer:
|
|||
|
||||
def update_buffers(self):
|
||||
"""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
|
||||
|
||||
# Update node buffers
|
||||
|
|
@ -773,16 +784,22 @@ class GraphViewer:
|
|||
# Convert to a PIL Image and save as PNG
|
||||
from PIL import Image
|
||||
|
||||
if self.node_id_texture_np is None:
|
||||
return
|
||||
|
||||
scaled_array = self.node_id_texture_np * 255
|
||||
img = Image.fromarray(
|
||||
scaled_array.astype(np.uint8),
|
||||
'RGBA',
|
||||
)
|
||||
img = img.transpose(method=Image.FLIP_TOP_BOTTOM)
|
||||
img = img.transpose(method=Image.Transpose.FLIP_TOP_BOTTOM) # type: ignore
|
||||
img.save(filename)
|
||||
|
||||
def render_id_map(self, mvp: glm.mat4):
|
||||
"""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
|
||||
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
|
||||
|
|
@ -802,7 +819,8 @@ class GraphViewer:
|
|||
self.node_id_texture_np = np.zeros((self.window_height, self.window_width, 4), dtype=np.float32)
|
||||
|
||||
# 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)
|
||||
|
||||
# Render nodes
|
||||
|
|
@ -813,10 +831,14 @@ class GraphViewer:
|
|||
|
||||
# Revert to default framebuffer
|
||||
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):
|
||||
"""Render the graph"""
|
||||
if not self.glctx:
|
||||
return
|
||||
|
||||
# Clear screen
|
||||
self.glctx.clear(*self.background_color, depth=1)
|
||||
|
||||
|
|
|
|||
|
|
@ -456,12 +456,14 @@ def compute_args_hash(*args: Any) -> str:
|
|||
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.
|
||||
"""
|
||||
if isinstance(content, bytes):
|
||||
return prefix + md5(content).hexdigest()
|
||||
return prefix + compute_args_hash(content)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@
|
|||
"react-i18next": "^16.3.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
"react-pdf": "^10.2.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"react-select": "^5.10.2",
|
||||
"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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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", "", {}, ""],
|
||||
|
||||
"marked": ["marked@16.4.0", "", { "bin": "bin/marked.js" }, ""],
|
||||
|
|
@ -1277,6 +1305,8 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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", "", {}, ""],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, ""],
|
||||
|
||||
"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-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-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-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", "", {}, ""],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.1", "", {}, ""],
|
||||
|
||||
"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", "", {}, ""],
|
||||
|
||||
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, ""],
|
||||
|
||||
"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", "", {}, ""],
|
||||
|
||||
"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-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" }, ""],
|
||||
|
||||
"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-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, ""],
|
||||
|
|
@ -1859,8 +1895,6 @@
|
|||
|
||||
"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", "", {}, ""],
|
||||
|
||||
"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" } }, ""],
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@
|
|||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"i18next": "^25.6.3",
|
||||
"katex": "^0.16.25",
|
||||
"mermaid": "^11.12.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"mermaid": "^11.12.1",
|
||||
"minisearch": "^7.2.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
|
@ -65,6 +65,8 @@
|
|||
"react-i18next": "^16.3.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
"react-pdf": "^10.2.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
|
|
@ -106,10 +108,10 @@
|
|||
"graphology-types": "^0.24.8",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8266
lightrag_webui/pnpm-lock.yaml
generated
Normal file
8266
lightrag_webui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,7 @@ import ApiSite from '@/features/ApiSite'
|
|||
import DocumentManager from '@/features/DocumentManager'
|
||||
import GraphViewer from '@/features/GraphViewer'
|
||||
import RetrievalTesting from '@/features/RetrievalTesting'
|
||||
import S3Browser from '@/features/S3Browser'
|
||||
import TableExplorer from '@/features/TableExplorer'
|
||||
|
||||
import { Tabs, TabsContent } from '@/components/ui/Tabs'
|
||||
|
|
@ -244,6 +245,12 @@ function App() {
|
|||
>
|
||||
<TableExplorer />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="storage"
|
||||
className="absolute top-0 right-0 bottom-0 left-0 overflow-hidden"
|
||||
>
|
||||
<S3Browser />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
<ApiKeyAlert open={apiKeyAlertOpen} onOpenChange={handleApiKeyAlertOpenChange} />
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ export type DocStatusResponse = {
|
|||
error_msg?: string
|
||||
metadata?: Record<string, any>
|
||||
file_path: string
|
||||
s3_key?: string
|
||||
}
|
||||
|
||||
export type DocsStatusesResponse = {
|
||||
|
|
@ -1180,3 +1181,91 @@ export const getTableData = async (
|
|||
})
|
||||
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
|
||||
}
|
||||
|
|
|
|||
431
lightrag_webui/src/components/storage/FileViewer.tsx
Normal file
431
lightrag_webui/src/components/storage/FileViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
618
lightrag_webui/src/components/storage/PDFViewer.tsx
Normal file
618
lightrag_webui/src/components/storage/PDFViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
lightrag_webui/src/components/ui/Resizable.tsx
Normal file
40
lightrag_webui/src/components/ui/Resizable.tsx
Normal 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 }
|
||||
123
lightrag_webui/src/components/ui/Sheet.tsx
Normal file
123
lightrag_webui/src/components/ui/Sheet.tsx
Normal 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,
|
||||
}
|
||||
|
|
@ -1419,6 +1419,7 @@ export default function DocumentManager() {
|
|||
<TableHead>{t('documentPanel.documentManager.columns.status')}</TableHead>
|
||||
<TableHead>{t('documentPanel.documentManager.columns.length')}</TableHead>
|
||||
<TableHead>{t('documentPanel.documentManager.columns.chunks')}</TableHead>
|
||||
<TableHead>{t('documentPanel.documentManager.columns.s3Key')}</TableHead>
|
||||
<TableHead
|
||||
onClick={() => handleSort('created_at')}
|
||||
className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 select-none"
|
||||
|
|
@ -1546,6 +1547,20 @@ export default function DocumentManager() {
|
|||
</TableCell>
|
||||
<TableCell>{doc.content_length ?? '-'}</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">
|
||||
{new Date(doc.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
|
|
|
|||
399
lightrag_webui/src/features/S3Browser.tsx
Normal file
399
lightrag_webui/src/features/S3Browser.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -70,6 +70,9 @@ function TabsNavigation() {
|
|||
{t('header.tables')}
|
||||
</NavigationTab>
|
||||
)}
|
||||
<NavigationTab value="storage" currentTab={currentTab}>
|
||||
{t('header.storage')}
|
||||
</NavigationTab>
|
||||
</TabsList>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
"knowledgeGraph": "شبكة المعرفة",
|
||||
"retrieval": "الاسترجاع",
|
||||
"api": "واجهة برمجة التطبيقات",
|
||||
"tables": "الجداول",
|
||||
"storage": "التخزين",
|
||||
"projectRepository": "مستودع المشروع",
|
||||
"logout": "تسجيل الخروج",
|
||||
"frontendNeedsRebuild": "الواجهة الأمامية تحتاج إلى إعادة البناء",
|
||||
|
|
@ -134,7 +136,8 @@
|
|||
"created": "تم الإنشاء",
|
||||
"updated": "تم التحديث",
|
||||
"metadata": "البيانات الوصفية",
|
||||
"select": "اختيار"
|
||||
"select": "اختيار",
|
||||
"s3Key": "مفتاح S3"
|
||||
},
|
||||
"status": {
|
||||
"all": "الكل",
|
||||
|
|
@ -453,6 +456,39 @@
|
|||
"placeholder": "أدخل مفتاح واجهة برمجة التطبيقات",
|
||||
"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": {
|
||||
"showing": "عرض {{start}} إلى {{end}} من أصل {{total}} إدخالات",
|
||||
"page": "الصفحة",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"retrieval": "Retrieval",
|
||||
"api": "API",
|
||||
"tables": "Tables",
|
||||
"storage": "Storage",
|
||||
"projectRepository": "Project Repository",
|
||||
"logout": "Logout",
|
||||
"frontendNeedsRebuild": "Frontend needs rebuild",
|
||||
|
|
@ -142,7 +143,8 @@
|
|||
"created": "Created",
|
||||
"updated": "Updated",
|
||||
"metadata": "Metadata",
|
||||
"select": "Select"
|
||||
"select": "Select",
|
||||
"s3Key": "S3 Key"
|
||||
},
|
||||
"status": {
|
||||
"all": "All",
|
||||
|
|
@ -515,6 +517,39 @@
|
|||
"placeholder": "Enter your API key",
|
||||
"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": {
|
||||
"showing": "Showing {{start}} to {{end}} of {{total}} entries",
|
||||
"page": "Page",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
"knowledgeGraph": "Graphe de connaissances",
|
||||
"retrieval": "Récupération",
|
||||
"api": "API",
|
||||
"tables": "Tables",
|
||||
"storage": "Stockage",
|
||||
"projectRepository": "Référentiel du projet",
|
||||
"logout": "Déconnexion",
|
||||
"frontendNeedsRebuild": "Le frontend nécessite une reconstruction",
|
||||
|
|
@ -134,7 +136,8 @@
|
|||
"created": "Créé",
|
||||
"updated": "Mis à jour",
|
||||
"metadata": "Métadonnées",
|
||||
"select": "Sélectionner"
|
||||
"select": "Sélectionner",
|
||||
"s3Key": "Clé S3"
|
||||
},
|
||||
"status": {
|
||||
"all": "Tous",
|
||||
|
|
@ -453,6 +456,39 @@
|
|||
"placeholder": "Entrez votre clé API",
|
||||
"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": {
|
||||
"showing": "Affichage de {{start}} à {{end}} sur {{total}} entrées",
|
||||
"page": "Page",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"retrieval": "检索",
|
||||
"api": "API",
|
||||
"tables": "数据表",
|
||||
"storage": "存储",
|
||||
"projectRepository": "项目仓库",
|
||||
"logout": "退出登录",
|
||||
"frontendNeedsRebuild": "前端代码需重新构建",
|
||||
|
|
@ -135,7 +136,8 @@
|
|||
"created": "创建时间",
|
||||
"updated": "更新时间",
|
||||
"metadata": "元数据",
|
||||
"select": "选择"
|
||||
"select": "选择",
|
||||
"s3Key": "S3 存储路径"
|
||||
},
|
||||
"status": {
|
||||
"all": "全部",
|
||||
|
|
@ -460,6 +462,39 @@
|
|||
"placeholder": "请输入 API Key",
|
||||
"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": {
|
||||
"showing": "显示第 {{start}} 到 {{end}} 条,共 {{total}} 条记录",
|
||||
"page": "页",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
"knowledgeGraph": "知識圖譜",
|
||||
"retrieval": "檢索",
|
||||
"api": "API",
|
||||
"tables": "資料表",
|
||||
"storage": "儲存空間",
|
||||
"projectRepository": "專案庫",
|
||||
"logout": "登出",
|
||||
"frontendNeedsRebuild": "前端程式碼需重新建置",
|
||||
|
|
@ -134,7 +136,8 @@
|
|||
"created": "建立時間",
|
||||
"updated": "更新時間",
|
||||
"metadata": "元資料",
|
||||
"select": "選擇"
|
||||
"select": "選擇",
|
||||
"s3Key": "S3 儲存路徑"
|
||||
},
|
||||
"status": {
|
||||
"all": "全部",
|
||||
|
|
@ -453,6 +456,39 @@
|
|||
"placeholder": "請輸入 API key",
|
||||
"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": {
|
||||
"showing": "顯示第 {{start}} 到 {{end}} 筆,共 {{total}} 筆記錄",
|
||||
"page": "頁",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const DEV_STORAGE_CONFIG = import.meta.env.DEV
|
|||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
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 {
|
||||
// Document manager settings
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ export default defineConfig({
|
|||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
},
|
||||
// Force all modules to use the same katex instance
|
||||
// This ensures mhchem extension registered in main.tsx is available to rehype-katex
|
||||
dedupe: ['katex']
|
||||
// Force all modules to use the same React and katex instances
|
||||
// This prevents "Invalid hook call" errors from duplicate React copies
|
||||
dedupe: ['react', 'react-dom', 'katex']
|
||||
},
|
||||
// base: import.meta.env.VITE_BASE_URL || '/webui/',
|
||||
base: webuiPrefix,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
// Expand scope to core package while keeping heavy optional providers excluded for now.
|
||||
"venv": ".venv",
|
||||
"venvPath": ".",
|
||||
"include": ["lightrag"],
|
||||
"exclude": [
|
||||
"examples",
|
||||
|
|
@ -13,7 +14,7 @@
|
|||
"lightrag/llm",
|
||||
"lightrag/evaluation"
|
||||
],
|
||||
"reportMissingImports": "error",
|
||||
"reportMissingModuleSource": "error",
|
||||
"reportMissingTypeStubs": "error"
|
||||
"reportMissingImports": "none",
|
||||
"reportMissingModuleSource": "none",
|
||||
"reportMissingTypeStubs": "none"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
Loading…
Add table
Reference in a new issue