Merge branch 'main' into duplicate_dev

This commit is contained in:
FloretKu 2025-11-04 13:40:52 +08:00 committed by GitHub
commit cf6bed7dc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 3637 additions and 1991 deletions

15
.gitignore vendored
View file

@ -46,10 +46,8 @@ neo4jWorkDir/
# Data & Storage
inputs/
output/
rag_storage/
examples/input/
examples/output/
output*/
data/
# Miscellaneous
@ -59,18 +57,15 @@ ignore_this.txt
*.ignore.*
# Project-specific files
dickens*/
book.txt
LightRAG.pdf
/dickens*/
/book.txt
download_models_hf.py
lightrag-dev/
gui/
# Frontend build output (built during PyPI release)
lightrag/api/webui/
/lightrag/api/webui/
# unit-test files
test_*
# Cline files
memory-bank/
memory-bank

View file

@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1
# Frontend build stage
FROM oven/bun:1 AS frontend-builder
@ -7,7 +9,8 @@ WORKDIR /app
COPY lightrag_webui/ ./lightrag_webui/
# Build frontend assets for inclusion in the API package
RUN cd lightrag_webui \
RUN --mount=type=cache,target=/root/.bun/install/cache \
cd lightrag_webui \
&& bun install --frozen-lockfile \
&& bun run build
@ -40,7 +43,8 @@ COPY setup.py .
COPY uv.lock .
# Install base, API, and offline extras without the project to improve caching
RUN uv sync --frozen --no-dev --extra api --extra offline --no-install-project --no-editable
RUN --mount=type=cache,target=/root/.local/share/uv \
uv sync --frozen --no-dev --extra api --extra offline --no-install-project --no-editable
# Copy project sources after dependency layer
COPY lightrag/ ./lightrag/
@ -49,7 +53,8 @@ COPY lightrag/ ./lightrag/
COPY --from=frontend-builder /app/lightrag/api/webui ./lightrag/api/webui
# Sync project in non-editable mode and ensure pip is available for runtime installs
RUN uv sync --frozen --no-dev --extra api --extra offline --no-editable \
RUN --mount=type=cache,target=/root/.local/share/uv \
uv sync --frozen --no-dev --extra api --extra offline --no-editable \
&& /app/.venv/bin/python -m ensurepip --upgrade
# Prepare offline cache directory and pre-populate tiktoken data
@ -81,7 +86,8 @@ ENV PATH=/app/.venv/bin:/root/.local/bin:$PATH
# Install dependencies with uv sync (uses locked versions from uv.lock)
# And ensure pip is available for runtime installs
RUN uv sync --frozen --no-dev --extra api --extra offline --no-editable \
RUN --mount=type=cache,target=/root/.local/share/uv \
uv sync --frozen --no-dev --extra api --extra offline --no-editable \
&& /app/.venv/bin/python -m ensurepip --upgrade
# Create persistent data directories AFTER package installation

View file

@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1
# Frontend build stage
FROM oven/bun:1 AS frontend-builder
@ -7,7 +9,8 @@ WORKDIR /app
COPY lightrag_webui/ ./lightrag_webui/
# Build frontend assets for inclusion in the API package
RUN cd lightrag_webui \
RUN --mount=type=cache,target=/root/.bun/install/cache \
cd lightrag_webui \
&& bun install --frozen-lockfile \
&& bun run build
@ -40,7 +43,8 @@ COPY setup.py .
COPY uv.lock .
# Install project dependencies (base + API extras) without the project to improve caching
RUN uv sync --frozen --no-dev --extra api --no-install-project --no-editable
RUN --mount=type=cache,target=/root/.local/share/uv \
uv sync --frozen --no-dev --extra api --no-install-project --no-editable
# Copy project sources after dependency layer
COPY lightrag/ ./lightrag/
@ -49,7 +53,8 @@ COPY lightrag/ ./lightrag/
COPY --from=frontend-builder /app/lightrag/api/webui ./lightrag/api/webui
# Sync project in non-editable mode and ensure pip is available for runtime installs
RUN uv sync --frozen --no-dev --extra api --no-editable \
RUN --mount=type=cache,target=/root/.local/share/uv \
uv sync --frozen --no-dev --extra api --no-editable \
&& /app/.venv/bin/python -m ensurepip --upgrade
# Prepare tiktoken cache directory and pre-populate tokenizer data
@ -81,7 +86,8 @@ ENV PATH=/app/.venv/bin:/root/.local/bin:$PATH
# Sync dependencies inside the final image using uv
# And ensure pip is available for runtime installs
RUN uv sync --frozen --no-dev --extra api --no-editable \
RUN --mount=type=cache,target=/root/.local/share/uv \
uv sync --frozen --no-dev --extra api --no-editable \
&& /app/.venv/bin/python -m ensurepip --upgrade
# Create persistent data directories

View file

@ -1,3 +1,4 @@
include requirements.txt
include lightrag/api/requirements.txt
recursive-include lightrag/api/webui *
recursive-include lightrag/api/static *

View file

@ -104,18 +104,26 @@ lightrag-server
git clone https://github.com/HKUDS/LightRAG.git
cd LightRAG
# 如有必要创建Python虚拟环境
# 以可编辑模式安装并支持API
# 以可开发编辑模式安装LightRAG服务器
pip install -e ".[api]"
cp env.example .env
cp env.example .env # 使用你的LLM和Embedding模型访问参数更新.env文件
# 构建前端代码
cd lightrag_webui
bun install --frozen-lockfile
bun run build
cd ..
lightrag-server
```
* 使用 Docker Compose 启动 LightRAG 服务器
```
```bash
git clone https://github.com/HKUDS/LightRAG.git
cd LightRAG
cp env.example .env
cp env.example .env # 使用你的LLM和Embedding模型访问参数更新.env文件
# modify LLM and Embedding settings in .env
docker compose up
```

View file

@ -103,19 +103,27 @@ lightrag-server
```bash
git clone https://github.com/HKUDS/LightRAG.git
cd LightRAG
# create a Python virtual enviroment if neccesary
# Create a Python virtual enviroment if neccesary
# Install in editable mode with API support
pip install -e ".[api]"
cp env.example .env
cp env.example .env # Update the .env with your LLM and embedding configurations
# Build front-end artifacts
cd lightrag_webui
bun install --frozen-lockfile
bun run build
cd ..
lightrag-server
```
* Launching the LightRAG Server with Docker Compose
```
```bash
git clone https://github.com/HKUDS/LightRAG.git
cd LightRAG
cp env.example .env
cp env.example .env # Update the .env with your LLM and embedding configurations
# modify LLM and Embedding settings in .env
docker compose up
```
@ -933,7 +941,8 @@ maxclients 500
The `workspace` parameter ensures data isolation between different LightRAG instances. Once initialized, the `workspace` is immutable and cannot be changed.Here is how workspaces are implemented for different types of storage:
- **For local file-based databases, data isolation is achieved through workspace subdirectories:** `JsonKVStorage`, `JsonDocStatusStorage`, `NetworkXStorage`, `NanoVectorDBStorage`, `FaissVectorDBStorage`.
- **For databases that store data in collections, it's done by adding a workspace prefix to the collection name:** `RedisKVStorage`, `RedisDocStatusStorage`, `MilvusVectorDBStorage`, `QdrantVectorDBStorage`, `MongoKVStorage`, `MongoDocStatusStorage`, `MongoVectorDBStorage`, `MongoGraphStorage`, `PGGraphStorage`.
- **For databases that store data in collections, it's done by adding a workspace prefix to the collection name:** `RedisKVStorage`, `RedisDocStatusStorage`, `MilvusVectorDBStorage`, `MongoKVStorage`, `MongoDocStatusStorage`, `MongoVectorDBStorage`, `MongoGraphStorage`, `PGGraphStorage`.
- **For Qdrant vector database, data isolation is achieved through payload-based partitioning (Qdrant's recommended multitenancy approach):** `QdrantVectorDBStorage` uses shared collections with payload filtering for unlimited workspace scalability.
- **For relational databases, data isolation is achieved by adding a `workspace` field to the tables for logical data separation:** `PGKVStorage`, `PGVectorStorage`, `PGDocStatusStorage`.
- **For the Neo4j graph database, logical data isolation is achieved through labels:** `Neo4JStorage`
@ -1538,7 +1547,7 @@ The dataset used in LightRAG can be downloaded from [TommyChien/UltraDomain](htt
### Generate Query
LightRAG uses the following prompt to generate high-level queries, with the corresponding code in `example/generate_query.py`.
LightRAG uses the following prompt to generate high-level queries, with the corresponding code in `examples/generate_query.py`.
<details>
<summary> Prompt </summary>

View file

@ -59,10 +59,19 @@ LightRAG can be configured using environment variables in the `.env` file:
Docker instructions work the same on all platforms with Docker Desktop installed.
### Build Optimization
The Dockerfile uses BuildKit cache mounts to significantly improve build performance:
- **Automatic cache management**: BuildKit is automatically enabled via `# syntax=docker/dockerfile:1` directive
- **Faster rebuilds**: Only downloads changed dependencies when `uv.lock` or `bun.lock` files are modified
- **Efficient package caching**: UV and Bun package downloads are cached across builds
- **No manual configuration needed**: Works out of the box in Docker Compose and GitHub Actions
### Start LightRAG server:
```bash
docker-compose up -d
docker compose up -d
```
LightRAG Server uses the following paths for data storage:
@ -77,9 +86,9 @@ data/
To update the Docker container:
```bash
docker-compose pull
docker-compose down
docker-compose up
docker compose pull
docker compose down
docker compose up
```
### Offline deployment
@ -91,10 +100,15 @@ Software packages requiring `transformers`, `torch`, or `cuda` will is not prein
### For local development and testing
```bash
# Build and run with docker-compose
# Build and run with Docker Compose (BuildKit automatically enabled)
docker compose up --build
# Or explicitly enable BuildKit if needed
DOCKER_BUILDKIT=1 docker compose up --build
```
**Note**: BuildKit is automatically enabled by the `# syntax=docker/dockerfile:1` directive in the Dockerfile, ensuring optimal caching performance.
### For production release
**multi-architecture build and push**:

View file

@ -119,6 +119,9 @@ ENABLE_LLM_CACHE_FOR_EXTRACT=true
### Document processing output language: English, Chinese, French, German ...
SUMMARY_LANGUAGE=English
### PDF decryption password for protected PDF files
# PDF_DECRYPT_PASSWORD=your_pdf_password_here
### Entity types that the LLM will attempt to recognize
# ENTITY_TYPES='["Person", "Creature", "Organization", "Location", "Event", "Concept", "Method", "Content", "Data", "Artifact", "NaturalObject"]'
@ -163,10 +166,11 @@ MAX_PARALLEL_INSERT=2
### Num of chunks send to Embedding in single request
# EMBEDDING_BATCH_NUM=10
###########################################################
###########################################################################
### LLM Configuration
### LLM_BINDING type: openai, ollama, lollms, azure_openai, aws_bedrock
###########################################################
### LLM_BINDING_HOST: host only for Ollama, endpoint for other LLM service
###########################################################################
### LLM request timeout setting for all llm (0 means no timeout for Ollma)
# LLM_TIMEOUT=180
@ -221,10 +225,11 @@ OLLAMA_LLM_NUM_CTX=32768
### Bedrock Specific Parameters
# BEDROCK_LLM_TEMPERATURE=1.0
####################################################################################
#######################################################################################
### Embedding Configuration (Should not be changed after the first file processed)
### EMBEDDING_BINDING: ollama, openai, azure_openai, jina, lollms, aws_bedrock
####################################################################################
### EMBEDDING_BINDING_HOST: host only for Ollama, endpoint for other Embedding service
#######################################################################################
# EMBEDDING_TIMEOUT=30
EMBEDDING_BINDING=ollama
EMBEDDING_MODEL=bge-m3:latest

View file

@ -0,0 +1,55 @@
from openai import OpenAI
# os.environ["OPENAI_API_KEY"] = ""
def openai_complete_if_cache(
model="gpt-4o-mini", prompt=None, system_prompt=None, history_messages=[], **kwargs
) -> str:
openai_client = OpenAI()
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.extend(history_messages)
messages.append({"role": "user", "content": prompt})
response = openai_client.chat.completions.create(
model=model, messages=messages, **kwargs
)
return response.choices[0].message.content
if __name__ == "__main__":
description = ""
prompt = f"""
Given the following description of a dataset:
{description}
Please identify 5 potential users who would engage with this dataset. For each user, list 5 tasks they would perform with this dataset. Then, for each (user, task) combination, generate 5 questions that require a high-level understanding of the entire dataset.
Output the results in the following structure:
- User 1: [user description]
- Task 1: [task description]
- Question 1:
- Question 2:
- Question 3:
- Question 4:
- Question 5:
- Task 2: [task description]
...
- Task 5: [task description]
- User 2: [user description]
...
- User 5: [user description]
...
"""
result = openai_complete_if_cache(model="gpt-4o-mini", prompt=prompt)
file_path = "./queries.txt"
with open(file_path, "w") as file:
file.write(result)
print(f"Queries written to {file_path}")

View file

@ -1,4 +0,0 @@
#!/bin/bash
source /home/netman/lightrag-xyj/venv/bin/activate
lightrag-server

View file

@ -1,5 +1,5 @@
[Unit]
Description=LightRAG XYJ Ollama Service
Description=LightRAG XYJ Service
After=network.target
[Service]
@ -8,10 +8,23 @@ User=netman
# Memory settings
MemoryHigh=8G
MemoryMax=12G
WorkingDirectory=/home/netman/lightrag-xyj
ExecStart=/home/netman/lightrag-xyj/lightrag-api
# Set the LightRAG installation directory (change this to match your installation path)
Environment="LIGHTRAG_HOME=/home/netman/lightrag-xyj"
# Set Environment to your Python virtual environment
Environment="PATH=${LIGHTRAG_HOME}/.venv/bin"
WorkingDirectory=${LIGHTRAG_HOME}
ExecStart=${LIGHTRAG_HOME}/.venv/bin/lightrag-server
# ExecStart=${LIGHTRAG_HOME}/.venv/bin/lightrag-gunicorn
# Kill mode require ExecStart must be gunicorn or unvicorn main process
KillMode=process
ExecStop=/bin/kill -s TERM $MAINPID
TimeoutStopSec=60
Restart=always
RestartSec=10
RestartSec=30
[Install]
WantedBy=multi-user.target

View file

@ -1,5 +1,5 @@
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
__version__ = "1.4.9.5"
__version__ = "1.4.9.8"
__author__ = "Zirui Guo"
__url__ = "https://github.com/HKUDS/LightRAG"

View file

@ -184,24 +184,16 @@ MAX_ASYNC=4
### 将 Lightrag 安装为 Linux 服务
从示例文件 `lightrag.service.example` 创建您的服务文件 `lightrag.service`。修改服务文件中的 WorkingDirectory 和 ExecStart
从示例文件 `lightrag.service.example` 创建您的服务文件 `lightrag.service`。修改服务文件中的服务启动定义
```text
Description=LightRAG Ollama Service
WorkingDirectory=<lightrag 安装目录>
ExecStart=<lightrag 安装目录>/lightrag/api/lightrag-api
```
修改您的服务启动脚本:`lightrag-api`。根据需要更改 python 虚拟环境激活命令:
```shell
#!/bin/bash
# 您的 python 虚拟环境激活命令
source /home/netman/lightrag-xyj/venv/bin/activate
# 启动 lightrag api 服务器
lightrag-server
# Set Enviroment to your Python virtual enviroment
Environment="PATH=/home/netman/lightrag-xyj/venv/bin"
WorkingDirectory=/home/netman/lightrag-xyj
# ExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-server
ExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-gunicorn
```
> ExecStart命令必须是 lightrag-gunicorn 或 lightrag-server 中的一个,不能使用其它脚本包裹它们。因为停止服务必须要求主进程必须是这两个进程。
安装 LightRAG 服务。如果您的系统是 Ubuntu以下命令将生效

View file

@ -165,7 +165,8 @@ Configuring an independent working directory and a dedicated `.env` configuratio
The command-line `workspace` argument and the `WORKSPACE` environment variable in the `.env` file can both be used to specify the workspace name for the current instance, with the command-line argument having higher priority. Here is how workspaces are implemented for different types of storage:
- **For local file-based databases, data isolation is achieved through workspace subdirectories:** `JsonKVStorage`, `JsonDocStatusStorage`, `NetworkXStorage`, `NanoVectorDBStorage`, `FaissVectorDBStorage`.
- **For databases that store data in collections, it's done by adding a workspace prefix to the collection name:** `RedisKVStorage`, `RedisDocStatusStorage`, `MilvusVectorDBStorage`, `QdrantVectorDBStorage`, `MongoKVStorage`, `MongoDocStatusStorage`, `MongoVectorDBStorage`, `MongoGraphStorage`, `PGGraphStorage`.
- **For databases that store data in collections, it's done by adding a workspace prefix to the collection name:** `RedisKVStorage`, `RedisDocStatusStorage`, `MilvusVectorDBStorage`, `MongoKVStorage`, `MongoDocStatusStorage`, `MongoVectorDBStorage`, `MongoGraphStorage`, `PGGraphStorage`.
- **For Qdrant vector database, data isolation is achieved through payload-based partitioning (Qdrant's recommended multitenancy approach):** `QdrantVectorDBStorage` uses shared collections with payload filtering for unlimited workspace scalability.
- **For relational databases, data isolation is achieved by adding a `workspace` field to the tables for logical data separation:** `PGKVStorage`, `PGVectorStorage`, `PGDocStatusStorage`.
- **For graph databases, logical data isolation is achieved through labels:** `Neo4JStorage`, `MemgraphStorage`
@ -188,24 +189,18 @@ MAX_ASYNC=4
### Install LightRAG as a Linux Service
Create your service file `lightrag.service` from the sample file: `lightrag.service.example`. Modify the `WorkingDirectory` and `ExecStart` in the service file:
Create your service file `lightrag.service` from the sample file: `lightrag.service.example`. Modify the start options the service file:
```text
Description=LightRAG Ollama Service
WorkingDirectory=<lightrag installed directory>
ExecStart=<lightrag installed directory>/lightrag/api/lightrag-api
# Set Enviroment to your Python virtual enviroment
Environment="PATH=/home/netman/lightrag-xyj/venv/bin"
WorkingDirectory=/home/netman/lightrag-xyj
# ExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-server
ExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-gunicorn
```
Modify your service startup script: `lightrag-api`. Change your Python virtual environment activation command as needed:
```shell
#!/bin/bash
# your python virtual environment activation
source /home/netman/lightrag-xyj/venv/bin/activate
# start lightrag api server
lightrag-server
```
> The ExecStart command must be either `lightrag-gunicorn` or `lightrag-server`; no wrapper scripts are allowed. This is because service termination requires the main process to be one of these two executables.
Install LightRAG service. If your system is Ubuntu, the following commands will work:

View file

@ -1 +1 @@
__api_version__ = "0245"
__api_version__ = "0250"

View file

@ -342,6 +342,9 @@ def parse_args() -> argparse.Namespace:
# Select Document loading tool (DOCLING, DEFAULT)
args.document_loading_engine = get_env_value("DOCUMENT_LOADING_ENGINE", "DEFAULT")
# PDF decryption password
args.pdf_decrypt_password = get_env_value("PDF_DECRYPT_PASSWORD", None)
# Add environment variables that were previously read directly
args.cors_origins = get_env_value("CORS_ORIGINS", "*")
args.summary_language = get_env_value("SUMMARY_LANGUAGE", DEFAULT_SUMMARY_LANGUAGE)

View file

@ -129,12 +129,10 @@ def on_exit(server):
print("=" * 80)
print("GUNICORN MASTER PROCESS: Shutting down")
print(f"Process ID: {os.getpid()}")
print("=" * 80)
# Release shared resources
print("Finalizing shared storage...")
finalize_share_data()
print("=" * 80)
print("Gunicorn shutdown complete")
print("=" * 80)

View file

@ -5,10 +5,13 @@ LightRAG FastAPI Server
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.openapi.docs import (
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html,
)
import os
import logging
import logging.config
import signal
import sys
import uvicorn
import pipmaster as pm
@ -78,24 +81,6 @@ config.read("config.ini")
auth_configured = bool(auth_handler.accounts)
def setup_signal_handlers():
"""Setup signal handlers for graceful shutdown"""
def signal_handler(sig, frame):
print(f"\n\nReceived signal {sig}, shutting down gracefully...")
print(f"Process ID: {os.getpid()}")
# Release shared resources
finalize_share_data()
# Exit with success status
sys.exit(0)
# Register signal handlers
signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
signal.signal(signal.SIGTERM, signal_handler) # kill command
class LLMConfigCache:
"""Smart LLM and Embedding configuration cache class"""
@ -146,7 +131,11 @@ class LLMConfigCache:
def check_frontend_build():
"""Check if frontend is built and optionally check if source is up-to-date"""
"""Check if frontend is built and optionally check if source is up-to-date
Returns:
bool: True if frontend is outdated, False if up-to-date or production environment
"""
webui_dir = Path(__file__).parent / "webui"
index_html = webui_dir / "index.html"
@ -181,7 +170,7 @@ def check_frontend_build():
logger.debug(
"Production environment detected, skipping source freshness check"
)
return
return False
# Development environment, perform source code timestamp check
logger.debug("Development environment detected, checking source freshness")
@ -212,7 +201,7 @@ def check_frontend_build():
source_dir / "bun.lock",
source_dir / "vite.config.ts",
source_dir / "tsconfig.json",
source_dir / "tailwind.config.js",
source_dir / "tailraid.config.js",
source_dir / "index.html",
]
@ -256,17 +245,25 @@ def check_frontend_build():
ASCIIColors.cyan(" cd ..")
ASCIIColors.yellow("\nThe server will continue with the current build.")
ASCIIColors.yellow("=" * 80 + "\n")
return True # Frontend is outdated
else:
logger.info("Frontend build is up-to-date")
return False # Frontend is up-to-date
except Exception as e:
# If check fails, log warning but don't affect startup
logger.warning(f"Failed to check frontend source freshness: {e}")
return False # Assume up-to-date on error
def create_app(args):
# Check frontend build first
check_frontend_build()
# Check frontend build first and get outdated status
is_frontend_outdated = check_frontend_build()
# Create unified API version display with warning symbol if frontend is outdated
api_version_display = (
f"{__api_version__}⚠️" if is_frontend_outdated else __api_version__
)
# Setup logging
logger.setLevel(args.log_level)
@ -341,8 +338,15 @@ def create_app(args):
# Clean up database connections
await rag.finalize_storages()
# Clean up shared data
finalize_share_data()
if "LIGHTRAG_GUNICORN_MODE" not in os.environ:
# Only perform cleanup in Uvicorn single-process mode
logger.debug("Unvicorn Mode: finalizing shared storage...")
finalize_share_data()
else:
# In Gunicorn mode with preload_app=True, cleanup is handled by on_exit hooks
logger.debug(
"Gunicorn Mode: postpone shared storage finalization to master process"
)
# Initialize FastAPI
base_description = (
@ -358,7 +362,7 @@ def create_app(args):
"description": swagger_description,
"version": __api_version__,
"openapi_url": "/openapi.json", # Explicitly set OpenAPI schema URL
"docs_url": "/docs", # Explicitly set docs URL
"docs_url": None, # Disable default docs, we'll create custom endpoint
"redoc_url": "/redoc", # Explicitly set redoc URL
"lifespan": lifespan,
}
@ -769,6 +773,25 @@ 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")
# Custom Swagger UI endpoint for offline support
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
"""Custom Swagger UI HTML with local static files"""
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
oauth2_redirect_url="/docs/oauth2-redirect",
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
swagger_css_url="/static/swagger-ui/swagger-ui.css",
swagger_favicon_url="/static/swagger-ui/favicon-32x32.png",
swagger_ui_parameters=app.swagger_ui_parameters,
)
@app.get("/docs/oauth2-redirect", include_in_schema=False)
async def swagger_ui_redirect():
"""OAuth2 redirect for Swagger UI"""
return get_swagger_ui_oauth2_redirect_html()
@app.get("/")
async def redirect_to_webui():
"""Redirect root path to /webui"""
@ -790,7 +813,7 @@ def create_app(args):
"auth_mode": "disabled",
"message": "Authentication is disabled. Using guest access.",
"core_version": core_version,
"api_version": __api_version__,
"api_version": api_version_display,
"webui_title": webui_title,
"webui_description": webui_description,
}
@ -799,7 +822,7 @@ def create_app(args):
"auth_configured": True,
"auth_mode": "enabled",
"core_version": core_version,
"api_version": __api_version__,
"api_version": api_version_display,
"webui_title": webui_title,
"webui_description": webui_description,
}
@ -817,7 +840,7 @@ def create_app(args):
"auth_mode": "disabled",
"message": "Authentication is disabled. Using guest access.",
"core_version": core_version,
"api_version": __api_version__,
"api_version": api_version_display,
"webui_title": webui_title,
"webui_description": webui_description,
}
@ -834,7 +857,7 @@ def create_app(args):
"token_type": "bearer",
"auth_mode": "enabled",
"core_version": core_version,
"api_version": __api_version__,
"api_version": api_version_display,
"webui_title": webui_title,
"webui_description": webui_description,
}
@ -898,7 +921,7 @@ def create_app(args):
"pipeline_busy": pipeline_status.get("busy", False),
"keyed_locks": keyed_lock_info,
"core_version": core_version,
"api_version": __api_version__,
"api_version": api_version_display,
"webui_title": webui_title,
"webui_description": webui_description,
}
@ -935,6 +958,15 @@ def create_app(args):
return response
# Mount Swagger UI static files for offline support
swagger_static_dir = Path(__file__).parent / "static" / "swagger-ui"
if swagger_static_dir.exists():
app.mount(
"/static/swagger-ui",
StaticFiles(directory=swagger_static_dir),
name="swagger-ui-static",
)
# Webui mount webui/index.html
static_dir = Path(__file__).parent / "webui"
static_dir.mkdir(exist_ok=True)
@ -1076,8 +1108,10 @@ def main():
update_uvicorn_mode_config()
display_splash_screen(global_args)
# Setup signal handlers for graceful shutdown
setup_signal_handlers()
# Note: Signal handlers are NOT registered here because:
# - Uvicorn has built-in signal handling that properly calls lifespan shutdown
# - Custom signal handlers can interfere with uvicorn's graceful shutdown
# - Cleanup is handled by the lifespan context manager's finally block
# Create application instance directly instead of using factory function
app = create_app(global_args)

View file

@ -1083,11 +1083,74 @@ async def pipeline_enqueue_file(
else:
if not pm.is_installed("pypdf2"): # type: ignore
pm.install("pypdf2")
if not pm.is_installed("pycryptodome"): # type: ignore
pm.install("pycryptodome")
from PyPDF2 import PdfReader # type: ignore
from io import BytesIO
pdf_file = BytesIO(file)
reader = PdfReader(pdf_file)
# Check if PDF is encrypted
if reader.is_encrypted:
pdf_password = global_args.pdf_decrypt_password
if not pdf_password:
# PDF is encrypted but no password provided
error_files = [
{
"file_path": str(file_path.name),
"error_description": "[File Extraction]PDF is encrypted but no password provided",
"original_error": "Please set PDF_DECRYPT_PASSWORD environment variable to decrypt this PDF file",
"file_size": file_size,
}
]
await rag.apipeline_enqueue_error_documents(
error_files, track_id
)
logger.error(
f"[File Extraction]PDF is encrypted but no password provided: {file_path.name}"
)
return False, track_id
# Try to decrypt with password
try:
decrypt_result = reader.decrypt(pdf_password)
if decrypt_result == 0:
# Password is incorrect
error_files = [
{
"file_path": str(file_path.name),
"error_description": "[File Extraction]Failed to decrypt PDF - incorrect password",
"original_error": "The provided PDF_DECRYPT_PASSWORD is incorrect for this file",
"file_size": file_size,
}
]
await rag.apipeline_enqueue_error_documents(
error_files, track_id
)
logger.error(
f"[File Extraction]Incorrect PDF password: {file_path.name}"
)
return False, track_id
except Exception as decrypt_error:
# Decryption process error
error_files = [
{
"file_path": str(file_path.name),
"error_description": "[File Extraction]PDF decryption failed",
"original_error": f"Error during PDF decryption: {str(decrypt_error)}",
"file_size": file_size,
}
]
await rag.apipeline_enqueue_error_documents(
error_files, track_id
)
logger.error(
f"[File Extraction]PDF decryption error for {file_path.name}: {str(decrypt_error)}"
)
return False, track_id
# Extract text from PDF (encrypted PDFs are now decrypted, unencrypted PDFs proceed directly)
for page in reader.pages:
content += page.extract_text() + "\n"
except Exception as e:

View file

@ -17,6 +17,7 @@ class EntityUpdateRequest(BaseModel):
entity_name: str
updated_data: Dict[str, Any]
allow_rename: bool = False
allow_merge: bool = False
class RelationUpdateRequest(BaseModel):
@ -221,22 +222,178 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
"""
Update an entity's properties in the knowledge graph
This endpoint allows updating entity properties, including renaming entities.
When renaming to an existing entity name, the behavior depends on allow_merge:
Args:
request (EntityUpdateRequest): Request containing entity name, updated data, and rename flag
request (EntityUpdateRequest): Request containing:
- entity_name (str): Name of the entity to update
- updated_data (Dict[str, Any]): Dictionary of properties to update
- allow_rename (bool): Whether to allow entity renaming (default: False)
- allow_merge (bool): Whether to merge into existing entity when renaming
causes name conflict (default: False)
Returns:
Dict: Updated entity information
Dict with the following structure:
{
"status": "success",
"message": "Entity updated successfully" | "Entity merged successfully into 'target_name'",
"data": {
"entity_name": str, # Final entity name
"description": str, # Entity description
"entity_type": str, # Entity type
"source_id": str, # Source chunk IDs
... # Other entity properties
},
"operation_summary": {
"merged": bool, # Whether entity was merged into another
"merge_status": str, # "success" | "failed" | "not_attempted"
"merge_error": str | None, # Error message if merge failed
"operation_status": str, # "success" | "partial_success" | "failure"
"target_entity": str | None, # Target entity name if renaming/merging
"final_entity": str, # Final entity name after operation
"renamed": bool # Whether entity was renamed
}
}
operation_status values explained:
- "success": All operations completed successfully
* For simple updates: entity properties updated
* For renames: entity renamed successfully
* For merges: non-name updates applied AND merge completed
- "partial_success": Update succeeded but merge failed
* Non-name property updates were applied successfully
* Merge operation failed (entity not merged)
* Original entity still exists with updated properties
* Use merge_error for failure details
- "failure": Operation failed completely
* If merge_status == "failed": Merge attempted but both update and merge failed
* If merge_status == "not_attempted": Regular update failed
* No changes were applied to the entity
merge_status values explained:
- "success": Entity successfully merged into target entity
- "failed": Merge operation was attempted but failed
- "not_attempted": No merge was attempted (normal update/rename)
Behavior when renaming to an existing entity:
- If allow_merge=False: Raises ValueError with 400 status (default behavior)
- If allow_merge=True: Automatically merges the source entity into the existing target entity,
preserving all relationships and applying non-name updates first
Example Request (simple update):
POST /graph/entity/edit
{
"entity_name": "Tesla",
"updated_data": {"description": "Updated description"},
"allow_rename": false,
"allow_merge": false
}
Example Response (simple update success):
{
"status": "success",
"message": "Entity updated successfully",
"data": { ... },
"operation_summary": {
"merged": false,
"merge_status": "not_attempted",
"merge_error": null,
"operation_status": "success",
"target_entity": null,
"final_entity": "Tesla",
"renamed": false
}
}
Example Request (rename with auto-merge):
POST /graph/entity/edit
{
"entity_name": "Elon Msk",
"updated_data": {
"entity_name": "Elon Musk",
"description": "Corrected description"
},
"allow_rename": true,
"allow_merge": true
}
Example Response (merge success):
{
"status": "success",
"message": "Entity merged successfully into 'Elon Musk'",
"data": { ... },
"operation_summary": {
"merged": true,
"merge_status": "success",
"merge_error": null,
"operation_status": "success",
"target_entity": "Elon Musk",
"final_entity": "Elon Musk",
"renamed": true
}
}
Example Response (partial success - update succeeded but merge failed):
{
"status": "success",
"message": "Entity updated successfully",
"data": { ... }, # Data reflects updated "Elon Msk" entity
"operation_summary": {
"merged": false,
"merge_status": "failed",
"merge_error": "Target entity locked by another operation",
"operation_status": "partial_success",
"target_entity": "Elon Musk",
"final_entity": "Elon Msk", # Original entity still exists
"renamed": true
}
}
"""
try:
result = await rag.aedit_entity(
entity_name=request.entity_name,
updated_data=request.updated_data,
allow_rename=request.allow_rename,
allow_merge=request.allow_merge,
)
# Extract operation_summary from result, with fallback for backward compatibility
operation_summary = result.get(
"operation_summary",
{
"merged": False,
"merge_status": "not_attempted",
"merge_error": None,
"operation_status": "success",
"target_entity": None,
"final_entity": request.updated_data.get(
"entity_name", request.entity_name
),
"renamed": request.updated_data.get(
"entity_name", request.entity_name
)
!= request.entity_name,
},
)
# Separate entity data from operation_summary for clean response
entity_data = dict(result)
entity_data.pop("operation_summary", None)
# Generate appropriate response message based on merge status
response_message = (
f"Entity merged successfully into '{operation_summary['final_entity']}'"
if operation_summary.get("merged")
else "Entity updated successfully"
)
return {
"status": "success",
"message": "Entity updated successfully",
"data": result,
"message": response_message,
"data": entity_data,
"operation_summary": operation_summary,
}
except ValueError as ve:
logger.error(

View file

@ -5,12 +5,11 @@ Start LightRAG server with Gunicorn
import os
import sys
import signal
import pipmaster as pm
from lightrag.api.utils_api import display_splash_screen, check_env_file
from lightrag.api.config import global_args
from lightrag.utils import get_env_value
from lightrag.kg.shared_storage import initialize_share_data, finalize_share_data
from lightrag.kg.shared_storage import initialize_share_data
from lightrag.constants import (
DEFAULT_WOKERS,
@ -34,21 +33,10 @@ def check_and_install_dependencies():
print(f"{package} installed successfully")
# Signal handler for graceful shutdown
def signal_handler(sig, frame):
print("\n\n" + "=" * 80)
print("RECEIVED TERMINATION SIGNAL")
print(f"Process ID: {os.getpid()}")
print("=" * 80 + "\n")
# Release shared resources
finalize_share_data()
# Exit with success status
sys.exit(0)
def main():
# Set Gunicorn mode flag for lifespan cleanup detection
os.environ["LIGHTRAG_GUNICORN_MODE"] = "1"
# Check .env file
if not check_env_file():
sys.exit(1)
@ -56,9 +44,8 @@ def main():
# Check and install dependencies
check_and_install_dependencies()
# Register signal handlers for graceful shutdown
signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
signal.signal(signal.SIGTERM, signal_handler) # kill command
# Note: Signal handlers are NOT registered here because:
# - Master cleanup already handled by gunicorn_config.on_exit()
# Display startup information
display_splash_screen(global_args)

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -38,7 +38,7 @@ DEFAULT_ENTITY_TYPES = [
"NaturalObject",
]
# Separator for graph fields
# Separator for: description, source_id and relation-key fields(Can not be changed after data inserted)
GRAPH_FIELD_SEP = "<SEP>"
# Query and retrieval configuration defaults

View file

@ -104,3 +104,11 @@ class PipelineCancelledException(Exception):
def __init__(self, message: str = "User cancelled"):
super().__init__(message)
self.message = message
class QdrantMigrationError(Exception):
"""Raised when Qdrant data migration from legacy collections fails."""
def __init__(self, message: str):
super().__init__(message)
self.message = message

View file

@ -983,7 +983,7 @@ class MilvusVectorDBStorage(BaseVectorStorage):
async def initialize(self):
"""Initialize Milvus collection"""
async with get_data_init_lock(enable_logging=True):
async with get_data_init_lock():
if self._initialized:
return

View file

@ -184,9 +184,17 @@ class NanoVectorDBStorage(BaseVectorStorage):
"""
try:
client = await self._get_client()
# Record count before deletion
before_count = len(client)
client.delete(ids)
# Calculate actual deleted count
after_count = len(client)
deleted_count = before_count - after_count
logger.debug(
f"[{self.workspace}] Successfully deleted {len(ids)} vectors from {self.namespace}"
f"[{self.workspace}] Successfully deleted {deleted_count} vectors from {self.namespace}"
)
except Exception as e:
logger.error(

View file

@ -1,21 +1,30 @@
import asyncio
import os
from typing import Any, final, List
from dataclasses import dataclass
import numpy as np
import hashlib
import uuid
from ..utils import logger
from ..base import BaseVectorStorage
from ..kg.shared_storage import get_data_init_lock, get_storage_lock
import configparser
import hashlib
import os
import uuid
from dataclasses import dataclass
from typing import Any, List, final
import numpy as np
import pipmaster as pm
from ..base import BaseVectorStorage
from ..exceptions import QdrantMigrationError
from ..kg.shared_storage import get_data_init_lock, get_storage_lock
from ..utils import compute_mdhash_id, logger
if not pm.is_installed("qdrant-client"):
pm.install("qdrant-client")
from qdrant_client import QdrantClient, models # type: ignore
DEFAULT_WORKSPACE = "_"
WORKSPACE_ID_FIELD = "workspace_id"
ENTITY_PREFIX = "ent-"
CREATED_AT_FIELD = "created_at"
ID_FIELD = "id"
config = configparser.ConfigParser()
config.read("config.ini", "utf-8")
@ -48,6 +57,15 @@ def compute_mdhash_id_for_qdrant(
raise ValueError("Invalid style. Choose from 'simple', 'hyphenated', or 'urn'.")
def workspace_filter_condition(workspace: str) -> models.FieldCondition:
"""
Create a workspace filter condition for Qdrant queries.
"""
return models.FieldCondition(
key=WORKSPACE_ID_FIELD, match=models.MatchValue(value=workspace)
)
@final
@dataclass
class QdrantVectorDBStorage(BaseVectorStorage):
@ -64,24 +82,192 @@ class QdrantVectorDBStorage(BaseVectorStorage):
self.__post_init__()
@staticmethod
def create_collection_if_not_exist(
client: QdrantClient, collection_name: str, **kwargs
def setup_collection(
client: QdrantClient,
collection_name: str,
legacy_namespace: str = None,
workspace: str = None,
**kwargs,
):
exists = False
if hasattr(client, "collection_exists"):
try:
exists = client.collection_exists(collection_name)
except Exception:
exists = False
else:
try:
client.get_collection(collection_name)
exists = True
except Exception:
exists = False
"""
Setup Qdrant collection with migration support from legacy collections.
if not exists:
Args:
client: QdrantClient instance
collection_name: Name of the new collection
legacy_namespace: Name of the legacy collection (if exists)
workspace: Workspace identifier for data isolation
**kwargs: Additional arguments for collection creation (vectors_config, hnsw_config, etc.)
"""
new_collection_exists = client.collection_exists(collection_name)
legacy_exists = legacy_namespace and client.collection_exists(legacy_namespace)
# Case 1: Both new and legacy collections exist - Warning only (no migration)
if new_collection_exists and legacy_exists:
logger.warning(
f"Qdrant: Legacy collection '{legacy_namespace}' still exist. Remove it if migration is complete."
)
return
# Case 2: Only new collection exists - Ensure index exists
if new_collection_exists:
# Check if workspace index exists, create if missing
try:
collection_info = client.get_collection(collection_name)
if WORKSPACE_ID_FIELD not in collection_info.payload_schema:
logger.info(
f"Qdrant: Creating missing workspace index for '{collection_name}'"
)
client.create_payload_index(
collection_name=collection_name,
field_name=WORKSPACE_ID_FIELD,
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=True,
),
)
except Exception as e:
logger.warning(
f"Qdrant: Could not verify/create workspace index for '{collection_name}': {e}"
)
return
# Case 3: Neither exists - Create new collection
if not legacy_exists:
logger.info(f"Qdrant: Creating new collection '{collection_name}'")
client.create_collection(collection_name, **kwargs)
client.create_payload_index(
collection_name=collection_name,
field_name=WORKSPACE_ID_FIELD,
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=True,
),
)
logger.info(f"Qdrant: Collection '{collection_name}' created successfully")
return
# Case 4: Only legacy exists - Migrate data
logger.info(
f"Qdrant: Migrating data from legacy collection '{legacy_namespace}'"
)
try:
# Get legacy collection count
legacy_count = client.count(
collection_name=legacy_namespace, exact=True
).count
logger.info(f"Qdrant: Found {legacy_count} records in legacy collection")
if legacy_count == 0:
logger.info("Qdrant: Legacy collection is empty, skipping migration")
# Create new empty collection
client.create_collection(collection_name, **kwargs)
client.create_payload_index(
collection_name=collection_name,
field_name=WORKSPACE_ID_FIELD,
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=True,
),
)
return
# Create new collection first
logger.info(f"Qdrant: Creating new collection '{collection_name}'")
client.create_collection(collection_name, **kwargs)
# Batch migration (500 records per batch)
migrated_count = 0
offset = None
batch_size = 500
while True:
# Scroll through legacy data
result = client.scroll(
collection_name=legacy_namespace,
limit=batch_size,
offset=offset,
with_vectors=True,
with_payload=True,
)
points, next_offset = result
if not points:
break
# Transform points for new collection
new_points = []
for point in points:
# Add workspace_id to payload
new_payload = dict(point.payload or {})
new_payload[WORKSPACE_ID_FIELD] = workspace or DEFAULT_WORKSPACE
# Create new point with workspace-prefixed ID
original_id = new_payload.get(ID_FIELD)
if original_id:
new_point_id = compute_mdhash_id_for_qdrant(
original_id, prefix=workspace or DEFAULT_WORKSPACE
)
else:
# Fallback: use original point ID
new_point_id = str(point.id)
new_points.append(
models.PointStruct(
id=new_point_id,
vector=point.vector,
payload=new_payload,
)
)
# Upsert to new collection
client.upsert(
collection_name=collection_name, points=new_points, wait=True
)
migrated_count += len(points)
logger.info(f"Qdrant: {migrated_count}/{legacy_count} records migrated")
# Check if we've reached the end
if next_offset is None:
break
offset = next_offset
# Verify migration by comparing counts
logger.info("Verifying migration...")
new_count = client.count(collection_name=collection_name, exact=True).count
if new_count != legacy_count:
error_msg = f"Qdrant: Migration verification failed, expected {legacy_count} records, got {new_count} in new collection"
logger.error(error_msg)
raise QdrantMigrationError(error_msg)
logger.info(
f"Qdrant: Migration completed successfully: {migrated_count} records migrated"
)
# Create payload index after successful migration
logger.info("Qdrant: Creating workspace payload index...")
client.create_payload_index(
collection_name=collection_name,
field_name=WORKSPACE_ID_FIELD,
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=True,
),
)
logger.info(
f"Qdrant: Migration from '{legacy_namespace}' to '{collection_name}' completed successfully"
)
except QdrantMigrationError:
# Re-raise migration errors without wrapping
raise
except Exception as e:
error_msg = f"Qdrant: Migration failed with error: {e}"
logger.error(error_msg)
raise QdrantMigrationError(error_msg) from e
def __post_init__(self):
# Check for QDRANT_WORKSPACE environment variable first (higher priority)
@ -101,18 +287,20 @@ class QdrantVectorDBStorage(BaseVectorStorage):
f"Using passed workspace parameter: '{effective_workspace}'"
)
# Build final_namespace with workspace prefix for data isolation
# Keep original namespace unchanged for type detection logic
# Get legacy namespace for data migration from old version
if effective_workspace:
self.final_namespace = f"{effective_workspace}_{self.namespace}"
logger.debug(
f"Final namespace with workspace prefix: '{self.final_namespace}'"
)
self.legacy_namespace = f"{effective_workspace}_{self.namespace}"
else:
# When workspace is empty, final_namespace equals original namespace
self.final_namespace = self.namespace
self.workspace = "_"
logger.debug(f"Final namespace (no workspace): '{self.final_namespace}'")
self.legacy_namespace = self.namespace
self.effective_workspace = effective_workspace or DEFAULT_WORKSPACE
# Use a shared collection with payload-based partitioning (Qdrant's recommended approach)
# Ref: https://qdrant.tech/documentation/guides/multiple-partitions/
self.final_namespace = f"lightrag_vdb_{self.namespace}"
logger.debug(
f"Using shared collection '{self.final_namespace}' with workspace '{self.effective_workspace}' for payload-based partitioning"
)
kwargs = self.global_config.get("vector_db_storage_cls_kwargs", {})
cosine_threshold = kwargs.get("cosine_better_than_threshold")
@ -149,15 +337,23 @@ class QdrantVectorDBStorage(BaseVectorStorage):
f"[{self.workspace}] QdrantClient created successfully"
)
# Create collection if not exists
QdrantVectorDBStorage.create_collection_if_not_exist(
# Setup collection (create if not exists and configure indexes)
# Pass legacy_namespace and workspace for migration support
QdrantVectorDBStorage.setup_collection(
self._client,
self.final_namespace,
legacy_namespace=self.legacy_namespace,
workspace=self.effective_workspace,
vectors_config=models.VectorParams(
size=self.embedding_func.embedding_dim,
distance=models.Distance.COSINE,
),
hnsw_config=models.HnswConfigDiff(
payload_m=16,
m=0,
),
)
self._initialized = True
logger.info(
f"[{self.workspace}] Qdrant collection '{self.namespace}' initialized successfully"
@ -179,8 +375,9 @@ class QdrantVectorDBStorage(BaseVectorStorage):
list_data = [
{
"id": k,
"created_at": current_time,
ID_FIELD: k,
WORKSPACE_ID_FIELD: self.effective_workspace,
CREATED_AT_FIELD: current_time,
**{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields},
}
for k, v in data.items()
@ -200,7 +397,9 @@ class QdrantVectorDBStorage(BaseVectorStorage):
for i, d in enumerate(list_data):
list_points.append(
models.PointStruct(
id=compute_mdhash_id_for_qdrant(d["id"]),
id=compute_mdhash_id_for_qdrant(
d[ID_FIELD], prefix=self.effective_workspace
),
vector=embeddings[i],
payload=d,
)
@ -222,21 +421,22 @@ class QdrantVectorDBStorage(BaseVectorStorage):
) # higher priority for query
embedding = embedding_result[0]
results = self._client.search(
results = self._client.query_points(
collection_name=self.final_namespace,
query_vector=embedding,
query=embedding,
limit=top_k,
with_payload=True,
score_threshold=self.cosine_better_than_threshold,
)
# logger.debug(f"[{self.workspace}] query result: {results}")
query_filter=models.Filter(
must=[workspace_filter_condition(self.effective_workspace)]
),
).points
return [
{
**dp.payload,
"distance": dp.score,
"created_at": dp.payload.get("created_at"),
CREATED_AT_FIELD: dp.payload.get(CREATED_AT_FIELD),
}
for dp in results
]
@ -252,14 +452,18 @@ class QdrantVectorDBStorage(BaseVectorStorage):
ids: List of vector IDs to be deleted
"""
try:
if not ids:
return
# Convert regular ids to Qdrant compatible ids
qdrant_ids = [compute_mdhash_id_for_qdrant(id) for id in ids]
# Delete points from the collection
qdrant_ids = [
compute_mdhash_id_for_qdrant(id, prefix=self.effective_workspace)
for id in ids
]
# Delete points from the collection with workspace filtering
self._client.delete(
collection_name=self.final_namespace,
points_selector=models.PointIdsList(
points=qdrant_ids,
),
points_selector=models.PointIdsList(points=qdrant_ids),
wait=True,
)
logger.debug(
@ -277,18 +481,16 @@ class QdrantVectorDBStorage(BaseVectorStorage):
entity_name: Name of the entity to delete
"""
try:
# Generate the entity ID
entity_id = compute_mdhash_id_for_qdrant(entity_name, prefix="ent-")
# logger.debug(
# f"[{self.workspace}] Attempting to delete entity {entity_name} with ID {entity_id}"
# )
# Generate the entity ID using the same function as used for storage
entity_id = compute_mdhash_id(entity_name, prefix=ENTITY_PREFIX)
qdrant_entity_id = compute_mdhash_id_for_qdrant(
entity_id, prefix=self.effective_workspace
)
# Delete the entity point from the collection
# Delete the entity point by its Qdrant ID directly
self._client.delete(
collection_name=self.final_namespace,
points_selector=models.PointIdsList(
points=[entity_id],
),
points_selector=models.PointIdsList(points=[qdrant_entity_id]),
wait=True,
)
logger.debug(
@ -304,10 +506,11 @@ class QdrantVectorDBStorage(BaseVectorStorage):
entity_name: Name of the entity whose relations should be deleted
"""
try:
# Find relations where the entity is either source or target
# Find relations where the entity is either source or target, with workspace filtering
results = self._client.scroll(
collection_name=self.final_namespace,
scroll_filter=models.Filter(
must=[workspace_filter_condition(self.effective_workspace)],
should=[
models.FieldCondition(
key="src_id", match=models.MatchValue(value=entity_name)
@ -315,7 +518,7 @@ class QdrantVectorDBStorage(BaseVectorStorage):
models.FieldCondition(
key="tgt_id", match=models.MatchValue(value=entity_name)
),
]
],
),
with_payload=True,
limit=1000, # Adjust as needed for your use case
@ -326,12 +529,11 @@ class QdrantVectorDBStorage(BaseVectorStorage):
ids_to_delete = [point.id for point in relation_points]
if ids_to_delete:
# Delete the relations
# Delete the relations with workspace filtering
assert isinstance(self._client, QdrantClient)
self._client.delete(
collection_name=self.final_namespace,
points_selector=models.PointIdsList(
points=ids_to_delete,
),
points_selector=models.PointIdsList(points=ids_to_delete),
wait=True,
)
logger.debug(
@ -357,9 +559,11 @@ class QdrantVectorDBStorage(BaseVectorStorage):
"""
try:
# Convert to Qdrant compatible ID
qdrant_id = compute_mdhash_id_for_qdrant(id)
qdrant_id = compute_mdhash_id_for_qdrant(
id, prefix=self.effective_workspace
)
# Retrieve the point by ID
# Retrieve the point by ID with workspace filtering
result = self._client.retrieve(
collection_name=self.final_namespace,
ids=[qdrant_id],
@ -369,10 +573,9 @@ class QdrantVectorDBStorage(BaseVectorStorage):
if not result:
return None
# Ensure the result contains created_at field
payload = result[0].payload
if "created_at" not in payload:
payload["created_at"] = None
if CREATED_AT_FIELD not in payload:
payload[CREATED_AT_FIELD] = None
return payload
except Exception as e:
@ -395,7 +598,10 @@ class QdrantVectorDBStorage(BaseVectorStorage):
try:
# Convert to Qdrant compatible IDs
qdrant_ids = [compute_mdhash_id_for_qdrant(id) for id in ids]
qdrant_ids = [
compute_mdhash_id_for_qdrant(id, prefix=self.effective_workspace)
for id in ids
]
# Retrieve the points by IDs
results = self._client.retrieve(
@ -410,14 +616,14 @@ class QdrantVectorDBStorage(BaseVectorStorage):
for point in results:
payload = dict(point.payload or {})
if "created_at" not in payload:
payload["created_at"] = None
if CREATED_AT_FIELD not in payload:
payload[CREATED_AT_FIELD] = None
qdrant_point_id = str(point.id) if point.id is not None else ""
if qdrant_point_id:
payload_by_qdrant_id[qdrant_point_id] = payload
original_id = payload.get("id")
original_id = payload.get(ID_FIELD)
if original_id is not None:
payload_by_original_id[str(original_id)] = payload
@ -450,7 +656,10 @@ class QdrantVectorDBStorage(BaseVectorStorage):
try:
# Convert to Qdrant compatible IDs
qdrant_ids = [compute_mdhash_id_for_qdrant(id) for id in ids]
qdrant_ids = [
compute_mdhash_id_for_qdrant(id, prefix=self.effective_workspace)
for id in ids
]
# Retrieve the points by IDs with vectors
results = self._client.retrieve(
@ -464,7 +673,7 @@ class QdrantVectorDBStorage(BaseVectorStorage):
for point in results:
if point and point.vector is not None and point.payload:
# Get original ID from payload
original_id = point.payload.get("id")
original_id = point.payload.get(ID_FIELD)
if original_id:
# Convert numpy array to list if needed
vector_data = point.vector
@ -482,7 +691,7 @@ class QdrantVectorDBStorage(BaseVectorStorage):
async def drop(self) -> dict[str, str]:
"""Drop all vector data from storage and clean up resources
This method will delete all data from the Qdrant collection.
This method will delete all data for the current workspace from the Qdrant collection.
Returns:
dict[str, str]: Operation status and message
@ -491,39 +700,23 @@ class QdrantVectorDBStorage(BaseVectorStorage):
"""
async with get_storage_lock():
try:
# Delete the collection and recreate it
exists = False
if hasattr(self._client, "collection_exists"):
try:
exists = self._client.collection_exists(self.final_namespace)
except Exception:
exists = False
else:
try:
self._client.get_collection(self.final_namespace)
exists = True
except Exception:
exists = False
if exists:
self._client.delete_collection(self.final_namespace)
# Recreate the collection
QdrantVectorDBStorage.create_collection_if_not_exist(
self._client,
self.final_namespace,
vectors_config=models.VectorParams(
size=self.embedding_func.embedding_dim,
distance=models.Distance.COSINE,
# Delete all points for the current workspace
self._client.delete(
collection_name=self.final_namespace,
points_selector=models.FilterSelector(
filter=models.Filter(
must=[workspace_filter_condition(self.effective_workspace)]
)
),
wait=True,
)
logger.info(
f"[{self.workspace}] Process {os.getpid()} drop Qdrant collection {self.namespace}"
f"[{self.workspace}] Process {os.getpid()} dropped workspace data from Qdrant collection {self.namespace}"
)
return {"status": "success", "message": "data dropped"}
except Exception as e:
logger.error(
f"[{self.workspace}] Error dropping Qdrant collection {self.namespace}: {e}"
f"[{self.workspace}] Error dropping workspace data from Qdrant collection {self.namespace}: {e}"
)
return {"status": "error", "message": str(e)}

View file

@ -10,6 +10,8 @@ from typing import Any, Dict, List, Optional, Union, TypeVar, Generic
from lightrag.exceptions import PipelineNotInitializedError
DEBUG_LOCKS = False
# Define a direct print function for critical logs that must be visible in all processes
def direct_log(message, enable_output: bool = True, level: str = "DEBUG"):
@ -90,7 +92,6 @@ _storage_keyed_lock: Optional["KeyedUnifiedLock"] = None
# async locks for coroutine synchronization in multiprocess mode
_async_locks: Optional[Dict[str, asyncio.Lock]] = None
DEBUG_LOCKS = False
_debug_n_locks_acquired: int = 0

View file

@ -1871,7 +1871,7 @@ class LightRAG:
chunks, pipeline_status, pipeline_status_lock
)
)
await entity_relation_task
chunk_results = await entity_relation_task
file_extraction_stage_ok = True
except Exception as e:
@ -1961,6 +1961,7 @@ class LightRAG:
if self.enable_deduplication:
dedup_service = LightRAGDeduplicationService(self)
# Use chunk_results from entity_relation_task
await merge_nodes_and_edges(
chunk_results=chunk_results, # result collected from entity_relation_task
knowledge_graph_inst=self.chunk_entity_relation_graph,
@ -3190,6 +3191,9 @@ class LightRAG:
]
if not existing_sources:
# No chunk references means this entity should be deleted
entities_to_delete.add(node_label)
entity_chunk_updates[node_label] = []
continue
remaining_sources = subtract_source_ids(existing_sources, chunk_ids)
@ -3211,6 +3215,7 @@ class LightRAG:
# Process relationships
for edge_data in affected_edges:
# source target is not in normalize order in graph db property
src = edge_data.get("source")
tgt = edge_data.get("target")
@ -3247,6 +3252,9 @@ class LightRAG:
]
if not existing_sources:
# No chunk references means this relationship should be deleted
relationships_to_delete.add(edge_tuple)
relation_chunk_updates[edge_tuple] = []
continue
remaining_sources = subtract_source_ids(existing_sources, chunk_ids)
@ -3330,36 +3338,7 @@ class LightRAG:
logger.error(f"Failed to delete chunks: {e}")
raise Exception(f"Failed to delete document chunks: {e}") from e
# 6. Delete entities that have no remaining sources
if entities_to_delete:
try:
# Delete from vector database
entity_vdb_ids = [
compute_mdhash_id(entity, prefix="ent-")
for entity in entities_to_delete
]
await self.entities_vdb.delete(entity_vdb_ids)
# Delete from graph
await self.chunk_entity_relation_graph.remove_nodes(
list(entities_to_delete)
)
# Delete from entity_chunks storage
if self.entity_chunks:
await self.entity_chunks.delete(list(entities_to_delete))
async with pipeline_status_lock:
log_message = f"Successfully deleted {len(entities_to_delete)} entities"
logger.info(log_message)
pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message)
except Exception as e:
logger.error(f"Failed to delete entities: {e}")
raise Exception(f"Failed to delete entities: {e}") from e
# 7. Delete relationships that have no remaining sources
# 6. Delete relationships that have no remaining sources
if relationships_to_delete:
try:
# Delete from vector database
@ -3396,6 +3375,96 @@ class LightRAG:
logger.error(f"Failed to delete relationships: {e}")
raise Exception(f"Failed to delete relationships: {e}") from e
# 7. Delete entities that have no remaining sources
if entities_to_delete:
try:
# Debug: Check and log all edges before deleting nodes
edges_to_delete = set()
edges_still_exist = 0
for entity in entities_to_delete:
edges = (
await self.chunk_entity_relation_graph.get_node_edges(
entity
)
)
if edges:
for src, tgt in edges:
# Normalize edge representation (sorted for consistency)
edge_tuple = tuple(sorted((src, tgt)))
edges_to_delete.add(edge_tuple)
if (
src in entities_to_delete
and tgt in entities_to_delete
):
logger.warning(
f"Edge still exists: {src} <-> {tgt}"
)
elif src in entities_to_delete:
logger.warning(
f"Edge still exists: {src} --> {tgt}"
)
else:
logger.warning(
f"Edge still exists: {src} <-- {tgt}"
)
edges_still_exist += 1
if edges_still_exist:
logger.warning(
f"⚠️ {edges_still_exist} entities still has edges before deletion"
)
# Clean residual edges from VDB and storage before deleting nodes
if edges_to_delete:
# Delete from relationships_vdb
rel_ids_to_delete = []
for src, tgt in edges_to_delete:
rel_ids_to_delete.extend(
[
compute_mdhash_id(src + tgt, prefix="rel-"),
compute_mdhash_id(tgt + src, prefix="rel-"),
]
)
await self.relationships_vdb.delete(rel_ids_to_delete)
# Delete from relation_chunks storage
if self.relation_chunks:
relation_storage_keys = [
make_relation_chunk_key(src, tgt)
for src, tgt in edges_to_delete
]
await self.relation_chunks.delete(relation_storage_keys)
logger.info(
f"Cleaned {len(edges_to_delete)} residual edges from VDB and chunk-tracking storage"
)
# Delete from graph (edges will be auto-deleted with nodes)
await self.chunk_entity_relation_graph.remove_nodes(
list(entities_to_delete)
)
# Delete from vector database
entity_vdb_ids = [
compute_mdhash_id(entity, prefix="ent-")
for entity in entities_to_delete
]
await self.entities_vdb.delete(entity_vdb_ids)
# Delete from entity_chunks storage
if self.entity_chunks:
await self.entity_chunks.delete(list(entities_to_delete))
async with pipeline_status_lock:
log_message = f"Successfully deleted {len(entities_to_delete)} entities"
logger.info(log_message)
pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message)
except Exception as e:
logger.error(f"Failed to delete entities: {e}")
raise Exception(f"Failed to delete entities: {e}") from e
# Persist changes to graph database before releasing graph database lock
await self._insert_done()
@ -3620,7 +3689,11 @@ class LightRAG:
)
async def aedit_entity(
self, entity_name: str, updated_data: dict[str, str], allow_rename: bool = True
self,
entity_name: str,
updated_data: dict[str, str],
allow_rename: bool = True,
allow_merge: bool = False,
) -> dict[str, Any]:
"""Asynchronously edit entity information.
@ -3631,6 +3704,7 @@ class LightRAG:
entity_name: Name of the entity to edit
updated_data: Dictionary containing updated attributes, e.g. {"description": "new description", "entity_type": "new type"}
allow_rename: Whether to allow entity renaming, defaults to True
allow_merge: Whether to merge into an existing entity when renaming to an existing name
Returns:
Dictionary containing updated entity information
@ -3644,16 +3718,21 @@ class LightRAG:
entity_name,
updated_data,
allow_rename,
allow_merge,
self.entity_chunks,
self.relation_chunks,
)
def edit_entity(
self, entity_name: str, updated_data: dict[str, str], allow_rename: bool = True
self,
entity_name: str,
updated_data: dict[str, str],
allow_rename: bool = True,
allow_merge: bool = False,
) -> dict[str, Any]:
loop = always_get_an_event_loop()
return loop.run_until_complete(
self.aedit_entity(entity_name, updated_data, allow_rename)
self.aedit_entity(entity_name, updated_data, allow_rename, allow_merge)
)
async def aedit_relation(
@ -3793,6 +3872,8 @@ class LightRAG:
target_entity,
merge_strategy,
target_entity_data,
self.entity_chunks,
self.relation_chunks,
)
def merge_entities(

View file

@ -11,7 +11,6 @@ if not pm.is_installed("openai"):
pm.install("openai")
from openai import (
AsyncOpenAI,
APIConnectionError,
RateLimitError,
APITimeoutError,
@ -27,6 +26,7 @@ from lightrag.utils import (
safe_unicode_decode,
logger,
)
from lightrag.types import GPTKeywordExtractionFormat
from lightrag.api import __api_version__
@ -36,6 +36,32 @@ from typing import Any, Union
from dotenv import load_dotenv
# Try to import Langfuse for LLM observability (optional)
# Falls back to standard OpenAI client if not available
# Langfuse requires proper configuration to work correctly
LANGFUSE_ENABLED = False
try:
# Check if required Langfuse environment variables are set
langfuse_public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
langfuse_secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
# Only enable Langfuse if both keys are configured
if langfuse_public_key and langfuse_secret_key:
from langfuse.openai import AsyncOpenAI
LANGFUSE_ENABLED = True
logger.info("Langfuse observability enabled for OpenAI client")
else:
from openai import AsyncOpenAI
logger.debug(
"Langfuse environment variables not configured, using standard OpenAI client"
)
except ImportError:
from openai import AsyncOpenAI
logger.debug("Langfuse not available, using standard OpenAI client")
# use the .env that is inside the current folder
# allows to use different .env file for each lightrag instance
# the OS environment variables take precedence over the .env file
@ -370,18 +396,23 @@ async def openai_complete_if_cache(
)
# Ensure resources are released even if no exception occurs
if (
iteration_started
and hasattr(response, "aclose")
and callable(getattr(response, "aclose", None))
):
try:
await response.aclose()
logger.debug("Successfully closed stream response")
except Exception as close_error:
logger.warning(
f"Failed to close stream response in finally block: {close_error}"
)
# Note: Some wrapped clients (e.g., Langfuse) may not implement aclose() properly
if iteration_started and hasattr(response, "aclose"):
aclose_method = getattr(response, "aclose", None)
if callable(aclose_method):
try:
await response.aclose()
logger.debug("Successfully closed stream response")
except (AttributeError, TypeError) as close_error:
# Some wrapper objects may report hasattr(aclose) but fail when called
# This is expected behavior for certain client wrappers
logger.debug(
f"Stream response cleanup not supported by client wrapper: {close_error}"
)
except Exception as close_error:
logger.warning(
f"Unexpected error during stream response cleanup: {close_error}"
)
# This prevents resource leaks since the caller doesn't handle closing
try:

View file

@ -681,14 +681,6 @@ async def rebuild_knowledge_from_chunks(
entity_chunks_storage=entity_chunks_storage,
)
rebuilt_entities_count += 1
status_message = (
f"Rebuild `{entity_name}` from {len(chunk_ids)} chunks"
)
logger.info(status_message)
if pipeline_status is not None and pipeline_status_lock is not None:
async with pipeline_status_lock:
pipeline_status["latest_message"] = status_message
pipeline_status["history_messages"].append(status_message)
except Exception as e:
failed_entities_count += 1
status_message = f"Failed to rebuild `{entity_name}`: {e}"
@ -1436,10 +1428,6 @@ async def _rebuild_single_relationship(
else:
truncation_info = ""
# Sort src and tgt to ensure consistent ordering (smaller string first)
if src > tgt:
src, tgt = tgt, src
# Update relationship in graph storage
updated_relationship_data = {
**current_relationship,
@ -1514,6 +1502,9 @@ async def _rebuild_single_relationship(
await knowledge_graph_inst.upsert_edge(src, tgt, updated_relationship_data)
# Update relationship in vector database
# Sort src and tgt to ensure consistent ordering (smaller string first)
if src > tgt:
src, tgt = tgt, src
try:
rel_vdb_id = compute_mdhash_id(src + tgt, prefix="rel-")
rel_vdb_id_reverse = compute_mdhash_id(tgt + src, prefix="rel-")
@ -2149,13 +2140,13 @@ async def _merge_edges_then_upsert(
else:
logger.debug(status_message)
# Sort src_id and tgt_id to ensure consistent ordering (smaller string first)
if src_id > tgt_id:
src_id, tgt_id = tgt_id, src_id
# 11. Update both graph and vector db
for need_insert_id in [src_id, tgt_id]:
if not (await knowledge_graph_inst.has_node(need_insert_id)):
# Optimization: Use get_node instead of has_node + get_node
existing_node = await knowledge_graph_inst.get_node(need_insert_id)
if existing_node is None:
# Node doesn't exist - create new node
node_created_at = int(time.time())
node_data = {
"entity_id": need_insert_id,
@ -2212,6 +2203,109 @@ async def _merge_edges_then_upsert(
"created_at": node_created_at,
}
added_entities.append(entity_data)
else:
# Node exists - update its source_ids by merging with new source_ids
updated = False # Track if any update occurred
# 1. Get existing full source_ids from entity_chunks_storage
existing_full_source_ids = []
if entity_chunks_storage is not None:
stored_chunks = await entity_chunks_storage.get_by_id(need_insert_id)
if stored_chunks and isinstance(stored_chunks, dict):
existing_full_source_ids = [
chunk_id
for chunk_id in stored_chunks.get("chunk_ids", [])
if chunk_id
]
# If not in entity_chunks_storage, get from graph database
if not existing_full_source_ids:
if existing_node.get("source_id"):
existing_full_source_ids = existing_node["source_id"].split(
GRAPH_FIELD_SEP
)
# 2. Merge with new source_ids from this relationship
new_source_ids_from_relation = [
chunk_id for chunk_id in source_ids if chunk_id
]
merged_full_source_ids = merge_source_ids(
existing_full_source_ids, new_source_ids_from_relation
)
# 3. Save merged full list to entity_chunks_storage (conditional)
if (
entity_chunks_storage is not None
and merged_full_source_ids != existing_full_source_ids
):
updated = True
await entity_chunks_storage.upsert(
{
need_insert_id: {
"chunk_ids": merged_full_source_ids,
"count": len(merged_full_source_ids),
}
}
)
# 4. Apply source_ids limit for graph and vector db
limit_method = global_config.get(
"source_ids_limit_method", SOURCE_IDS_LIMIT_METHOD_KEEP
)
max_source_limit = global_config.get("max_source_ids_per_entity")
limited_source_ids = apply_source_ids_limit(
merged_full_source_ids,
max_source_limit,
limit_method,
identifier=f"`{need_insert_id}`",
)
# 5. Update graph database and vector database with limited source_ids (conditional)
limited_source_id_str = GRAPH_FIELD_SEP.join(limited_source_ids)
if limited_source_id_str != existing_node.get("source_id", ""):
updated = True
updated_node_data = {
**existing_node,
"source_id": limited_source_id_str,
}
await knowledge_graph_inst.upsert_node(
need_insert_id, node_data=updated_node_data
)
# Update vector database
if entity_vdb is not None:
entity_vdb_id = compute_mdhash_id(need_insert_id, prefix="ent-")
entity_content = (
f"{need_insert_id}\n{existing_node.get('description', '')}"
)
vdb_data = {
entity_vdb_id: {
"content": entity_content,
"entity_name": need_insert_id,
"source_id": limited_source_id_str,
"entity_type": existing_node.get("entity_type", "UNKNOWN"),
"file_path": existing_node.get(
"file_path", "unknown_source"
),
}
}
await safe_vdb_operation_with_exception(
operation=lambda payload=vdb_data: entity_vdb.upsert(payload),
operation_name="existing_entity_update",
entity_name=need_insert_id,
max_retries=3,
retry_delay=0.1,
)
# 6. Log once at the end if any update occurred
if updated:
status_message = f"Chunks appended from relation: `{need_insert_id}`"
logger.info(status_message)
if pipeline_status is not None and pipeline_status_lock is not None:
async with pipeline_status_lock:
pipeline_status["latest_message"] = status_message
pipeline_status["history_messages"].append(status_message)
edge_created_at = int(time.time())
await knowledge_graph_inst.upsert_edge(
@ -2240,6 +2334,10 @@ async def _merge_edges_then_upsert(
weight=weight,
)
# Sort src_id and tgt_id to ensure consistent ordering (smaller string first)
if src_id > tgt_id:
src_id, tgt_id = tgt_id, src_id
if relationships_vdb is not None:
rel_vdb_id = compute_mdhash_id(src_id + tgt_id, prefix="rel-")
rel_vdb_id_reverse = compute_mdhash_id(tgt_id + src_id, prefix="rel-")
@ -3872,7 +3970,7 @@ async def _merge_all_chunks(
return merged_chunks
async def _build_llm_context(
async def _build_context_str(
entities_context: list[dict],
relations_context: list[dict],
merged_chunks: list[dict],
@ -3972,23 +4070,32 @@ async def _build_llm_context(
truncated_chunks
)
# Rebuild text_units_context with truncated chunks
# Rebuild chunks_context with truncated chunks
# The actual tokens may be slightly less than available_chunk_tokens due to deduplication logic
text_units_context = []
chunks_context = []
for i, chunk in enumerate(truncated_chunks):
text_units_context.append(
chunks_context.append(
{
"reference_id": chunk["reference_id"],
"content": chunk["content"],
}
)
text_units_str = "\n".join(
json.dumps(text_unit, ensure_ascii=False) for text_unit in chunks_context
)
reference_list_str = "\n".join(
f"[{ref['reference_id']}] {ref['file_path']}"
for ref in reference_list
if ref["reference_id"]
)
logger.info(
f"Final context: {len(entities_context)} entities, {len(relations_context)} relations, {len(text_units_context)} chunks"
f"Final context: {len(entities_context)} entities, {len(relations_context)} relations, {len(chunks_context)} chunks"
)
# not necessary to use LLM to generate a response
if not entities_context and not relations_context:
if not entities_context and not relations_context and not chunks_context:
# Return empty raw data structure when no entities/relations
empty_raw_data = convert_to_user_format(
[],
@ -4019,15 +4126,6 @@ async def _build_llm_context(
if chunk_tracking_log:
logger.info(f"Final chunks S+F/O: {' '.join(chunk_tracking_log)}")
text_units_str = "\n".join(
json.dumps(text_unit, ensure_ascii=False) for text_unit in text_units_context
)
reference_list_str = "\n".join(
f"[{ref['reference_id']}] {ref['file_path']}"
for ref in reference_list
if ref["reference_id"]
)
result = kg_context_template.format(
entities_str=entities_str,
relations_str=relations_str,
@ -4037,7 +4135,7 @@ async def _build_llm_context(
# Always return both context and complete data structure (unified approach)
logger.debug(
f"[_build_llm_context] Converting to user format: {len(entities_context)} entities, {len(relations_context)} relations, {len(truncated_chunks)} chunks"
f"[_build_context_str] Converting to user format: {len(entities_context)} entities, {len(relations_context)} relations, {len(truncated_chunks)} chunks"
)
final_data = convert_to_user_format(
entities_context,
@ -4049,7 +4147,7 @@ async def _build_llm_context(
relation_id_to_original,
)
logger.debug(
f"[_build_llm_context] Final data after conversion: {len(final_data.get('entities', []))} entities, {len(final_data.get('relationships', []))} relationships, {len(final_data.get('chunks', []))} chunks"
f"[_build_context_str] Final data after conversion: {len(final_data.get('entities', []))} entities, {len(final_data.get('relationships', []))} relationships, {len(final_data.get('chunks', []))} chunks"
)
return result, final_data
@ -4126,8 +4224,8 @@ async def _build_query_context(
return None
# Stage 4: Build final LLM context with dynamic token processing
# _build_llm_context now always returns tuple[str, dict]
context, raw_data = await _build_llm_context(
# _build_context_str now always returns tuple[str, dict]
context, raw_data = await _build_context_str(
entities_context=truncation_result["entities_context"],
relations_context=truncation_result["relations_context"],
merged_chunks=merged_chunks,
@ -4900,10 +4998,10 @@ async def naive_query(
"final_chunks_count": len(processed_chunks_with_ref_ids),
}
# Build text_units_context from processed chunks with reference IDs
text_units_context = []
# Build chunks_context from processed chunks with reference IDs
chunks_context = []
for i, chunk in enumerate(processed_chunks_with_ref_ids):
text_units_context.append(
chunks_context.append(
{
"reference_id": chunk["reference_id"],
"content": chunk["content"],
@ -4911,7 +5009,7 @@ async def naive_query(
)
text_units_str = "\n".join(
json.dumps(text_unit, ensure_ascii=False) for text_unit in text_units_context
json.dumps(text_unit, ensure_ascii=False) for text_unit in chunks_context
)
reference_list_str = "\n".join(
f"[{ref['reference_id']}] {ref['file_path']}"

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
# Development environment configuration
VITE_BACKEND_URL=http://localhost:9621
VITE_API_PROXY=true
VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status
VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status,/static

View file

@ -1,4 +1,4 @@
# Development environment configuration
VITE_BACKEND_URL=http://localhost:9621
VITE_API_PROXY=true
VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status
VITE_API_ENDPOINTS=/api,/documents,/graphs,/graph,/health,/query,/docs,/redoc,/openapi.json,/login,/auth-status,/static

View file

@ -143,6 +143,21 @@ export type QueryResponse = {
response: string
}
export type EntityUpdateResponse = {
status: string
message: string
data: Record<string, any>
operation_summary?: {
merged: boolean
merge_status: 'success' | 'failed' | 'not_attempted'
merge_error: string | null
operation_status: 'success' | 'partial_success' | 'failure'
target_entity: string | null
final_entity?: string | null
renamed?: boolean
}
}
export type DocActionResponse = {
status: 'success' | 'partial_success' | 'failure' | 'duplicated'
message: string
@ -719,17 +734,20 @@ export const loginToServer = async (username: string, password: string): Promise
* @param entityName The name of the entity to update
* @param updatedData Dictionary containing updated attributes
* @param allowRename Whether to allow renaming the entity (default: false)
* @param allowMerge Whether to merge into an existing entity when renaming to a duplicate name
* @returns Promise with the updated entity information
*/
export const updateEntity = async (
entityName: string,
updatedData: Record<string, any>,
allowRename: boolean = false
): Promise<DocActionResponse> => {
allowRename: boolean = false,
allowMerge: boolean = false
): Promise<EntityUpdateResponse> => {
const response = await axiosInstance.post('/graph/entity/edit', {
entity_name: entityName,
updated_data: updatedData,
allow_rename: allowRename
allow_rename: allowRename,
allow_merge: allowMerge
})
return response.data
}

View file

@ -3,8 +3,11 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
import { useGraphStore } from '@/stores/graph'
import { useSettingsStore } from '@/stores/settings'
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
import PropertyEditDialog from './PropertyEditDialog'
import MergeDialog from './MergeDialog'
/**
* Interface for the EditablePropertyRow component props
@ -48,6 +51,12 @@ const EditablePropertyRow = ({
const [isEditing, setIsEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [currentValue, setCurrentValue] = useState(initialValue)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [mergeDialogOpen, setMergeDialogOpen] = useState(false)
const [mergeDialogInfo, setMergeDialogInfo] = useState<{
targetEntity: string
sourceEntity: string
} | null>(null)
useEffect(() => {
setCurrentValue(initialValue)
@ -56,42 +65,135 @@ const EditablePropertyRow = ({
const handleEditClick = () => {
if (isEditable && !isEditing) {
setIsEditing(true)
setErrorMessage(null)
}
}
const handleCancel = () => {
setIsEditing(false)
setErrorMessage(null)
}
const handleSave = async (value: string) => {
const handleSave = async (value: string, options?: { allowMerge?: boolean }) => {
if (isSubmitting || value === String(currentValue)) {
setIsEditing(false)
setErrorMessage(null)
return
}
setIsSubmitting(true)
setErrorMessage(null)
try {
if (entityType === 'node' && entityId && nodeId) {
let updatedData = { [name]: value }
const allowMerge = options?.allowMerge ?? false
if (name === 'entity_id') {
const exists = await checkEntityNameExists(value)
if (exists) {
toast.error(t('graphPanel.propertiesView.errors.duplicateName'))
return
if (!allowMerge) {
const exists = await checkEntityNameExists(value)
if (exists) {
const errorMsg = t('graphPanel.propertiesView.errors.duplicateName')
setErrorMessage(errorMsg)
toast.error(errorMsg)
return
}
}
updatedData = { 'entity_name': value }
}
await updateEntity(entityId, updatedData, true)
try {
await useGraphStore.getState().updateNodeAndSelect(nodeId, entityId, name, value)
} catch (error) {
console.error('Error updating node in graph:', error)
throw new Error('Failed to update node in graph')
const response = await updateEntity(entityId, updatedData, true, allowMerge)
const operationSummary = response.operation_summary
const operationStatus = operationSummary?.operation_status || 'complete_success'
const finalValue = operationSummary?.final_entity ?? value
// Handle different operation statuses
if (operationStatus === 'success') {
if (operationSummary?.merged) {
// Node was successfully merged into an existing entity
setMergeDialogInfo({
targetEntity: finalValue,
sourceEntity: entityId,
})
setMergeDialogOpen(true)
// Remove old entity name from search history
SearchHistoryManager.removeLabel(entityId)
// Note: Search Label update is deferred until user clicks refresh button in merge dialog
toast.success(t('graphPanel.propertiesView.success.entityMerged'))
} else {
// Node was updated/renamed normally
try {
const graphValue = name === 'entity_id' ? finalValue : value
await useGraphStore
.getState()
.updateNodeAndSelect(nodeId, entityId, name, graphValue)
} catch (error) {
console.error('Error updating node in graph:', error)
throw new Error('Failed to update node in graph')
}
// Update search history: remove old name, add new name
if (name === 'entity_id') {
const currentLabel = useSettingsStore.getState().queryLabel
SearchHistoryManager.removeLabel(entityId)
SearchHistoryManager.addToHistory(finalValue)
// Trigger dropdown refresh to show updated search history
useSettingsStore.getState().triggerSearchLabelDropdownRefresh()
// If current queryLabel is the old entity name, update to new name
if (currentLabel === entityId) {
useSettingsStore.getState().setQueryLabel(finalValue)
}
}
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
}
// Update local state and notify parent component
// For entity_id updates, use finalValue (which may be different due to merging)
// For other properties, use the original value the user entered
const valueToSet = name === 'entity_id' ? finalValue : value
setCurrentValue(valueToSet)
onValueChange?.(valueToSet)
} else if (operationStatus === 'partial_success') {
// Partial success: update succeeded but merge failed
// Do NOT update graph data to keep frontend in sync with backend
const mergeError = operationSummary?.merge_error || 'Unknown error'
const errorMsg = t('graphPanel.propertiesView.errors.updateSuccessButMergeFailed', {
error: mergeError
})
setErrorMessage(errorMsg)
toast.error(errorMsg)
// Do not update currentValue or call onValueChange
return
} else {
// Complete failure or unknown status
// Check if this was a merge attempt or just a regular update
if (operationSummary?.merge_status === 'failed') {
// Merge operation was attempted but failed
const mergeError = operationSummary?.merge_error || 'Unknown error'
const errorMsg = t('graphPanel.propertiesView.errors.mergeFailed', {
error: mergeError
})
setErrorMessage(errorMsg)
toast.error(errorMsg)
} else {
// Regular update failed (no merge involved)
const errorMsg = t('graphPanel.propertiesView.errors.updateFailed')
setErrorMessage(errorMsg)
toast.error(errorMsg)
}
// Do not update currentValue or call onValueChange
return
}
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
} else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) {
const updatedData = { [name]: value }
await updateRelation(sourceId, targetId, updatedData)
@ -102,19 +204,53 @@ const EditablePropertyRow = ({
throw new Error('Failed to update edge in graph')
}
toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
setCurrentValue(value)
onValueChange?.(value)
}
setIsEditing(false)
setCurrentValue(value)
onValueChange?.(value)
} catch (error) {
console.error('Error updating property:', error)
toast.error(t('graphPanel.propertiesView.errors.updateFailed'))
const errorMsg = error instanceof Error ? error.message : t('graphPanel.propertiesView.errors.updateFailed')
setErrorMessage(errorMsg)
toast.error(errorMsg)
return
} finally {
setIsSubmitting(false)
}
}
const handleMergeRefresh = (useMergedStart: boolean) => {
const info = mergeDialogInfo
const graphState = useGraphStore.getState()
const settingsState = useSettingsStore.getState()
const currentLabel = settingsState.queryLabel
// Clear graph state
graphState.clearSelection()
graphState.setGraphDataFetchAttempted(false)
graphState.setLastSuccessfulQueryLabel('')
if (useMergedStart && info?.targetEntity) {
// Use merged entity as new start point (might already be set in handleSave)
settingsState.setQueryLabel(info.targetEntity)
} else {
// Keep current start point - refresh by resetting and restoring label
// This handles the case where user wants to stay with current label
settingsState.setQueryLabel('')
setTimeout(() => {
settingsState.setQueryLabel(currentLabel)
}, 50)
}
// Force graph re-render and reset zoom/scale (same as refresh button behavior)
graphState.incrementGraphDataVersion()
setMergeDialogOpen(false)
setMergeDialogInfo(null)
toast.info(t('graphPanel.propertiesView.mergeDialog.refreshing'))
}
return (
<div className="flex items-center gap-1 overflow-hidden">
<PropertyName name={name} />
@ -131,6 +267,19 @@ const EditablePropertyRow = ({
propertyName={name}
initialValue={String(currentValue)}
isSubmitting={isSubmitting}
errorMessage={errorMessage}
/>
<MergeDialog
mergeDialogOpen={mergeDialogOpen}
mergeDialogInfo={mergeDialogInfo}
onOpenChange={(open) => {
setMergeDialogOpen(open)
if (!open) {
setMergeDialogInfo(null)
}
}}
onRefresh={handleMergeRefresh}
/>
</div>
)

View file

@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, useRef } from 'react'
import { AsyncSelect } from '@/components/ui/AsyncSelect'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import { useBackendState } from '@/stores/state'
import {
dropdownDisplayLimit,
controlButtonVariant,
@ -17,10 +18,16 @@ import { getPopularLabels, searchLabels } from '@/api/lightrag'
const GraphLabels = () => {
const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel()
const dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger()
const [isRefreshing, setIsRefreshing] = useState(false)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [selectKey, setSelectKey] = useState(0)
// Pipeline state monitoring
const pipelineBusy = useBackendState.use.pipelineBusy()
const prevPipelineBusy = useRef<boolean | undefined>(undefined)
const shouldRefreshPopularLabelsRef = useRef(false)
// Dynamic tooltip based on current label state
const getRefreshTooltip = useCallback(() => {
if (isRefreshing) {
@ -54,6 +61,61 @@ const GraphLabels = () => {
initializeHistory()
}, [])
// Force AsyncSelect to re-render when label changes externally (e.g., from entity rename/merge)
useEffect(() => {
setSelectKey(prev => prev + 1)
}, [label])
// Force AsyncSelect to re-render when dropdown refresh is triggered (e.g., after entity rename)
useEffect(() => {
if (dropdownRefreshTrigger > 0) {
setSelectKey(prev => prev + 1)
}
}, [dropdownRefreshTrigger])
// Monitor pipeline state changes: busy -> idle
useEffect(() => {
if (prevPipelineBusy.current === true && pipelineBusy === false) {
console.log('Pipeline changed from busy to idle, marking for popular labels refresh')
shouldRefreshPopularLabelsRef.current = true
}
prevPipelineBusy.current = pipelineBusy
}, [pipelineBusy])
// Helper: Reload popular labels from backend
const reloadPopularLabels = useCallback(async () => {
if (!shouldRefreshPopularLabelsRef.current) return
console.log('Reloading popular labels (triggered by pipeline idle)')
try {
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
SearchHistoryManager.clearHistory()
if (popularLabels.length === 0) {
const fallbackLabels = ['entity', 'relationship', 'document', 'concept']
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} else {
await SearchHistoryManager.initializeWithDefaults(popularLabels)
}
} catch (error) {
console.error('Failed to reload popular labels:', error)
const fallbackLabels = ['entity', 'relationship', 'document']
SearchHistoryManager.clearHistory()
await SearchHistoryManager.initializeWithDefaults(fallbackLabels)
} finally {
// Always clear the flag
shouldRefreshPopularLabelsRef.current = false
}
}, [])
// Helper: Bump dropdown data to trigger refresh
const bumpDropdownData = useCallback(({ forceSelectKey = false } = {}) => {
setRefreshTrigger(prev => prev + 1)
if (forceSelectKey) {
setSelectKey(prev => prev + 1)
}
}, [])
const fetchData = useCallback(
async (query?: string): Promise<string[]> => {
let results: string[] = [];
@ -102,6 +164,12 @@ const GraphLabels = () => {
currentLabel = '*'
}
// Scenario 1: Manual refresh - reload popular labels if flag is set (regardless of current label)
if (shouldRefreshPopularLabelsRef.current) {
await reloadPopularLabels()
bumpDropdownData({ forceSelectKey: true })
}
if (currentLabel && currentLabel !== '*') {
// Scenario 1: Has specific label, try to refresh current label
console.log(`Refreshing current label: ${currentLabel}`)
@ -122,7 +190,7 @@ const GraphLabels = () => {
console.log('Refreshing global data and popular labels')
try {
// Re-fetch popular labels and update search history
// Re-fetch popular labels and update search history (if not already done)
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
SearchHistoryManager.clearHistory()
@ -160,7 +228,16 @@ const GraphLabels = () => {
} finally {
setIsRefreshing(false)
}
}, [label])
}, [label, reloadPopularLabels, bumpDropdownData])
// Handle dropdown before open - reload popular labels if needed
const handleDropdownBeforeOpen = useCallback(async () => {
const currentLabel = useSettingsStore.getState().queryLabel
if (shouldRefreshPopularLabelsRef.current && (!currentLabel || currentLabel === '*')) {
await reloadPopularLabels()
bumpDropdownData()
}
}, [reloadPopularLabels, bumpDropdownData])
return (
<div className="flex items-center">
@ -183,6 +260,7 @@ const GraphLabels = () => {
searchInputClassName="max-h-8"
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
fetcher={fetchData}
onBeforeOpen={handleDropdownBeforeOpen}
renderOption={(item) => (
<div className="truncate" title={item}>
{item}
@ -223,6 +301,9 @@ const GraphLabels = () => {
// Update the label to trigger data loading
useSettingsStore.getState().setQueryLabel(newLabel);
// Force graph re-render and reset zoom/scale (must be AFTER setQueryLabel)
useGraphStore.getState().incrementGraphDataVersion();
}}
clearable={false} // Prevent clearing value on reselect
debounceTime={500}

View file

@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
interface MergeDialogProps {
mergeDialogOpen: boolean
mergeDialogInfo: {
targetEntity: string
sourceEntity: string
} | null
onOpenChange: (open: boolean) => void
onRefresh: (useMergedStart: boolean) => void
}
/**
* MergeDialog component that appears after a successful entity merge
* Allows user to choose whether to use the merged entity or keep current start point
*/
const MergeDialog = ({
mergeDialogOpen,
mergeDialogInfo,
onOpenChange,
onRefresh
}: MergeDialogProps) => {
const { t } = useTranslation()
const currentQueryLabel = useSettingsStore.use.queryLabel()
return (
<Dialog open={mergeDialogOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('graphPanel.propertiesView.mergeDialog.title')}</DialogTitle>
<DialogDescription>
{t('graphPanel.propertiesView.mergeDialog.description', {
source: mergeDialogInfo?.sourceEntity ?? '',
target: mergeDialogInfo?.targetEntity ?? '',
})}
</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('graphPanel.propertiesView.mergeDialog.refreshHint')}
</p>
<DialogFooter className="mt-4 flex-col gap-2 sm:flex-row sm:justify-end">
{currentQueryLabel !== mergeDialogInfo?.sourceEntity && (
<Button
type="button"
variant="outline"
onClick={() => onRefresh(false)}
>
{t('graphPanel.propertiesView.mergeDialog.keepCurrentStart')}
</Button>
)}
<Button type="button" onClick={() => onRefresh(true)}>
{t('graphPanel.propertiesView.mergeDialog.useMergedStart')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default MergeDialog

View file

@ -225,8 +225,8 @@ const PropertyRow = ({
formattedTooltip += `\n(Truncated: ${truncate})`
}
// Use EditablePropertyRow for editable fields (description, entity_id and keywords)
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'keywords')) {
// Use EditablePropertyRow for editable fields (description, entity_id and entity_type)
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'entity_type' || name === 'keywords')) {
return (
<EditablePropertyRow
name={name}
@ -325,7 +325,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
nodeId={String(node.id)}
entityId={node.properties['entity_id']}
entityType="node"
isEditable={name === 'description' || name === 'entity_id'}
isEditable={name === 'description' || name === 'entity_id' || name === 'entity_type'}
truncate={node.properties['truncate']}
/>
)

View file

@ -9,14 +9,16 @@ import {
DialogDescription
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
interface PropertyEditDialogProps {
isOpen: boolean
onClose: () => void
onSave: (value: string) => void
onSave: (value: string, options?: { allowMerge?: boolean }) => void
propertyName: string
initialValue: string
isSubmitting?: boolean
errorMessage?: string | null
}
/**
@ -29,17 +31,18 @@ const PropertyEditDialog = ({
onSave,
propertyName,
initialValue,
isSubmitting = false
isSubmitting = false,
errorMessage = null
}: PropertyEditDialogProps) => {
const { t } = useTranslation()
const [value, setValue] = useState('')
// Add error state to display save failure messages
const [error, setError] = useState<string | null>(null)
const [allowMerge, setAllowMerge] = useState(false)
// Initialize value when dialog opens
useEffect(() => {
if (isOpen) {
setValue(initialValue)
setAllowMerge(false)
}
}, [isOpen, initialValue])
@ -85,19 +88,10 @@ const PropertyEditDialog = ({
};
const handleSave = async () => {
if (value.trim() !== '') {
// Clear previous error messages
setError(null)
try {
await onSave(value)
onClose()
} catch (error) {
console.error('Save error:', error)
// Set error message to state for UI display
setError(typeof error === 'object' && error !== null
? (error as Error).message || t('common.saveFailed')
: t('common.saveFailed'))
}
const trimmedValue = value.trim()
if (trimmedValue !== '') {
const options = propertyName === 'entity_id' ? { allowMerge } : undefined
await onSave(trimmedValue, options)
}
}
@ -116,9 +110,9 @@ const PropertyEditDialog = ({
</DialogHeader>
{/* Display error message if save fails */}
{error && (
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm mt-2">
{error}
{errorMessage && (
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm">
{errorMessage}
</div>
)}
@ -146,6 +140,25 @@ const PropertyEditDialog = ({
})()}
</div>
{propertyName === 'entity_id' && (
<div className="rounded-md border border-border bg-muted/20 p-3">
<label className="flex items-start gap-2 text-sm font-medium">
<Checkbox
id="allow-merge"
checked={allowMerge}
disabled={isSubmitting}
onCheckedChange={(checked) => setAllowMerge(checked === true)}
/>
<div>
<span>{t('graphPanel.propertiesView.mergeOptionLabel')}</span>
<p className="text-xs font-normal text-muted-foreground">
{t('graphPanel.propertiesView.mergeOptionDescription')}
</p>
</div>
</label>
</div>
)}
<DialogFooter>
<Button
type="button"

View file

@ -43,6 +43,8 @@ export interface AsyncSelectProps<T> {
value: string
/** Callback when selection changes */
onChange: (value: string) => void
/** Callback before opening the dropdown (async supported) */
onBeforeOpen?: () => void | Promise<void>
/** Accessibility label for the select field */
ariaLabel?: string
/** Placeholder text when no selection */
@ -83,6 +85,7 @@ export function AsyncSelect<T>({
searchPlaceholder,
value,
onChange,
onBeforeOpen,
disabled = false,
className,
triggerClassName,
@ -196,8 +199,18 @@ export function AsyncSelect<T>({
[selectedValue, onChange, clearable, options, getOptionValue]
)
const handleOpenChange = useCallback(
async (newOpen: boolean) => {
if (newOpen && onBeforeOpen) {
await onBeforeOpen()
}
setOpen(newOpen)
},
[onBeforeOpen]
)
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"

View file

@ -226,13 +226,13 @@ const GraphViewer = () => {
</div>
{showPropertyPanel && (
<div className="absolute top-2 right-2">
<div className="absolute top-2 right-2 z-10">
<PropertiesView />
</div>
)}
{showLegend && (
<div className="absolute bottom-10 right-2">
<div className="absolute bottom-10 right-2 z-0">
<Legend className="bg-background/60 backdrop-blur-lg" />
</div>
)}

View file

@ -62,6 +62,12 @@ export default function SiteHeader() {
? `${coreVersion}/${apiVersion}`
: null;
// Check if frontend needs rebuild (apiVersion ends with warning symbol)
const hasWarning = apiVersion?.endsWith('⚠️');
const versionTooltip = hasWarning
? t('header.frontendNeedsRebuild')
: versionDisplay ? `v${versionDisplay}` : '';
const handleLogout = () => {
navigationService.navigateToLogin();
}
@ -106,9 +112,18 @@ export default function SiteHeader() {
<nav className="w-[200px] flex items-center justify-end">
<div className="flex items-center gap-2">
{versionDisplay && (
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1">
v{versionDisplay}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1 cursor-default">
v{versionDisplay}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{versionTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}>
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">

View file

@ -10,225 +10,18 @@ import { useBackendState } from '@/stores/state'
import { useSettingsStore } from '@/stores/settings'
import seedrandom from 'seedrandom'
const TYPE_SYNONYMS: Record<string, string> = {
'unknown': 'unknown',
'未知': 'unknown',
'other': 'other',
'其它': 'other',
'concept': 'concept',
'object': 'concept',
'type': 'concept',
'category': 'concept',
'model': 'concept',
'project': 'concept',
'condition': 'concept',
'rule': 'concept',
'regulation': 'concept',
'article': 'concept',
'law': 'concept',
'legalclause': 'concept',
'policy': 'concept',
'disease': 'concept',
'概念': 'concept',
'对象': 'concept',
'类别': 'concept',
'分类': 'concept',
'模型': 'concept',
'项目': 'concept',
'条件': 'concept',
'规则': 'concept',
'法律': 'concept',
'法律条款': 'concept',
'条文': 'concept',
'政策': 'policy',
'疾病': 'concept',
'method': 'method',
'process': 'method',
'方法': 'method',
'过程': 'method',
'artifact': 'artifact',
'technology': 'artifact',
'tech': 'artifact',
'product': 'artifact',
'equipment': 'artifact',
'device': 'artifact',
'stuff': 'artifact',
'component': 'artifact',
'material': 'artifact',
'chemical': 'artifact',
'drug': 'artifact',
'medicine': 'artifact',
'food': 'artifact',
'weapon': 'artifact',
'arms': 'artifact',
'人工制品': 'artifact',
'人造物品': 'artifact',
'技术': 'technology',
'科技': 'technology',
'产品': 'artifact',
'设备': 'artifact',
'装备': 'artifact',
'物品': 'artifact',
'材料': 'artifact',
'化学': 'artifact',
'药物': 'artifact',
'食品': 'artifact',
'武器': 'artifact',
'军火': 'artifact',
'naturalobject': 'naturalobject',
'natural': 'naturalobject',
'phenomena': 'naturalobject',
'substance': 'naturalobject',
'plant': 'naturalobject',
'自然对象': 'naturalobject',
'自然物体': 'naturalobject',
'自然现象': 'naturalobject',
'物质': 'naturalobject',
'物体': 'naturalobject',
'data': 'data',
'figure': 'data',
'value': 'data',
'数据': 'data',
'数字': 'data',
'数值': 'data',
'content': 'content',
'book': 'content',
'video': 'content',
'内容': 'content',
'作品': 'content',
'书籍': 'content',
'视频': 'content',
'organization': 'organization',
'org': 'organization',
'company': 'organization',
'组织': 'organization',
'公司': 'organization',
'机构': 'organization',
'组织机构': 'organization',
'event': 'event',
'事件': 'event',
'activity': 'event',
'活动': 'event',
'person': 'person',
'people': 'person',
'human': 'person',
'role': 'person',
'人物': 'person',
'人类': 'person',
'人': 'person',
'角色': 'person',
'creature': 'creature',
'animal': 'creature',
'beings': 'creature',
'being': 'creature',
'alien': 'creature',
'ghost': 'creature',
'动物': 'creature',
'生物': 'creature',
'神仙': 'creature',
'鬼怪': 'creature',
'妖怪': 'creature',
'location': 'location',
'geography': 'location',
'geo': 'location',
'place': 'location',
'address': 'location',
'地点': 'location',
'位置': 'location',
'地址': 'location',
'地理': 'location',
'地域': 'location',
};
// node type to color mapping
const NODE_TYPE_COLORS: Record<string, string> = {
'person': '#4169E1', // RoyalBlue
'creature': '#bd7ebe', // LightViolet
'organization': '#00cc00', // LightGreen
'location': '#cf6d17', // Carrot
'event': '#00bfa0', // Turquoise
'concept': '#e3493b', // GoogleRed
'method': '#b71c1c', // red
'content': '#0f558a', // NavyBlue
'data': '#0000ff', // Blue
'artifact': '#4421af', // DeepPurple
'naturalobject': '#b2e061', // YellowGreen
'other': '#f4d371', // Yellow
'unknown': '#b0b0b0', // Yellow
};
// Extended colors pool - Used for unknown node types
const EXTENDED_COLORS = [
'#84a3e1', // SkyBlue
'#5a2c6d', // DeepViolet
'#2F4F4F', // DarkSlateGray
'#003366', // DarkBlue
'#9b3a31', // DarkBrown
'#00CED1', // DarkTurquoise
'#b300b3', // Purple
'#0f705d', // Green
'#ff99cc', // Pale Pink
'#6ef7b3', // LightGreen
'#cd071e', // ChinaRed
];
import { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor'
// Select color based on node type
const getNodeColorByType = (nodeType: string | undefined): string => {
const state = useGraphStore.getState()
const { color, map, updated } = resolveNodeColor(nodeType, state.typeColorMap)
const defaultColor = '#5D6D7E';
const normalizedType = nodeType ? nodeType.toLowerCase() : 'unknown';
const typeColorMap = useGraphStore.getState().typeColorMap;
// First try to find standard type
const standardType = TYPE_SYNONYMS[normalizedType];
// Check cache using standard type if available, otherwise use normalized type
const cacheKey = standardType || normalizedType;
if (typeColorMap.has(cacheKey)) {
return typeColorMap.get(cacheKey) || defaultColor;
if (updated) {
useGraphStore.setState({ typeColorMap: map })
}
if (standardType) {
const color = NODE_TYPE_COLORS[standardType];
// Store using standard type name as key
const newMap = new Map(typeColorMap);
newMap.set(standardType, color);
useGraphStore.setState({ typeColorMap: newMap });
return color;
}
// For unpredefind nodeTypes, use extended colors
// Find used extended colors
const usedExtendedColors = new Set(
Array.from(typeColorMap.entries())
.filter(([, color]) => !Object.values(NODE_TYPE_COLORS).includes(color))
.map(([, color]) => color)
);
// Find and use the first unused extended color
const unusedColor = EXTENDED_COLORS.find(color => !usedExtendedColors.has(color));
const newColor = unusedColor || defaultColor;
// Update color mapping - use normalized type for unknown types
const newMap = new Map(typeColorMap);
newMap.set(normalizedType, newColor);
useGraphStore.setState({ typeColorMap: newMap });
return newColor;
return color || DEFAULT_NODE_COLOR
};

View file

@ -13,6 +13,7 @@
"api": "واجهة برمجة التطبيقات",
"projectRepository": "مستودع المشروع",
"logout": "تسجيل الخروج",
"frontendNeedsRebuild": "الواجهة الأمامية تحتاج إلى إعادة البناء",
"themeToggle": {
"switchToLight": "التحويل إلى السمة الفاتحة",
"switchToDark": "التحويل إلى السمة الداكنة"
@ -305,11 +306,24 @@
"errors": {
"duplicateName": "اسم العقدة موجود بالفعل",
"updateFailed": "فشل تحديث العقدة",
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا"
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا",
"updateSuccessButMergeFailed": "تم تحديث الخصائص، لكن الدمج فشل: {{error}}",
"mergeFailed": "فشل الدمج: {{error}}"
},
"success": {
"entityUpdated": "تم تحديث العقدة بنجاح",
"relationUpdated": "تم تحديث العلاقة بنجاح"
"relationUpdated": "تم تحديث العلاقة بنجاح",
"entityMerged": "تم دمج العقد بنجاح"
},
"mergeOptionLabel": "دمج تلقائي عند العثور على اسم مكرر",
"mergeOptionDescription": "عند التفعيل، سيتم دمج هذه العقدة تلقائيًا في العقدة الموجودة بدلاً من ظهور خطأ عند إعادة التسمية بنفس الاسم.",
"mergeDialog": {
"title": "تم دمج العقدة",
"description": "\"{{source}}\" تم دمجها في \"{{target}}\".",
"refreshHint": "يجب تحديث الرسم البياني لتحميل البنية الأحدث.",
"keepCurrentStart": "تحديث مع الحفاظ على عقدة البدء الحالية",
"useMergedStart": "تحديث واستخدام العقدة المدمجة كنقطة بدء",
"refreshing": "جارٍ تحديث الرسم البياني..."
},
"node": {
"title": "عقدة",

View file

@ -13,6 +13,7 @@
"api": "API",
"projectRepository": "Project Repository",
"logout": "Logout",
"frontendNeedsRebuild": "Frontend needs rebuild",
"themeToggle": {
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme"
@ -305,11 +306,24 @@
"errors": {
"duplicateName": "Node name already exists",
"updateFailed": "Failed to update node",
"tryAgainLater": "Please try again later"
"tryAgainLater": "Please try again later",
"updateSuccessButMergeFailed": "Properties updated, but merge failed: {{error}}",
"mergeFailed": "Merge failed: {{error}}"
},
"success": {
"entityUpdated": "Node updated successfully",
"relationUpdated": "Relation updated successfully"
"relationUpdated": "Relation updated successfully",
"entityMerged": "Nodes merged successfully"
},
"mergeOptionLabel": "Automatically merge when a duplicate name is found",
"mergeOptionDescription": "If enabled, renaming to an existing name will merge this node into the existing one instead of failing.",
"mergeDialog": {
"title": "Node merged",
"description": "\"{{source}}\" has been merged into \"{{target}}\".",
"refreshHint": "Refresh the graph to load the latest structure.",
"keepCurrentStart": "Refresh and keep current start node",
"useMergedStart": "Refresh and use merged node",
"refreshing": "Refreshing graph..."
},
"node": {
"title": "Node",

View file

@ -13,6 +13,7 @@
"api": "API",
"projectRepository": "Référentiel du projet",
"logout": "Déconnexion",
"frontendNeedsRebuild": "Le frontend nécessite une reconstruction",
"themeToggle": {
"switchToLight": "Passer au thème clair",
"switchToDark": "Passer au thème sombre"
@ -305,11 +306,24 @@
"errors": {
"duplicateName": "Le nom du nœud existe déjà",
"updateFailed": "Échec de la mise à jour du nœud",
"tryAgainLater": "Veuillez réessayer plus tard"
"tryAgainLater": "Veuillez réessayer plus tard",
"updateSuccessButMergeFailed": "Propriétés mises à jour, mais la fusion a échoué : {{error}}",
"mergeFailed": "Échec de la fusion : {{error}}"
},
"success": {
"entityUpdated": "Nœud mis à jour avec succès",
"relationUpdated": "Relation mise à jour avec succès"
"relationUpdated": "Relation mise à jour avec succès",
"entityMerged": "Fusion des nœuds réussie"
},
"mergeOptionLabel": "Fusionner automatiquement en cas de nom dupliqué",
"mergeOptionDescription": "Si activé, renommer vers un nom existant fusionnera automatiquement ce nœud avec celui-ci au lieu d'échouer.",
"mergeDialog": {
"title": "Nœud fusionné",
"description": "\"{{source}}\" a été fusionné dans \"{{target}}\".",
"refreshHint": "Actualisez le graphe pour charger la structure la plus récente.",
"keepCurrentStart": "Actualiser en conservant le nœud de départ actuel",
"useMergedStart": "Actualiser en utilisant le nœud fusionné",
"refreshing": "Actualisation du graphe..."
},
"node": {
"title": "Nœud",

View file

@ -13,6 +13,7 @@
"api": "API",
"projectRepository": "项目仓库",
"logout": "退出登录",
"frontendNeedsRebuild": "前端代码需重新构建",
"themeToggle": {
"switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题"
@ -305,11 +306,24 @@
"errors": {
"duplicateName": "节点名称已存在",
"updateFailed": "更新节点失败",
"tryAgainLater": "请稍后重试"
"tryAgainLater": "请稍后重试",
"updateSuccessButMergeFailed": "属性已更新,但合并失败:{{error}}",
"mergeFailed": "合并失败:{{error}}"
},
"success": {
"entityUpdated": "节点更新成功",
"relationUpdated": "关系更新成功"
"relationUpdated": "关系更新成功",
"entityMerged": "节点合并成功"
},
"mergeOptionLabel": "重名时自动合并",
"mergeOptionDescription": "勾选后,重命名为已存在的名称会将当前节点自动合并过去,而不会报错。",
"mergeDialog": {
"title": "节点已合并",
"description": "\"{{source}}\" 已合并到 \"{{target}}\"。",
"refreshHint": "请刷新图谱以获取最新结构。",
"keepCurrentStart": "刷新并保持当前起始节点",
"useMergedStart": "刷新并以合并后的节点为起始节点",
"refreshing": "正在刷新图谱..."
},
"node": {
"title": "节点",

View file

@ -13,6 +13,7 @@
"api": "API",
"projectRepository": "專案庫",
"logout": "登出",
"frontendNeedsRebuild": "前端程式碼需重新建置",
"themeToggle": {
"switchToLight": "切換至淺色主題",
"switchToDark": "切換至深色主題"
@ -305,11 +306,24 @@
"errors": {
"duplicateName": "節點名稱已存在",
"updateFailed": "更新節點失敗",
"tryAgainLater": "請稍後重試"
"tryAgainLater": "請稍後重試",
"updateSuccessButMergeFailed": "屬性已更新,但合併失敗:{{error}}",
"mergeFailed": "合併失敗:{{error}}"
},
"success": {
"entityUpdated": "節點更新成功",
"relationUpdated": "關係更新成功"
"relationUpdated": "關係更新成功",
"entityMerged": "節點合併成功"
},
"mergeOptionLabel": "遇到重名時自動合併",
"mergeOptionDescription": "勾選後,重新命名為既有名稱時會自動將當前節點合併過去,不再報錯。",
"mergeDialog": {
"title": "節點已合併",
"description": "\"{{source}}\" 已合併到 \"{{target}}\"。",
"refreshHint": "請重新整理圖譜以取得最新結構。",
"keepCurrentStart": "重新整理並保留目前的起始節點",
"useMergedStart": "重新整理並以合併後的節點為起始節點",
"refreshing": "正在重新整理圖譜..."
},
"node": {
"title": "節點",

View file

@ -2,6 +2,7 @@ import { create } from 'zustand'
import { createSelectors } from '@/lib/utils'
import { DirectedGraph } from 'graphology'
import MiniSearch from 'minisearch'
import { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor'
export type RawNodeType = {
// for NetworkX: id is identical to properties['entity_id']
@ -246,7 +247,7 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
console.log('updateNodeAndSelect', nodeId, entityId, propertyName, newValue)
// For entity_id changes (node renaming) with NetworkX graph storage
// For entity_id changes (node renaming) with raw graph storage
if ((nodeId === entityId) && (propertyName === 'entity_id')) {
// Create new node with updated ID but same attributes
sigmaGraph.addNode(newValue, { ...nodeAttributes, label: newValue })
@ -319,11 +320,21 @@ const useGraphStoreBase = create<GraphState>()((set, get) => ({
// For non-NetworkX nodes or non-entity_id changes
const nodeIndex = rawGraph.nodeIdMap[String(nodeId)]
if (nodeIndex !== undefined) {
rawGraph.nodes[nodeIndex].properties[propertyName] = newValue
const nodeRef = rawGraph.nodes[nodeIndex]
nodeRef.properties[propertyName] = newValue
if (propertyName === 'entity_id') {
rawGraph.nodes[nodeIndex].labels = [newValue]
nodeRef.labels = [newValue]
sigmaGraph.setNodeAttribute(String(nodeId), 'label', newValue)
}
if (propertyName === 'entity_type') {
const { color, map, updated } = resolveNodeColor(newValue, state.typeColorMap)
const resolvedColor = color || DEFAULT_NODE_COLOR
nodeRef.color = resolvedColor
sigmaGraph.setNodeAttribute(String(nodeId), 'color', resolvedColor)
if (updated) {
set({ typeColorMap: map })
}
}
}
// Trigger a re-render by incrementing the version counter

View file

@ -78,6 +78,10 @@ interface SettingsState {
currentTab: Tab
setCurrentTab: (tab: Tab) => void
// Search label dropdown refresh trigger (non-persistent, runtime only)
searchLabelDropdownRefreshTrigger: number
triggerSearchLabelDropdownRefresh: () => void
}
const useSettingsStoreBase = create<SettingsState>()(
@ -229,7 +233,14 @@ const useSettingsStoreBase = create<SettingsState>()(
})
},
setUserPromptHistory: (history: string[]) => set({ userPromptHistory: history })
setUserPromptHistory: (history: string[]) => set({ userPromptHistory: history }),
// Search label dropdown refresh trigger (not persisted)
searchLabelDropdownRefreshTrigger: 0,
triggerSearchLabelDropdownRefresh: () =>
set((state) => ({
searchLabelDropdownRefreshTrigger: state.searchLabelDropdownRefreshTrigger + 1
}))
}),
{
name: 'settings-storage',

View file

@ -0,0 +1,228 @@
const DEFAULT_NODE_COLOR = '#5D6D7E'
const TYPE_SYNONYMS: Record<string, string> = {
unknown: 'unknown',
: 'unknown',
other: 'other',
: 'other',
concept: 'concept',
object: 'concept',
type: 'concept',
category: 'concept',
model: 'concept',
project: 'concept',
condition: 'concept',
rule: 'concept',
regulation: 'concept',
article: 'concept',
law: 'concept',
legalclause: 'concept',
policy: 'concept',
disease: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'policy',
: 'concept',
method: 'method',
process: 'method',
: 'method',
: 'method',
artifact: 'artifact',
technology: 'artifact',
tech: 'artifact',
product: 'artifact',
equipment: 'artifact',
device: 'artifact',
stuff: 'artifact',
component: 'artifact',
material: 'artifact',
chemical: 'artifact',
drug: 'artifact',
medicine: 'artifact',
food: 'artifact',
weapon: 'artifact',
arms: 'artifact',
: 'artifact',
: 'artifact',
: 'technology',
: 'technology',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
naturalobject: 'naturalobject',
natural: 'naturalobject',
phenomena: 'naturalobject',
substance: 'naturalobject',
plant: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
data: 'data',
figure: 'data',
value: 'data',
: 'data',
: 'data',
: 'data',
content: 'content',
book: 'content',
video: 'content',
: 'content',
: 'content',
: 'content',
: 'content',
organization: 'organization',
org: 'organization',
company: 'organization',
: 'organization',
: 'organization',
: 'organization',
: 'organization',
event: 'event',
: 'event',
activity: 'event',
: 'event',
person: 'person',
people: 'person',
human: 'person',
role: 'person',
: 'person',
: 'person',
: 'person',
: 'person',
creature: 'creature',
animal: 'creature',
beings: 'creature',
being: 'creature',
alien: 'creature',
ghost: 'creature',
: 'creature',
: 'creature',
: 'creature',
: 'creature',
: 'creature',
location: 'location',
geography: 'location',
geo: 'location',
place: 'location',
address: 'location',
: 'location',
: 'location',
: 'location',
: 'location',
: 'location'
}
const NODE_TYPE_COLORS: Record<string, string> = {
person: '#4169E1',
creature: '#bd7ebe',
organization: '#00cc00',
location: '#cf6d17',
event: '#00bfa0',
concept: '#e3493b',
method: '#b71c1c',
content: '#0f558a',
data: '#0000ff',
artifact: '#4421af',
naturalobject: '#b2e061',
other: '#f4d371',
unknown: '#b0b0b0'
}
const EXTENDED_COLORS = [
'#84a3e1',
'#5a2c6d',
'#2F4F4F',
'#003366',
'#9b3a31',
'#00CED1',
'#b300b3',
'#0f705d',
'#ff99cc',
'#6ef7b3',
'#cd071e'
]
const PREDEFINED_COLOR_SET = new Set(Object.values(NODE_TYPE_COLORS))
interface ResolveNodeColorResult {
color: string
map: Map<string, string>
updated: boolean
}
export const resolveNodeColor = (
nodeType: string | undefined,
currentMap: Map<string, string> | undefined
): ResolveNodeColorResult => {
const typeColorMap = currentMap ?? new Map<string, string>()
const normalizedType = nodeType ? nodeType.toLowerCase() : 'unknown'
const standardType = TYPE_SYNONYMS[normalizedType]
const cacheKey = standardType || normalizedType
if (typeColorMap.has(cacheKey)) {
return {
color: typeColorMap.get(cacheKey) || DEFAULT_NODE_COLOR,
map: typeColorMap,
updated: false
}
}
if (standardType) {
const color = NODE_TYPE_COLORS[standardType] || DEFAULT_NODE_COLOR
const newMap = new Map(typeColorMap)
newMap.set(standardType, color)
return {
color,
map: newMap,
updated: true
}
}
const usedExtendedColors = new Set(
Array.from(typeColorMap.values()).filter((color) => !PREDEFINED_COLOR_SET.has(color))
)
const unusedColor = EXTENDED_COLORS.find((color) => !usedExtendedColors.has(color))
const color = unusedColor || DEFAULT_NODE_COLOR
const newMap = new Map(typeColorMap)
newMap.set(normalizedType, color)
return {
color,
map: newMap,
updated: true
}
}
export { DEFAULT_NODE_COLOR }

View file

@ -40,7 +40,7 @@ export default defineConfig({
changeOrigin: true,
rewrite: endpoint === '/api' ?
(path) => path.replace(/^\/api/, '') :
endpoint === '/docs' || endpoint === '/redoc' || endpoint === '/openapi.json' ?
endpoint === '/docs' || endpoint === '/redoc' || endpoint === '/openapi.json' || endpoint === '/static' ?
(path) => path : undefined
}
])

View file

@ -80,15 +80,16 @@ api = [
# Offline deployment dependencies (layered design for flexibility)
offline-docs = [
# Document processing dependencies
"openpyxl>=3.0.0,<4.0.0",
"pycryptodome>=3.0.0,<4.0.0",
"pypdf2>=3.0.0",
"python-docx>=0.8.11,<2.0.0",
"python-pptx>=0.6.21,<2.0.0",
"openpyxl>=3.0.0,<4.0.0",
]
offline-storage = [
# Storage backend dependencies
"redis>=5.0.0,<7.0.0",
"redis>=5.0.0,<8.0.0",
"neo4j>=5.0.0,<7.0.0",
"pymilvus>=2.6.2,<3.0.0",
"pymongo>=4.0.0,<5.0.0",
@ -112,6 +113,11 @@ offline = [
"lightrag-hku[offline-docs,offline-storage,offline-llm]",
]
observability = [
# LLM observability and tracing dependencies
"langfuse>=3.8.1",
]
[project.scripts]
lightrag-server = "lightrag.api.lightrag_server:main"
lightrag-gunicorn = "lightrag.api.run_with_gunicorn:main"
@ -134,7 +140,7 @@ include-package-data = true
version = {attr = "lightrag.__version__"}
[tool.setuptools.package-data]
lightrag = ["api/webui/**/*"]
lightrag = ["api/webui/**/*", "api/static/**/*"]
[tool.ruff]
target-version = "py310"

View file

@ -9,6 +9,7 @@
# Document processing dependencies (with version constraints matching pyproject.toml)
openpyxl>=3.0.0,<4.0.0
pycryptodome>=3.0.0,<4.0.0
pypdf2>=3.0.0
python-docx>=0.8.11,<2.0.0
python-pptx>=0.6.21,<2.0.0

View file

@ -13,4 +13,4 @@ neo4j>=5.0.0,<7.0.0
pymilvus>=2.6.2,<3.0.0
pymongo>=4.0.0,<5.0.0
qdrant-client>=1.7.0,<2.0.0
redis>=5.0.0,<7.0.0
redis>=5.0.0,<8.0.0

View file

@ -20,12 +20,13 @@ neo4j>=5.0.0,<7.0.0
ollama>=0.1.0,<1.0.0
openai>=1.0.0,<3.0.0
openpyxl>=3.0.0,<4.0.0
pycryptodome>=3.0.0,<4.0.0
pymilvus>=2.6.2,<3.0.0
pymongo>=4.0.0,<5.0.0
pypdf2>=3.0.0
python-docx>=0.8.11,<2.0.0
python-pptx>=0.6.21,<2.0.0
qdrant-client>=1.7.0,<2.0.0
redis>=5.0.0,<7.0.0
redis>=5.0.0,<8.0.0
voyageai>=0.2.0,<1.0.0
zhipuai>=2.0.0,<3.0.0

1663
uv.lock generated

File diff suppressed because it is too large Load diff