Merge branch 'main' into duplicate_dev
This commit is contained in:
commit
cf6bed7dc0
60 changed files with 3637 additions and 1991 deletions
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -46,10 +46,8 @@ neo4jWorkDir/
|
||||||
|
|
||||||
# Data & Storage
|
# Data & Storage
|
||||||
inputs/
|
inputs/
|
||||||
|
output/
|
||||||
rag_storage/
|
rag_storage/
|
||||||
examples/input/
|
|
||||||
examples/output/
|
|
||||||
output*/
|
|
||||||
data/
|
data/
|
||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
|
|
@ -59,18 +57,15 @@ ignore_this.txt
|
||||||
*.ignore.*
|
*.ignore.*
|
||||||
|
|
||||||
# Project-specific files
|
# Project-specific files
|
||||||
dickens*/
|
/dickens*/
|
||||||
book.txt
|
/book.txt
|
||||||
LightRAG.pdf
|
|
||||||
download_models_hf.py
|
download_models_hf.py
|
||||||
lightrag-dev/
|
|
||||||
gui/
|
|
||||||
|
|
||||||
# Frontend build output (built during PyPI release)
|
# Frontend build output (built during PyPI release)
|
||||||
lightrag/api/webui/
|
/lightrag/api/webui/
|
||||||
|
|
||||||
# unit-test files
|
# unit-test files
|
||||||
test_*
|
test_*
|
||||||
|
|
||||||
# Cline files
|
# Cline files
|
||||||
memory-bank/
|
memory-bank
|
||||||
|
|
|
||||||
14
Dockerfile
14
Dockerfile
|
|
@ -1,3 +1,5 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Frontend build stage
|
# Frontend build stage
|
||||||
FROM oven/bun:1 AS frontend-builder
|
FROM oven/bun:1 AS frontend-builder
|
||||||
|
|
||||||
|
|
@ -7,7 +9,8 @@ WORKDIR /app
|
||||||
COPY lightrag_webui/ ./lightrag_webui/
|
COPY lightrag_webui/ ./lightrag_webui/
|
||||||
|
|
||||||
# Build frontend assets for inclusion in the API package
|
# 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 install --frozen-lockfile \
|
||||||
&& bun run build
|
&& bun run build
|
||||||
|
|
||||||
|
|
@ -40,7 +43,8 @@ COPY setup.py .
|
||||||
COPY uv.lock .
|
COPY uv.lock .
|
||||||
|
|
||||||
# Install base, API, and offline extras without the project to improve caching
|
# 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 project sources after dependency layer
|
||||||
COPY lightrag/ ./lightrag/
|
COPY lightrag/ ./lightrag/
|
||||||
|
|
@ -49,7 +53,8 @@ COPY lightrag/ ./lightrag/
|
||||||
COPY --from=frontend-builder /app/lightrag/api/webui ./lightrag/api/webui
|
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
|
# 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
|
&& /app/.venv/bin/python -m ensurepip --upgrade
|
||||||
|
|
||||||
# Prepare offline cache directory and pre-populate tiktoken data
|
# 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)
|
# Install dependencies with uv sync (uses locked versions from uv.lock)
|
||||||
# And ensure pip is available for runtime installs
|
# 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
|
&& /app/.venv/bin/python -m ensurepip --upgrade
|
||||||
|
|
||||||
# Create persistent data directories AFTER package installation
|
# Create persistent data directories AFTER package installation
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Frontend build stage
|
# Frontend build stage
|
||||||
FROM oven/bun:1 AS frontend-builder
|
FROM oven/bun:1 AS frontend-builder
|
||||||
|
|
||||||
|
|
@ -7,7 +9,8 @@ WORKDIR /app
|
||||||
COPY lightrag_webui/ ./lightrag_webui/
|
COPY lightrag_webui/ ./lightrag_webui/
|
||||||
|
|
||||||
# Build frontend assets for inclusion in the API package
|
# 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 install --frozen-lockfile \
|
||||||
&& bun run build
|
&& bun run build
|
||||||
|
|
||||||
|
|
@ -40,7 +43,8 @@ COPY setup.py .
|
||||||
COPY uv.lock .
|
COPY uv.lock .
|
||||||
|
|
||||||
# Install project dependencies (base + API extras) without the project to improve caching
|
# 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 project sources after dependency layer
|
||||||
COPY lightrag/ ./lightrag/
|
COPY lightrag/ ./lightrag/
|
||||||
|
|
@ -49,7 +53,8 @@ COPY lightrag/ ./lightrag/
|
||||||
COPY --from=frontend-builder /app/lightrag/api/webui ./lightrag/api/webui
|
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
|
# 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
|
&& /app/.venv/bin/python -m ensurepip --upgrade
|
||||||
|
|
||||||
# Prepare tiktoken cache directory and pre-populate tokenizer data
|
# 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
|
# Sync dependencies inside the final image using uv
|
||||||
# And ensure pip is available for runtime installs
|
# 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
|
&& /app/.venv/bin/python -m ensurepip --upgrade
|
||||||
|
|
||||||
# Create persistent data directories
|
# Create persistent data directories
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
include requirements.txt
|
include requirements.txt
|
||||||
include lightrag/api/requirements.txt
|
include lightrag/api/requirements.txt
|
||||||
recursive-include lightrag/api/webui *
|
recursive-include lightrag/api/webui *
|
||||||
|
recursive-include lightrag/api/static *
|
||||||
|
|
|
||||||
16
README-zh.md
16
README-zh.md
|
|
@ -104,18 +104,26 @@ lightrag-server
|
||||||
git clone https://github.com/HKUDS/LightRAG.git
|
git clone https://github.com/HKUDS/LightRAG.git
|
||||||
cd LightRAG
|
cd LightRAG
|
||||||
# 如有必要,创建Python虚拟环境
|
# 如有必要,创建Python虚拟环境
|
||||||
# 以可编辑模式安装并支持API
|
# 以可开发(编辑)模式安装LightRAG服务器
|
||||||
pip install -e ".[api]"
|
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
|
lightrag-server
|
||||||
```
|
```
|
||||||
|
|
||||||
* 使用 Docker Compose 启动 LightRAG 服务器
|
* 使用 Docker Compose 启动 LightRAG 服务器
|
||||||
|
|
||||||
```
|
```bash
|
||||||
git clone https://github.com/HKUDS/LightRAG.git
|
git clone https://github.com/HKUDS/LightRAG.git
|
||||||
cd LightRAG
|
cd LightRAG
|
||||||
cp env.example .env
|
cp env.example .env # 使用你的LLM和Embedding模型访问参数更新.env文件
|
||||||
# modify LLM and Embedding settings in .env
|
# modify LLM and Embedding settings in .env
|
||||||
docker compose up
|
docker compose up
|
||||||
```
|
```
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -103,19 +103,27 @@ lightrag-server
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/HKUDS/LightRAG.git
|
git clone https://github.com/HKUDS/LightRAG.git
|
||||||
cd LightRAG
|
cd LightRAG
|
||||||
# create a Python virtual enviroment if neccesary
|
# Create a Python virtual enviroment if neccesary
|
||||||
# Install in editable mode with API support
|
# Install in editable mode with API support
|
||||||
pip install -e ".[api]"
|
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
|
lightrag-server
|
||||||
```
|
```
|
||||||
|
|
||||||
* Launching the LightRAG Server with Docker Compose
|
* Launching the LightRAG Server with Docker Compose
|
||||||
|
|
||||||
```
|
```bash
|
||||||
git clone https://github.com/HKUDS/LightRAG.git
|
git clone https://github.com/HKUDS/LightRAG.git
|
||||||
cd LightRAG
|
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
|
# modify LLM and Embedding settings in .env
|
||||||
docker compose up
|
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:
|
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 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 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`
|
- **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
|
### 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>
|
<details>
|
||||||
<summary> Prompt </summary>
|
<summary> Prompt </summary>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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:
|
### Start LightRAG server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
LightRAG Server uses the following paths for data storage:
|
LightRAG Server uses the following paths for data storage:
|
||||||
|
|
@ -77,9 +86,9 @@ data/
|
||||||
|
|
||||||
To update the Docker container:
|
To update the Docker container:
|
||||||
```bash
|
```bash
|
||||||
docker-compose pull
|
docker compose pull
|
||||||
docker-compose down
|
docker compose down
|
||||||
docker-compose up
|
docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
### Offline deployment
|
### Offline deployment
|
||||||
|
|
@ -91,10 +100,15 @@ Software packages requiring `transformers`, `torch`, or `cuda` will is not prein
|
||||||
### For local development and testing
|
### For local development and testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build and run with docker-compose
|
# Build and run with Docker Compose (BuildKit automatically enabled)
|
||||||
docker compose up --build
|
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
|
### For production release
|
||||||
|
|
||||||
**multi-architecture build and push**:
|
**multi-architecture build and push**:
|
||||||
|
|
|
||||||
13
env.example
13
env.example
|
|
@ -119,6 +119,9 @@ ENABLE_LLM_CACHE_FOR_EXTRACT=true
|
||||||
### Document processing output language: English, Chinese, French, German ...
|
### Document processing output language: English, Chinese, French, German ...
|
||||||
SUMMARY_LANGUAGE=English
|
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 that the LLM will attempt to recognize
|
||||||
# ENTITY_TYPES='["Person", "Creature", "Organization", "Location", "Event", "Concept", "Method", "Content", "Data", "Artifact", "NaturalObject"]'
|
# 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
|
### Num of chunks send to Embedding in single request
|
||||||
# EMBEDDING_BATCH_NUM=10
|
# EMBEDDING_BATCH_NUM=10
|
||||||
|
|
||||||
###########################################################
|
###########################################################################
|
||||||
### LLM Configuration
|
### LLM Configuration
|
||||||
### LLM_BINDING type: openai, ollama, lollms, azure_openai, aws_bedrock
|
### 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 request timeout setting for all llm (0 means no timeout for Ollma)
|
||||||
# LLM_TIMEOUT=180
|
# LLM_TIMEOUT=180
|
||||||
|
|
||||||
|
|
@ -221,10 +225,11 @@ OLLAMA_LLM_NUM_CTX=32768
|
||||||
### Bedrock Specific Parameters
|
### Bedrock Specific Parameters
|
||||||
# BEDROCK_LLM_TEMPERATURE=1.0
|
# BEDROCK_LLM_TEMPERATURE=1.0
|
||||||
|
|
||||||
####################################################################################
|
#######################################################################################
|
||||||
### Embedding Configuration (Should not be changed after the first file processed)
|
### Embedding Configuration (Should not be changed after the first file processed)
|
||||||
### EMBEDDING_BINDING: ollama, openai, azure_openai, jina, lollms, aws_bedrock
|
### 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_TIMEOUT=30
|
||||||
EMBEDDING_BINDING=ollama
|
EMBEDDING_BINDING=ollama
|
||||||
EMBEDDING_MODEL=bge-m3:latest
|
EMBEDDING_MODEL=bge-m3:latest
|
||||||
|
|
|
||||||
55
examples/generate_query.py
Normal file
55
examples/generate_query.py
Normal 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}")
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
source /home/netman/lightrag-xyj/venv/bin/activate
|
|
||||||
lightrag-server
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LightRAG XYJ Ollama Service
|
Description=LightRAG XYJ Service
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|
@ -8,10 +8,23 @@ User=netman
|
||||||
# Memory settings
|
# Memory settings
|
||||||
MemoryHigh=8G
|
MemoryHigh=8G
|
||||||
MemoryMax=12G
|
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
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=30
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
|
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
|
||||||
|
|
||||||
__version__ = "1.4.9.5"
|
__version__ = "1.4.9.8"
|
||||||
__author__ = "Zirui Guo"
|
__author__ = "Zirui Guo"
|
||||||
__url__ = "https://github.com/HKUDS/LightRAG"
|
__url__ = "https://github.com/HKUDS/LightRAG"
|
||||||
|
|
|
||||||
|
|
@ -184,24 +184,16 @@ MAX_ASYNC=4
|
||||||
|
|
||||||
### 将 Lightrag 安装为 Linux 服务
|
### 将 Lightrag 安装为 Linux 服务
|
||||||
|
|
||||||
从示例文件 `lightrag.service.example` 创建您的服务文件 `lightrag.service`。修改服务文件中的 WorkingDirectory 和 ExecStart:
|
从示例文件 `lightrag.service.example` 创建您的服务文件 `lightrag.service`。修改服务文件中的服务启动定义:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Description=LightRAG Ollama Service
|
# Set Enviroment to your Python virtual enviroment
|
||||||
WorkingDirectory=<lightrag 安装目录>
|
Environment="PATH=/home/netman/lightrag-xyj/venv/bin"
|
||||||
ExecStart=<lightrag 安装目录>/lightrag/api/lightrag-api
|
WorkingDirectory=/home/netman/lightrag-xyj
|
||||||
```
|
# ExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-server
|
||||||
|
ExecStart=/home/netman/lightrag-xyj/venv/bin/lightrag-gunicorn
|
||||||
修改您的服务启动脚本:`lightrag-api`。根据需要更改 python 虚拟环境激活命令:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# 您的 python 虚拟环境激活命令
|
|
||||||
source /home/netman/lightrag-xyj/venv/bin/activate
|
|
||||||
# 启动 lightrag api 服务器
|
|
||||||
lightrag-server
|
|
||||||
```
|
```
|
||||||
|
> ExecStart命令必须是 lightrag-gunicorn 或 lightrag-server 中的一个,不能使用其它脚本包裹它们。因为停止服务必须要求主进程必须是这两个进程。
|
||||||
|
|
||||||
安装 LightRAG 服务。如果您的系统是 Ubuntu,以下命令将生效:
|
安装 LightRAG 服务。如果您的系统是 Ubuntu,以下命令将生效:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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 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 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`
|
- **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
|
### 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
|
```text
|
||||||
Description=LightRAG Ollama Service
|
# Set Enviroment to your Python virtual enviroment
|
||||||
WorkingDirectory=<lightrag installed directory>
|
Environment="PATH=/home/netman/lightrag-xyj/venv/bin"
|
||||||
ExecStart=<lightrag installed directory>/lightrag/api/lightrag-api
|
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:
|
> 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.
|
||||||
|
|
||||||
```shell
|
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# your python virtual environment activation
|
|
||||||
source /home/netman/lightrag-xyj/venv/bin/activate
|
|
||||||
# start lightrag api server
|
|
||||||
lightrag-server
|
|
||||||
```
|
|
||||||
|
|
||||||
Install LightRAG service. If your system is Ubuntu, the following commands will work:
|
Install LightRAG service. If your system is Ubuntu, the following commands will work:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__api_version__ = "0245"
|
__api_version__ = "0250"
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,9 @@ def parse_args() -> argparse.Namespace:
|
||||||
# Select Document loading tool (DOCLING, DEFAULT)
|
# Select Document loading tool (DOCLING, DEFAULT)
|
||||||
args.document_loading_engine = get_env_value("DOCUMENT_LOADING_ENGINE", "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
|
# Add environment variables that were previously read directly
|
||||||
args.cors_origins = get_env_value("CORS_ORIGINS", "*")
|
args.cors_origins = get_env_value("CORS_ORIGINS", "*")
|
||||||
args.summary_language = get_env_value("SUMMARY_LANGUAGE", DEFAULT_SUMMARY_LANGUAGE)
|
args.summary_language = get_env_value("SUMMARY_LANGUAGE", DEFAULT_SUMMARY_LANGUAGE)
|
||||||
|
|
|
||||||
|
|
@ -129,12 +129,10 @@ def on_exit(server):
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
print("GUNICORN MASTER PROCESS: Shutting down")
|
print("GUNICORN MASTER PROCESS: Shutting down")
|
||||||
print(f"Process ID: {os.getpid()}")
|
print(f"Process ID: {os.getpid()}")
|
||||||
print("=" * 80)
|
|
||||||
|
|
||||||
# Release shared resources
|
print("Finalizing shared storage...")
|
||||||
finalize_share_data()
|
finalize_share_data()
|
||||||
|
|
||||||
print("=" * 80)
|
|
||||||
print("Gunicorn shutdown complete")
|
print("Gunicorn shutdown complete")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ LightRAG FastAPI Server
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Request
|
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.openapi.docs import (
|
||||||
|
get_swagger_ui_html,
|
||||||
|
get_swagger_ui_oauth2_redirect_html,
|
||||||
|
)
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
import signal
|
|
||||||
import sys
|
import sys
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import pipmaster as pm
|
import pipmaster as pm
|
||||||
|
|
@ -78,24 +81,6 @@ config.read("config.ini")
|
||||||
auth_configured = bool(auth_handler.accounts)
|
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:
|
class LLMConfigCache:
|
||||||
"""Smart LLM and Embedding configuration cache class"""
|
"""Smart LLM and Embedding configuration cache class"""
|
||||||
|
|
||||||
|
|
@ -146,7 +131,11 @@ class LLMConfigCache:
|
||||||
|
|
||||||
|
|
||||||
def check_frontend_build():
|
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"
|
webui_dir = Path(__file__).parent / "webui"
|
||||||
index_html = webui_dir / "index.html"
|
index_html = webui_dir / "index.html"
|
||||||
|
|
||||||
|
|
@ -181,7 +170,7 @@ def check_frontend_build():
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Production environment detected, skipping source freshness check"
|
"Production environment detected, skipping source freshness check"
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
|
|
||||||
# Development environment, perform source code timestamp check
|
# Development environment, perform source code timestamp check
|
||||||
logger.debug("Development environment detected, checking source freshness")
|
logger.debug("Development environment detected, checking source freshness")
|
||||||
|
|
@ -212,7 +201,7 @@ def check_frontend_build():
|
||||||
source_dir / "bun.lock",
|
source_dir / "bun.lock",
|
||||||
source_dir / "vite.config.ts",
|
source_dir / "vite.config.ts",
|
||||||
source_dir / "tsconfig.json",
|
source_dir / "tsconfig.json",
|
||||||
source_dir / "tailwind.config.js",
|
source_dir / "tailraid.config.js",
|
||||||
source_dir / "index.html",
|
source_dir / "index.html",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -256,17 +245,25 @@ def check_frontend_build():
|
||||||
ASCIIColors.cyan(" cd ..")
|
ASCIIColors.cyan(" cd ..")
|
||||||
ASCIIColors.yellow("\nThe server will continue with the current build.")
|
ASCIIColors.yellow("\nThe server will continue with the current build.")
|
||||||
ASCIIColors.yellow("=" * 80 + "\n")
|
ASCIIColors.yellow("=" * 80 + "\n")
|
||||||
|
return True # Frontend is outdated
|
||||||
else:
|
else:
|
||||||
logger.info("Frontend build is up-to-date")
|
logger.info("Frontend build is up-to-date")
|
||||||
|
return False # Frontend is up-to-date
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If check fails, log warning but don't affect startup
|
# If check fails, log warning but don't affect startup
|
||||||
logger.warning(f"Failed to check frontend source freshness: {e}")
|
logger.warning(f"Failed to check frontend source freshness: {e}")
|
||||||
|
return False # Assume up-to-date on error
|
||||||
|
|
||||||
|
|
||||||
def create_app(args):
|
def create_app(args):
|
||||||
# Check frontend build first
|
# Check frontend build first and get outdated status
|
||||||
check_frontend_build()
|
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
|
# Setup logging
|
||||||
logger.setLevel(args.log_level)
|
logger.setLevel(args.log_level)
|
||||||
|
|
@ -341,8 +338,15 @@ def create_app(args):
|
||||||
# Clean up database connections
|
# Clean up database connections
|
||||||
await rag.finalize_storages()
|
await rag.finalize_storages()
|
||||||
|
|
||||||
# Clean up shared data
|
if "LIGHTRAG_GUNICORN_MODE" not in os.environ:
|
||||||
finalize_share_data()
|
# 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
|
# Initialize FastAPI
|
||||||
base_description = (
|
base_description = (
|
||||||
|
|
@ -358,7 +362,7 @@ def create_app(args):
|
||||||
"description": swagger_description,
|
"description": swagger_description,
|
||||||
"version": __api_version__,
|
"version": __api_version__,
|
||||||
"openapi_url": "/openapi.json", # Explicitly set OpenAPI schema URL
|
"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
|
"redoc_url": "/redoc", # Explicitly set redoc URL
|
||||||
"lifespan": lifespan,
|
"lifespan": lifespan,
|
||||||
}
|
}
|
||||||
|
|
@ -769,6 +773,25 @@ def create_app(args):
|
||||||
ollama_api = OllamaAPI(rag, top_k=args.top_k, api_key=api_key)
|
ollama_api = OllamaAPI(rag, top_k=args.top_k, api_key=api_key)
|
||||||
app.include_router(ollama_api.router, prefix="/api")
|
app.include_router(ollama_api.router, prefix="/api")
|
||||||
|
|
||||||
|
# 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("/")
|
@app.get("/")
|
||||||
async def redirect_to_webui():
|
async def redirect_to_webui():
|
||||||
"""Redirect root path to /webui"""
|
"""Redirect root path to /webui"""
|
||||||
|
|
@ -790,7 +813,7 @@ def create_app(args):
|
||||||
"auth_mode": "disabled",
|
"auth_mode": "disabled",
|
||||||
"message": "Authentication is disabled. Using guest access.",
|
"message": "Authentication is disabled. Using guest access.",
|
||||||
"core_version": core_version,
|
"core_version": core_version,
|
||||||
"api_version": __api_version__,
|
"api_version": api_version_display,
|
||||||
"webui_title": webui_title,
|
"webui_title": webui_title,
|
||||||
"webui_description": webui_description,
|
"webui_description": webui_description,
|
||||||
}
|
}
|
||||||
|
|
@ -799,7 +822,7 @@ def create_app(args):
|
||||||
"auth_configured": True,
|
"auth_configured": True,
|
||||||
"auth_mode": "enabled",
|
"auth_mode": "enabled",
|
||||||
"core_version": core_version,
|
"core_version": core_version,
|
||||||
"api_version": __api_version__,
|
"api_version": api_version_display,
|
||||||
"webui_title": webui_title,
|
"webui_title": webui_title,
|
||||||
"webui_description": webui_description,
|
"webui_description": webui_description,
|
||||||
}
|
}
|
||||||
|
|
@ -817,7 +840,7 @@ def create_app(args):
|
||||||
"auth_mode": "disabled",
|
"auth_mode": "disabled",
|
||||||
"message": "Authentication is disabled. Using guest access.",
|
"message": "Authentication is disabled. Using guest access.",
|
||||||
"core_version": core_version,
|
"core_version": core_version,
|
||||||
"api_version": __api_version__,
|
"api_version": api_version_display,
|
||||||
"webui_title": webui_title,
|
"webui_title": webui_title,
|
||||||
"webui_description": webui_description,
|
"webui_description": webui_description,
|
||||||
}
|
}
|
||||||
|
|
@ -834,7 +857,7 @@ def create_app(args):
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"auth_mode": "enabled",
|
"auth_mode": "enabled",
|
||||||
"core_version": core_version,
|
"core_version": core_version,
|
||||||
"api_version": __api_version__,
|
"api_version": api_version_display,
|
||||||
"webui_title": webui_title,
|
"webui_title": webui_title,
|
||||||
"webui_description": webui_description,
|
"webui_description": webui_description,
|
||||||
}
|
}
|
||||||
|
|
@ -898,7 +921,7 @@ def create_app(args):
|
||||||
"pipeline_busy": pipeline_status.get("busy", False),
|
"pipeline_busy": pipeline_status.get("busy", False),
|
||||||
"keyed_locks": keyed_lock_info,
|
"keyed_locks": keyed_lock_info,
|
||||||
"core_version": core_version,
|
"core_version": core_version,
|
||||||
"api_version": __api_version__,
|
"api_version": api_version_display,
|
||||||
"webui_title": webui_title,
|
"webui_title": webui_title,
|
||||||
"webui_description": webui_description,
|
"webui_description": webui_description,
|
||||||
}
|
}
|
||||||
|
|
@ -935,6 +958,15 @@ def create_app(args):
|
||||||
|
|
||||||
return response
|
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
|
# Webui mount webui/index.html
|
||||||
static_dir = Path(__file__).parent / "webui"
|
static_dir = Path(__file__).parent / "webui"
|
||||||
static_dir.mkdir(exist_ok=True)
|
static_dir.mkdir(exist_ok=True)
|
||||||
|
|
@ -1076,8 +1108,10 @@ def main():
|
||||||
update_uvicorn_mode_config()
|
update_uvicorn_mode_config()
|
||||||
display_splash_screen(global_args)
|
display_splash_screen(global_args)
|
||||||
|
|
||||||
# Setup signal handlers for graceful shutdown
|
# Note: Signal handlers are NOT registered here because:
|
||||||
setup_signal_handlers()
|
# - 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
|
# Create application instance directly instead of using factory function
|
||||||
app = create_app(global_args)
|
app = create_app(global_args)
|
||||||
|
|
|
||||||
|
|
@ -1083,11 +1083,74 @@ async def pipeline_enqueue_file(
|
||||||
else:
|
else:
|
||||||
if not pm.is_installed("pypdf2"): # type: ignore
|
if not pm.is_installed("pypdf2"): # type: ignore
|
||||||
pm.install("pypdf2")
|
pm.install("pypdf2")
|
||||||
|
if not pm.is_installed("pycryptodome"): # type: ignore
|
||||||
|
pm.install("pycryptodome")
|
||||||
from PyPDF2 import PdfReader # type: ignore
|
from PyPDF2 import PdfReader # type: ignore
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
pdf_file = BytesIO(file)
|
pdf_file = BytesIO(file)
|
||||||
reader = PdfReader(pdf_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:
|
for page in reader.pages:
|
||||||
content += page.extract_text() + "\n"
|
content += page.extract_text() + "\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class EntityUpdateRequest(BaseModel):
|
||||||
entity_name: str
|
entity_name: str
|
||||||
updated_data: Dict[str, Any]
|
updated_data: Dict[str, Any]
|
||||||
allow_rename: bool = False
|
allow_rename: bool = False
|
||||||
|
allow_merge: bool = False
|
||||||
|
|
||||||
|
|
||||||
class RelationUpdateRequest(BaseModel):
|
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
|
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:
|
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:
|
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:
|
try:
|
||||||
result = await rag.aedit_entity(
|
result = await rag.aedit_entity(
|
||||||
entity_name=request.entity_name,
|
entity_name=request.entity_name,
|
||||||
updated_data=request.updated_data,
|
updated_data=request.updated_data,
|
||||||
allow_rename=request.allow_rename,
|
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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Entity updated successfully",
|
"message": response_message,
|
||||||
"data": result,
|
"data": entity_data,
|
||||||
|
"operation_summary": operation_summary,
|
||||||
}
|
}
|
||||||
except ValueError as ve:
|
except ValueError as ve:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,11 @@ Start LightRAG server with Gunicorn
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import signal
|
|
||||||
import pipmaster as pm
|
import pipmaster as pm
|
||||||
from lightrag.api.utils_api import display_splash_screen, check_env_file
|
from lightrag.api.utils_api import display_splash_screen, check_env_file
|
||||||
from lightrag.api.config import global_args
|
from lightrag.api.config import global_args
|
||||||
from lightrag.utils import get_env_value
|
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 (
|
from lightrag.constants import (
|
||||||
DEFAULT_WOKERS,
|
DEFAULT_WOKERS,
|
||||||
|
|
@ -34,21 +33,10 @@ def check_and_install_dependencies():
|
||||||
print(f"{package} installed successfully")
|
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():
|
def main():
|
||||||
|
# Set Gunicorn mode flag for lifespan cleanup detection
|
||||||
|
os.environ["LIGHTRAG_GUNICORN_MODE"] = "1"
|
||||||
|
|
||||||
# Check .env file
|
# Check .env file
|
||||||
if not check_env_file():
|
if not check_env_file():
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
@ -56,9 +44,8 @@ def main():
|
||||||
# Check and install dependencies
|
# Check and install dependencies
|
||||||
check_and_install_dependencies()
|
check_and_install_dependencies()
|
||||||
|
|
||||||
# Register signal handlers for graceful shutdown
|
# Note: Signal handlers are NOT registered here because:
|
||||||
signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
|
# - Master cleanup already handled by gunicorn_config.on_exit()
|
||||||
signal.signal(signal.SIGTERM, signal_handler) # kill command
|
|
||||||
|
|
||||||
# Display startup information
|
# Display startup information
|
||||||
display_splash_screen(global_args)
|
display_splash_screen(global_args)
|
||||||
|
|
|
||||||
BIN
lightrag/api/static/swagger-ui/favicon-32x32.png
Normal file
BIN
lightrag/api/static/swagger-ui/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 628 B |
2
lightrag/api/static/swagger-ui/swagger-ui-bundle.js
Normal file
2
lightrag/api/static/swagger-ui/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
lightrag/api/static/swagger-ui/swagger-ui.css
Normal file
3
lightrag/api/static/swagger-ui/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -38,7 +38,7 @@ DEFAULT_ENTITY_TYPES = [
|
||||||
"NaturalObject",
|
"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>"
|
GRAPH_FIELD_SEP = "<SEP>"
|
||||||
|
|
||||||
# Query and retrieval configuration defaults
|
# Query and retrieval configuration defaults
|
||||||
|
|
|
||||||
|
|
@ -104,3 +104,11 @@ class PipelineCancelledException(Exception):
|
||||||
def __init__(self, message: str = "User cancelled"):
|
def __init__(self, message: str = "User cancelled"):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.message = 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
|
||||||
|
|
|
||||||
|
|
@ -983,7 +983,7 @@ class MilvusVectorDBStorage(BaseVectorStorage):
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
"""Initialize Milvus collection"""
|
"""Initialize Milvus collection"""
|
||||||
async with get_data_init_lock(enable_logging=True):
|
async with get_data_init_lock():
|
||||||
if self._initialized:
|
if self._initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,9 +184,17 @@ class NanoVectorDBStorage(BaseVectorStorage):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
|
# Record count before deletion
|
||||||
|
before_count = len(client)
|
||||||
|
|
||||||
client.delete(ids)
|
client.delete(ids)
|
||||||
|
|
||||||
|
# Calculate actual deleted count
|
||||||
|
after_count = len(client)
|
||||||
|
deleted_count = before_count - after_count
|
||||||
|
|
||||||
logger.debug(
|
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:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,30 @@
|
||||||
import asyncio
|
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 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
|
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"):
|
if not pm.is_installed("qdrant-client"):
|
||||||
pm.install("qdrant-client")
|
pm.install("qdrant-client")
|
||||||
|
|
||||||
from qdrant_client import QdrantClient, models # type: ignore
|
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 = configparser.ConfigParser()
|
||||||
config.read("config.ini", "utf-8")
|
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'.")
|
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
|
@final
|
||||||
@dataclass
|
@dataclass
|
||||||
class QdrantVectorDBStorage(BaseVectorStorage):
|
class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
|
|
@ -64,24 +82,192 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
self.__post_init__()
|
self.__post_init__()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_collection_if_not_exist(
|
def setup_collection(
|
||||||
client: QdrantClient, collection_name: str, **kwargs
|
client: QdrantClient,
|
||||||
|
collection_name: str,
|
||||||
|
legacy_namespace: str = None,
|
||||||
|
workspace: str = None,
|
||||||
|
**kwargs,
|
||||||
):
|
):
|
||||||
exists = False
|
"""
|
||||||
if hasattr(client, "collection_exists"):
|
Setup Qdrant collection with migration support from legacy collections.
|
||||||
try:
|
|
||||||
exists = client.collection_exists(collection_name)
|
|
||||||
except Exception:
|
|
||||||
exists = False
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
client.get_collection(collection_name)
|
|
||||||
exists = True
|
|
||||||
except Exception:
|
|
||||||
exists = False
|
|
||||||
|
|
||||||
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_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):
|
def __post_init__(self):
|
||||||
# Check for QDRANT_WORKSPACE environment variable first (higher priority)
|
# Check for QDRANT_WORKSPACE environment variable first (higher priority)
|
||||||
|
|
@ -101,18 +287,20 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
f"Using passed workspace parameter: '{effective_workspace}'"
|
f"Using passed workspace parameter: '{effective_workspace}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build final_namespace with workspace prefix for data isolation
|
# Get legacy namespace for data migration from old version
|
||||||
# Keep original namespace unchanged for type detection logic
|
|
||||||
if effective_workspace:
|
if effective_workspace:
|
||||||
self.final_namespace = f"{effective_workspace}_{self.namespace}"
|
self.legacy_namespace = f"{effective_workspace}_{self.namespace}"
|
||||||
logger.debug(
|
|
||||||
f"Final namespace with workspace prefix: '{self.final_namespace}'"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# When workspace is empty, final_namespace equals original namespace
|
self.legacy_namespace = self.namespace
|
||||||
self.final_namespace = self.namespace
|
|
||||||
self.workspace = "_"
|
self.effective_workspace = effective_workspace or DEFAULT_WORKSPACE
|
||||||
logger.debug(f"Final namespace (no workspace): '{self.final_namespace}'")
|
|
||||||
|
# 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", {})
|
kwargs = self.global_config.get("vector_db_storage_cls_kwargs", {})
|
||||||
cosine_threshold = kwargs.get("cosine_better_than_threshold")
|
cosine_threshold = kwargs.get("cosine_better_than_threshold")
|
||||||
|
|
@ -149,15 +337,23 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
f"[{self.workspace}] QdrantClient created successfully"
|
f"[{self.workspace}] QdrantClient created successfully"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create collection if not exists
|
# Setup collection (create if not exists and configure indexes)
|
||||||
QdrantVectorDBStorage.create_collection_if_not_exist(
|
# Pass legacy_namespace and workspace for migration support
|
||||||
|
QdrantVectorDBStorage.setup_collection(
|
||||||
self._client,
|
self._client,
|
||||||
self.final_namespace,
|
self.final_namespace,
|
||||||
|
legacy_namespace=self.legacy_namespace,
|
||||||
|
workspace=self.effective_workspace,
|
||||||
vectors_config=models.VectorParams(
|
vectors_config=models.VectorParams(
|
||||||
size=self.embedding_func.embedding_dim,
|
size=self.embedding_func.embedding_dim,
|
||||||
distance=models.Distance.COSINE,
|
distance=models.Distance.COSINE,
|
||||||
),
|
),
|
||||||
|
hnsw_config=models.HnswConfigDiff(
|
||||||
|
payload_m=16,
|
||||||
|
m=0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{self.workspace}] Qdrant collection '{self.namespace}' initialized successfully"
|
f"[{self.workspace}] Qdrant collection '{self.namespace}' initialized successfully"
|
||||||
|
|
@ -179,8 +375,9 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
|
|
||||||
list_data = [
|
list_data = [
|
||||||
{
|
{
|
||||||
"id": k,
|
ID_FIELD: k,
|
||||||
"created_at": current_time,
|
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},
|
**{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields},
|
||||||
}
|
}
|
||||||
for k, v in data.items()
|
for k, v in data.items()
|
||||||
|
|
@ -200,7 +397,9 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
for i, d in enumerate(list_data):
|
for i, d in enumerate(list_data):
|
||||||
list_points.append(
|
list_points.append(
|
||||||
models.PointStruct(
|
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],
|
vector=embeddings[i],
|
||||||
payload=d,
|
payload=d,
|
||||||
)
|
)
|
||||||
|
|
@ -222,21 +421,22 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
) # higher priority for query
|
) # higher priority for query
|
||||||
embedding = embedding_result[0]
|
embedding = embedding_result[0]
|
||||||
|
|
||||||
results = self._client.search(
|
results = self._client.query_points(
|
||||||
collection_name=self.final_namespace,
|
collection_name=self.final_namespace,
|
||||||
query_vector=embedding,
|
query=embedding,
|
||||||
limit=top_k,
|
limit=top_k,
|
||||||
with_payload=True,
|
with_payload=True,
|
||||||
score_threshold=self.cosine_better_than_threshold,
|
score_threshold=self.cosine_better_than_threshold,
|
||||||
)
|
query_filter=models.Filter(
|
||||||
|
must=[workspace_filter_condition(self.effective_workspace)]
|
||||||
# logger.debug(f"[{self.workspace}] query result: {results}")
|
),
|
||||||
|
).points
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
**dp.payload,
|
**dp.payload,
|
||||||
"distance": dp.score,
|
"distance": dp.score,
|
||||||
"created_at": dp.payload.get("created_at"),
|
CREATED_AT_FIELD: dp.payload.get(CREATED_AT_FIELD),
|
||||||
}
|
}
|
||||||
for dp in results
|
for dp in results
|
||||||
]
|
]
|
||||||
|
|
@ -252,14 +452,18 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
ids: List of vector IDs to be deleted
|
ids: List of vector IDs to be deleted
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
|
||||||
# Convert regular ids to Qdrant compatible ids
|
# Convert regular ids to Qdrant compatible ids
|
||||||
qdrant_ids = [compute_mdhash_id_for_qdrant(id) for id in ids]
|
qdrant_ids = [
|
||||||
# Delete points from the collection
|
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(
|
self._client.delete(
|
||||||
collection_name=self.final_namespace,
|
collection_name=self.final_namespace,
|
||||||
points_selector=models.PointIdsList(
|
points_selector=models.PointIdsList(points=qdrant_ids),
|
||||||
points=qdrant_ids,
|
|
||||||
),
|
|
||||||
wait=True,
|
wait=True,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -277,18 +481,16 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
entity_name: Name of the entity to delete
|
entity_name: Name of the entity to delete
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Generate the entity ID
|
# Generate the entity ID using the same function as used for storage
|
||||||
entity_id = compute_mdhash_id_for_qdrant(entity_name, prefix="ent-")
|
entity_id = compute_mdhash_id(entity_name, prefix=ENTITY_PREFIX)
|
||||||
# logger.debug(
|
qdrant_entity_id = compute_mdhash_id_for_qdrant(
|
||||||
# f"[{self.workspace}] Attempting to delete entity {entity_name} with ID {entity_id}"
|
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(
|
self._client.delete(
|
||||||
collection_name=self.final_namespace,
|
collection_name=self.final_namespace,
|
||||||
points_selector=models.PointIdsList(
|
points_selector=models.PointIdsList(points=[qdrant_entity_id]),
|
||||||
points=[entity_id],
|
|
||||||
),
|
|
||||||
wait=True,
|
wait=True,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -304,10 +506,11 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
entity_name: Name of the entity whose relations should be deleted
|
entity_name: Name of the entity whose relations should be deleted
|
||||||
"""
|
"""
|
||||||
try:
|
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(
|
results = self._client.scroll(
|
||||||
collection_name=self.final_namespace,
|
collection_name=self.final_namespace,
|
||||||
scroll_filter=models.Filter(
|
scroll_filter=models.Filter(
|
||||||
|
must=[workspace_filter_condition(self.effective_workspace)],
|
||||||
should=[
|
should=[
|
||||||
models.FieldCondition(
|
models.FieldCondition(
|
||||||
key="src_id", match=models.MatchValue(value=entity_name)
|
key="src_id", match=models.MatchValue(value=entity_name)
|
||||||
|
|
@ -315,7 +518,7 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
models.FieldCondition(
|
models.FieldCondition(
|
||||||
key="tgt_id", match=models.MatchValue(value=entity_name)
|
key="tgt_id", match=models.MatchValue(value=entity_name)
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
),
|
),
|
||||||
with_payload=True,
|
with_payload=True,
|
||||||
limit=1000, # Adjust as needed for your use case
|
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]
|
ids_to_delete = [point.id for point in relation_points]
|
||||||
|
|
||||||
if ids_to_delete:
|
if ids_to_delete:
|
||||||
# Delete the relations
|
# Delete the relations with workspace filtering
|
||||||
|
assert isinstance(self._client, QdrantClient)
|
||||||
self._client.delete(
|
self._client.delete(
|
||||||
collection_name=self.final_namespace,
|
collection_name=self.final_namespace,
|
||||||
points_selector=models.PointIdsList(
|
points_selector=models.PointIdsList(points=ids_to_delete),
|
||||||
points=ids_to_delete,
|
|
||||||
),
|
|
||||||
wait=True,
|
wait=True,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -357,9 +559,11 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Convert to Qdrant compatible ID
|
# 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(
|
result = self._client.retrieve(
|
||||||
collection_name=self.final_namespace,
|
collection_name=self.final_namespace,
|
||||||
ids=[qdrant_id],
|
ids=[qdrant_id],
|
||||||
|
|
@ -369,10 +573,9 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
if not result:
|
if not result:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Ensure the result contains created_at field
|
|
||||||
payload = result[0].payload
|
payload = result[0].payload
|
||||||
if "created_at" not in payload:
|
if CREATED_AT_FIELD not in payload:
|
||||||
payload["created_at"] = None
|
payload[CREATED_AT_FIELD] = None
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -395,7 +598,10 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Convert to Qdrant compatible IDs
|
# 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
|
# Retrieve the points by IDs
|
||||||
results = self._client.retrieve(
|
results = self._client.retrieve(
|
||||||
|
|
@ -410,14 +616,14 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
|
|
||||||
for point in results:
|
for point in results:
|
||||||
payload = dict(point.payload or {})
|
payload = dict(point.payload or {})
|
||||||
if "created_at" not in payload:
|
if CREATED_AT_FIELD not in payload:
|
||||||
payload["created_at"] = None
|
payload[CREATED_AT_FIELD] = None
|
||||||
|
|
||||||
qdrant_point_id = str(point.id) if point.id is not None else ""
|
qdrant_point_id = str(point.id) if point.id is not None else ""
|
||||||
if qdrant_point_id:
|
if qdrant_point_id:
|
||||||
payload_by_qdrant_id[qdrant_point_id] = payload
|
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:
|
if original_id is not None:
|
||||||
payload_by_original_id[str(original_id)] = payload
|
payload_by_original_id[str(original_id)] = payload
|
||||||
|
|
||||||
|
|
@ -450,7 +656,10 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Convert to Qdrant compatible IDs
|
# 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
|
# Retrieve the points by IDs with vectors
|
||||||
results = self._client.retrieve(
|
results = self._client.retrieve(
|
||||||
|
|
@ -464,7 +673,7 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
for point in results:
|
for point in results:
|
||||||
if point and point.vector is not None and point.payload:
|
if point and point.vector is not None and point.payload:
|
||||||
# Get original ID from payload
|
# Get original ID from payload
|
||||||
original_id = point.payload.get("id")
|
original_id = point.payload.get(ID_FIELD)
|
||||||
if original_id:
|
if original_id:
|
||||||
# Convert numpy array to list if needed
|
# Convert numpy array to list if needed
|
||||||
vector_data = point.vector
|
vector_data = point.vector
|
||||||
|
|
@ -482,7 +691,7 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
async def drop(self) -> dict[str, str]:
|
async def drop(self) -> dict[str, str]:
|
||||||
"""Drop all vector data from storage and clean up resources
|
"""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:
|
Returns:
|
||||||
dict[str, str]: Operation status and message
|
dict[str, str]: Operation status and message
|
||||||
|
|
@ -491,39 +700,23 @@ class QdrantVectorDBStorage(BaseVectorStorage):
|
||||||
"""
|
"""
|
||||||
async with get_storage_lock():
|
async with get_storage_lock():
|
||||||
try:
|
try:
|
||||||
# Delete the collection and recreate it
|
# Delete all points for the current workspace
|
||||||
exists = False
|
self._client.delete(
|
||||||
if hasattr(self._client, "collection_exists"):
|
collection_name=self.final_namespace,
|
||||||
try:
|
points_selector=models.FilterSelector(
|
||||||
exists = self._client.collection_exists(self.final_namespace)
|
filter=models.Filter(
|
||||||
except Exception:
|
must=[workspace_filter_condition(self.effective_workspace)]
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
wait=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
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"}
|
return {"status": "success", "message": "data dropped"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
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)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ from typing import Any, Dict, List, Optional, Union, TypeVar, Generic
|
||||||
|
|
||||||
from lightrag.exceptions import PipelineNotInitializedError
|
from lightrag.exceptions import PipelineNotInitializedError
|
||||||
|
|
||||||
|
DEBUG_LOCKS = False
|
||||||
|
|
||||||
|
|
||||||
# Define a direct print function for critical logs that must be visible in all processes
|
# 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"):
|
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 for coroutine synchronization in multiprocess mode
|
||||||
_async_locks: Optional[Dict[str, asyncio.Lock]] = None
|
_async_locks: Optional[Dict[str, asyncio.Lock]] = None
|
||||||
|
|
||||||
DEBUG_LOCKS = False
|
|
||||||
_debug_n_locks_acquired: int = 0
|
_debug_n_locks_acquired: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1871,7 +1871,7 @@ class LightRAG:
|
||||||
chunks, pipeline_status, pipeline_status_lock
|
chunks, pipeline_status, pipeline_status_lock
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await entity_relation_task
|
chunk_results = await entity_relation_task
|
||||||
file_extraction_stage_ok = True
|
file_extraction_stage_ok = True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1961,6 +1961,7 @@ class LightRAG:
|
||||||
if self.enable_deduplication:
|
if self.enable_deduplication:
|
||||||
dedup_service = LightRAGDeduplicationService(self)
|
dedup_service = LightRAGDeduplicationService(self)
|
||||||
|
|
||||||
|
# Use chunk_results from entity_relation_task
|
||||||
await merge_nodes_and_edges(
|
await merge_nodes_and_edges(
|
||||||
chunk_results=chunk_results, # result collected from entity_relation_task
|
chunk_results=chunk_results, # result collected from entity_relation_task
|
||||||
knowledge_graph_inst=self.chunk_entity_relation_graph,
|
knowledge_graph_inst=self.chunk_entity_relation_graph,
|
||||||
|
|
@ -3190,6 +3191,9 @@ class LightRAG:
|
||||||
]
|
]
|
||||||
|
|
||||||
if not existing_sources:
|
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
|
continue
|
||||||
|
|
||||||
remaining_sources = subtract_source_ids(existing_sources, chunk_ids)
|
remaining_sources = subtract_source_ids(existing_sources, chunk_ids)
|
||||||
|
|
@ -3211,6 +3215,7 @@ class LightRAG:
|
||||||
|
|
||||||
# Process relationships
|
# Process relationships
|
||||||
for edge_data in affected_edges:
|
for edge_data in affected_edges:
|
||||||
|
# source target is not in normalize order in graph db property
|
||||||
src = edge_data.get("source")
|
src = edge_data.get("source")
|
||||||
tgt = edge_data.get("target")
|
tgt = edge_data.get("target")
|
||||||
|
|
||||||
|
|
@ -3247,6 +3252,9 @@ class LightRAG:
|
||||||
]
|
]
|
||||||
|
|
||||||
if not existing_sources:
|
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
|
continue
|
||||||
|
|
||||||
remaining_sources = subtract_source_ids(existing_sources, chunk_ids)
|
remaining_sources = subtract_source_ids(existing_sources, chunk_ids)
|
||||||
|
|
@ -3330,36 +3338,7 @@ class LightRAG:
|
||||||
logger.error(f"Failed to delete chunks: {e}")
|
logger.error(f"Failed to delete chunks: {e}")
|
||||||
raise Exception(f"Failed to delete document chunks: {e}") from e
|
raise Exception(f"Failed to delete document chunks: {e}") from e
|
||||||
|
|
||||||
# 6. Delete entities that have no remaining sources
|
# 6. Delete relationships 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
|
|
||||||
if relationships_to_delete:
|
if relationships_to_delete:
|
||||||
try:
|
try:
|
||||||
# Delete from vector database
|
# Delete from vector database
|
||||||
|
|
@ -3396,6 +3375,96 @@ class LightRAG:
|
||||||
logger.error(f"Failed to delete relationships: {e}")
|
logger.error(f"Failed to delete relationships: {e}")
|
||||||
raise Exception(f"Failed to delete relationships: {e}") from 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
|
# Persist changes to graph database before releasing graph database lock
|
||||||
await self._insert_done()
|
await self._insert_done()
|
||||||
|
|
||||||
|
|
@ -3620,7 +3689,11 @@ class LightRAG:
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aedit_entity(
|
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]:
|
) -> dict[str, Any]:
|
||||||
"""Asynchronously edit entity information.
|
"""Asynchronously edit entity information.
|
||||||
|
|
||||||
|
|
@ -3631,6 +3704,7 @@ class LightRAG:
|
||||||
entity_name: Name of the entity to edit
|
entity_name: Name of the entity to edit
|
||||||
updated_data: Dictionary containing updated attributes, e.g. {"description": "new description", "entity_type": "new type"}
|
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_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:
|
Returns:
|
||||||
Dictionary containing updated entity information
|
Dictionary containing updated entity information
|
||||||
|
|
@ -3644,16 +3718,21 @@ class LightRAG:
|
||||||
entity_name,
|
entity_name,
|
||||||
updated_data,
|
updated_data,
|
||||||
allow_rename,
|
allow_rename,
|
||||||
|
allow_merge,
|
||||||
self.entity_chunks,
|
self.entity_chunks,
|
||||||
self.relation_chunks,
|
self.relation_chunks,
|
||||||
)
|
)
|
||||||
|
|
||||||
def edit_entity(
|
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]:
|
) -> dict[str, Any]:
|
||||||
loop = always_get_an_event_loop()
|
loop = always_get_an_event_loop()
|
||||||
return loop.run_until_complete(
|
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(
|
async def aedit_relation(
|
||||||
|
|
@ -3793,6 +3872,8 @@ class LightRAG:
|
||||||
target_entity,
|
target_entity,
|
||||||
merge_strategy,
|
merge_strategy,
|
||||||
target_entity_data,
|
target_entity_data,
|
||||||
|
self.entity_chunks,
|
||||||
|
self.relation_chunks,
|
||||||
)
|
)
|
||||||
|
|
||||||
def merge_entities(
|
def merge_entities(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ if not pm.is_installed("openai"):
|
||||||
pm.install("openai")
|
pm.install("openai")
|
||||||
|
|
||||||
from openai import (
|
from openai import (
|
||||||
AsyncOpenAI,
|
|
||||||
APIConnectionError,
|
APIConnectionError,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
APITimeoutError,
|
APITimeoutError,
|
||||||
|
|
@ -27,6 +26,7 @@ from lightrag.utils import (
|
||||||
safe_unicode_decode,
|
safe_unicode_decode,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
from lightrag.types import GPTKeywordExtractionFormat
|
from lightrag.types import GPTKeywordExtractionFormat
|
||||||
from lightrag.api import __api_version__
|
from lightrag.api import __api_version__
|
||||||
|
|
||||||
|
|
@ -36,6 +36,32 @@ from typing import Any, Union
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
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
|
# use the .env that is inside the current folder
|
||||||
# allows to use different .env file for each lightrag instance
|
# allows to use different .env file for each lightrag instance
|
||||||
# the OS environment variables take precedence over the .env file
|
# 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
|
# Ensure resources are released even if no exception occurs
|
||||||
if (
|
# Note: Some wrapped clients (e.g., Langfuse) may not implement aclose() properly
|
||||||
iteration_started
|
if iteration_started and hasattr(response, "aclose"):
|
||||||
and hasattr(response, "aclose")
|
aclose_method = getattr(response, "aclose", None)
|
||||||
and callable(getattr(response, "aclose", None))
|
if callable(aclose_method):
|
||||||
):
|
try:
|
||||||
try:
|
await response.aclose()
|
||||||
await response.aclose()
|
logger.debug("Successfully closed stream response")
|
||||||
logger.debug("Successfully closed stream response")
|
except (AttributeError, TypeError) as close_error:
|
||||||
except Exception as close_error:
|
# Some wrapper objects may report hasattr(aclose) but fail when called
|
||||||
logger.warning(
|
# This is expected behavior for certain client wrappers
|
||||||
f"Failed to close stream response in finally block: {close_error}"
|
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
|
# This prevents resource leaks since the caller doesn't handle closing
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -681,14 +681,6 @@ async def rebuild_knowledge_from_chunks(
|
||||||
entity_chunks_storage=entity_chunks_storage,
|
entity_chunks_storage=entity_chunks_storage,
|
||||||
)
|
)
|
||||||
rebuilt_entities_count += 1
|
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:
|
except Exception as e:
|
||||||
failed_entities_count += 1
|
failed_entities_count += 1
|
||||||
status_message = f"Failed to rebuild `{entity_name}`: {e}"
|
status_message = f"Failed to rebuild `{entity_name}`: {e}"
|
||||||
|
|
@ -1436,10 +1428,6 @@ async def _rebuild_single_relationship(
|
||||||
else:
|
else:
|
||||||
truncation_info = ""
|
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
|
# Update relationship in graph storage
|
||||||
updated_relationship_data = {
|
updated_relationship_data = {
|
||||||
**current_relationship,
|
**current_relationship,
|
||||||
|
|
@ -1514,6 +1502,9 @@ async def _rebuild_single_relationship(
|
||||||
await knowledge_graph_inst.upsert_edge(src, tgt, updated_relationship_data)
|
await knowledge_graph_inst.upsert_edge(src, tgt, updated_relationship_data)
|
||||||
|
|
||||||
# Update relationship in vector database
|
# Update relationship in vector database
|
||||||
|
# Sort src and tgt to ensure consistent ordering (smaller string first)
|
||||||
|
if src > tgt:
|
||||||
|
src, tgt = tgt, src
|
||||||
try:
|
try:
|
||||||
rel_vdb_id = compute_mdhash_id(src + tgt, prefix="rel-")
|
rel_vdb_id = compute_mdhash_id(src + tgt, prefix="rel-")
|
||||||
rel_vdb_id_reverse = compute_mdhash_id(tgt + src, prefix="rel-")
|
rel_vdb_id_reverse = compute_mdhash_id(tgt + src, prefix="rel-")
|
||||||
|
|
@ -2149,13 +2140,13 @@ async def _merge_edges_then_upsert(
|
||||||
else:
|
else:
|
||||||
logger.debug(status_message)
|
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
|
# 11. Update both graph and vector db
|
||||||
for need_insert_id in [src_id, tgt_id]:
|
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_created_at = int(time.time())
|
||||||
node_data = {
|
node_data = {
|
||||||
"entity_id": need_insert_id,
|
"entity_id": need_insert_id,
|
||||||
|
|
@ -2212,6 +2203,109 @@ async def _merge_edges_then_upsert(
|
||||||
"created_at": node_created_at,
|
"created_at": node_created_at,
|
||||||
}
|
}
|
||||||
added_entities.append(entity_data)
|
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())
|
edge_created_at = int(time.time())
|
||||||
await knowledge_graph_inst.upsert_edge(
|
await knowledge_graph_inst.upsert_edge(
|
||||||
|
|
@ -2240,6 +2334,10 @@ async def _merge_edges_then_upsert(
|
||||||
weight=weight,
|
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:
|
if relationships_vdb is not None:
|
||||||
rel_vdb_id = compute_mdhash_id(src_id + tgt_id, prefix="rel-")
|
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-")
|
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
|
return merged_chunks
|
||||||
|
|
||||||
|
|
||||||
async def _build_llm_context(
|
async def _build_context_str(
|
||||||
entities_context: list[dict],
|
entities_context: list[dict],
|
||||||
relations_context: list[dict],
|
relations_context: list[dict],
|
||||||
merged_chunks: list[dict],
|
merged_chunks: list[dict],
|
||||||
|
|
@ -3972,23 +4070,32 @@ async def _build_llm_context(
|
||||||
truncated_chunks
|
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
|
# 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):
|
for i, chunk in enumerate(truncated_chunks):
|
||||||
text_units_context.append(
|
chunks_context.append(
|
||||||
{
|
{
|
||||||
"reference_id": chunk["reference_id"],
|
"reference_id": chunk["reference_id"],
|
||||||
"content": chunk["content"],
|
"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(
|
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
|
# 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
|
# Return empty raw data structure when no entities/relations
|
||||||
empty_raw_data = convert_to_user_format(
|
empty_raw_data = convert_to_user_format(
|
||||||
[],
|
[],
|
||||||
|
|
@ -4019,15 +4126,6 @@ async def _build_llm_context(
|
||||||
if chunk_tracking_log:
|
if chunk_tracking_log:
|
||||||
logger.info(f"Final chunks S+F/O: {' '.join(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(
|
result = kg_context_template.format(
|
||||||
entities_str=entities_str,
|
entities_str=entities_str,
|
||||||
relations_str=relations_str,
|
relations_str=relations_str,
|
||||||
|
|
@ -4037,7 +4135,7 @@ async def _build_llm_context(
|
||||||
|
|
||||||
# Always return both context and complete data structure (unified approach)
|
# Always return both context and complete data structure (unified approach)
|
||||||
logger.debug(
|
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(
|
final_data = convert_to_user_format(
|
||||||
entities_context,
|
entities_context,
|
||||||
|
|
@ -4049,7 +4147,7 @@ async def _build_llm_context(
|
||||||
relation_id_to_original,
|
relation_id_to_original,
|
||||||
)
|
)
|
||||||
logger.debug(
|
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
|
return result, final_data
|
||||||
|
|
||||||
|
|
@ -4126,8 +4224,8 @@ async def _build_query_context(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Stage 4: Build final LLM context with dynamic token processing
|
# Stage 4: Build final LLM context with dynamic token processing
|
||||||
# _build_llm_context now always returns tuple[str, dict]
|
# _build_context_str now always returns tuple[str, dict]
|
||||||
context, raw_data = await _build_llm_context(
|
context, raw_data = await _build_context_str(
|
||||||
entities_context=truncation_result["entities_context"],
|
entities_context=truncation_result["entities_context"],
|
||||||
relations_context=truncation_result["relations_context"],
|
relations_context=truncation_result["relations_context"],
|
||||||
merged_chunks=merged_chunks,
|
merged_chunks=merged_chunks,
|
||||||
|
|
@ -4900,10 +4998,10 @@ async def naive_query(
|
||||||
"final_chunks_count": len(processed_chunks_with_ref_ids),
|
"final_chunks_count": len(processed_chunks_with_ref_ids),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build text_units_context from processed chunks with reference IDs
|
# Build chunks_context from processed chunks with reference IDs
|
||||||
text_units_context = []
|
chunks_context = []
|
||||||
for i, chunk in enumerate(processed_chunks_with_ref_ids):
|
for i, chunk in enumerate(processed_chunks_with_ref_ids):
|
||||||
text_units_context.append(
|
chunks_context.append(
|
||||||
{
|
{
|
||||||
"reference_id": chunk["reference_id"],
|
"reference_id": chunk["reference_id"],
|
||||||
"content": chunk["content"],
|
"content": chunk["content"],
|
||||||
|
|
@ -4911,7 +5009,7 @@ async def naive_query(
|
||||||
)
|
)
|
||||||
|
|
||||||
text_units_str = "\n".join(
|
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(
|
reference_list_str = "\n".join(
|
||||||
f"[{ref['reference_id']}] {ref['file_path']}"
|
f"[{ref['reference_id']}] {ref['file_path']}"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
||||||
# Development environment configuration
|
# Development environment configuration
|
||||||
VITE_BACKEND_URL=http://localhost:9621
|
VITE_BACKEND_URL=http://localhost:9621
|
||||||
VITE_API_PROXY=true
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Development environment configuration
|
# Development environment configuration
|
||||||
VITE_BACKEND_URL=http://localhost:9621
|
VITE_BACKEND_URL=http://localhost:9621
|
||||||
VITE_API_PROXY=true
|
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
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,21 @@ export type QueryResponse = {
|
||||||
response: string
|
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 = {
|
export type DocActionResponse = {
|
||||||
status: 'success' | 'partial_success' | 'failure' | 'duplicated'
|
status: 'success' | 'partial_success' | 'failure' | 'duplicated'
|
||||||
message: string
|
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 entityName The name of the entity to update
|
||||||
* @param updatedData Dictionary containing updated attributes
|
* @param updatedData Dictionary containing updated attributes
|
||||||
* @param allowRename Whether to allow renaming the entity (default: false)
|
* @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
|
* @returns Promise with the updated entity information
|
||||||
*/
|
*/
|
||||||
export const updateEntity = async (
|
export const updateEntity = async (
|
||||||
entityName: string,
|
entityName: string,
|
||||||
updatedData: Record<string, any>,
|
updatedData: Record<string, any>,
|
||||||
allowRename: boolean = false
|
allowRename: boolean = false,
|
||||||
): Promise<DocActionResponse> => {
|
allowMerge: boolean = false
|
||||||
|
): Promise<EntityUpdateResponse> => {
|
||||||
const response = await axiosInstance.post('/graph/entity/edit', {
|
const response = await axiosInstance.post('/graph/entity/edit', {
|
||||||
entity_name: entityName,
|
entity_name: entityName,
|
||||||
updated_data: updatedData,
|
updated_data: updatedData,
|
||||||
allow_rename: allowRename
|
allow_rename: allowRename,
|
||||||
|
allow_merge: allowMerge
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
|
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { SearchHistoryManager } from '@/utils/SearchHistoryManager'
|
||||||
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
|
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
|
||||||
import PropertyEditDialog from './PropertyEditDialog'
|
import PropertyEditDialog from './PropertyEditDialog'
|
||||||
|
import MergeDialog from './MergeDialog'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for the EditablePropertyRow component props
|
* Interface for the EditablePropertyRow component props
|
||||||
|
|
@ -48,6 +51,12 @@ const EditablePropertyRow = ({
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [currentValue, setCurrentValue] = useState(initialValue)
|
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(() => {
|
useEffect(() => {
|
||||||
setCurrentValue(initialValue)
|
setCurrentValue(initialValue)
|
||||||
|
|
@ -56,42 +65,135 @@ const EditablePropertyRow = ({
|
||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
if (isEditable && !isEditing) {
|
if (isEditable && !isEditing) {
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
|
setErrorMessage(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
|
setErrorMessage(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (value: string) => {
|
const handleSave = async (value: string, options?: { allowMerge?: boolean }) => {
|
||||||
if (isSubmitting || value === String(currentValue)) {
|
if (isSubmitting || value === String(currentValue)) {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
|
setErrorMessage(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
setErrorMessage(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (entityType === 'node' && entityId && nodeId) {
|
if (entityType === 'node' && entityId && nodeId) {
|
||||||
let updatedData = { [name]: value }
|
let updatedData = { [name]: value }
|
||||||
|
const allowMerge = options?.allowMerge ?? false
|
||||||
|
|
||||||
if (name === 'entity_id') {
|
if (name === 'entity_id') {
|
||||||
const exists = await checkEntityNameExists(value)
|
if (!allowMerge) {
|
||||||
if (exists) {
|
const exists = await checkEntityNameExists(value)
|
||||||
toast.error(t('graphPanel.propertiesView.errors.duplicateName'))
|
if (exists) {
|
||||||
return
|
const errorMsg = t('graphPanel.propertiesView.errors.duplicateName')
|
||||||
|
setErrorMessage(errorMsg)
|
||||||
|
toast.error(errorMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updatedData = { 'entity_name': value }
|
updatedData = { 'entity_name': value }
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateEntity(entityId, updatedData, true)
|
const response = await updateEntity(entityId, updatedData, true, allowMerge)
|
||||||
try {
|
const operationSummary = response.operation_summary
|
||||||
await useGraphStore.getState().updateNodeAndSelect(nodeId, entityId, name, value)
|
const operationStatus = operationSummary?.operation_status || 'complete_success'
|
||||||
} catch (error) {
|
const finalValue = operationSummary?.final_entity ?? value
|
||||||
console.error('Error updating node in graph:', error)
|
|
||||||
throw new Error('Failed to update node in graph')
|
// 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) {
|
} else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) {
|
||||||
const updatedData = { [name]: value }
|
const updatedData = { [name]: value }
|
||||||
await updateRelation(sourceId, targetId, updatedData)
|
await updateRelation(sourceId, targetId, updatedData)
|
||||||
|
|
@ -102,19 +204,53 @@ const EditablePropertyRow = ({
|
||||||
throw new Error('Failed to update edge in graph')
|
throw new Error('Failed to update edge in graph')
|
||||||
}
|
}
|
||||||
toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
|
toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
|
||||||
|
setCurrentValue(value)
|
||||||
|
onValueChange?.(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
setCurrentValue(value)
|
|
||||||
onValueChange?.(value)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating property:', 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 {
|
} finally {
|
||||||
setIsSubmitting(false)
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-1 overflow-hidden">
|
<div className="flex items-center gap-1 overflow-hidden">
|
||||||
<PropertyName name={name} />
|
<PropertyName name={name} />
|
||||||
|
|
@ -131,6 +267,19 @@ const EditablePropertyRow = ({
|
||||||
propertyName={name}
|
propertyName={name}
|
||||||
initialValue={String(currentValue)}
|
initialValue={String(currentValue)}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MergeDialog
|
||||||
|
mergeDialogOpen={mergeDialogOpen}
|
||||||
|
mergeDialogInfo={mergeDialogInfo}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setMergeDialogOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setMergeDialogInfo(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRefresh={handleMergeRefresh}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||||
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { useGraphStore } from '@/stores/graph'
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
import { useBackendState } from '@/stores/state'
|
||||||
import {
|
import {
|
||||||
dropdownDisplayLimit,
|
dropdownDisplayLimit,
|
||||||
controlButtonVariant,
|
controlButtonVariant,
|
||||||
|
|
@ -17,10 +18,16 @@ import { getPopularLabels, searchLabels } from '@/api/lightrag'
|
||||||
const GraphLabels = () => {
|
const GraphLabels = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const label = useSettingsStore.use.queryLabel()
|
const label = useSettingsStore.use.queryLabel()
|
||||||
|
const dropdownRefreshTrigger = useSettingsStore.use.searchLabelDropdownRefreshTrigger()
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [selectKey, setSelectKey] = 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
|
// Dynamic tooltip based on current label state
|
||||||
const getRefreshTooltip = useCallback(() => {
|
const getRefreshTooltip = useCallback(() => {
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
|
|
@ -54,6 +61,61 @@ const GraphLabels = () => {
|
||||||
initializeHistory()
|
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(
|
const fetchData = useCallback(
|
||||||
async (query?: string): Promise<string[]> => {
|
async (query?: string): Promise<string[]> => {
|
||||||
let results: string[] = [];
|
let results: string[] = [];
|
||||||
|
|
@ -102,6 +164,12 @@ const GraphLabels = () => {
|
||||||
currentLabel = '*'
|
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 !== '*') {
|
if (currentLabel && currentLabel !== '*') {
|
||||||
// Scenario 1: Has specific label, try to refresh current label
|
// Scenario 1: Has specific label, try to refresh current label
|
||||||
console.log(`Refreshing current label: ${currentLabel}`)
|
console.log(`Refreshing current label: ${currentLabel}`)
|
||||||
|
|
@ -122,7 +190,7 @@ const GraphLabels = () => {
|
||||||
console.log('Refreshing global data and popular labels')
|
console.log('Refreshing global data and popular labels')
|
||||||
|
|
||||||
try {
|
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)
|
const popularLabels = await getPopularLabels(popularLabelsDefaultLimit)
|
||||||
SearchHistoryManager.clearHistory()
|
SearchHistoryManager.clearHistory()
|
||||||
|
|
||||||
|
|
@ -160,7 +228,16 @@ const GraphLabels = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false)
|
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 (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|
@ -183,6 +260,7 @@ const GraphLabels = () => {
|
||||||
searchInputClassName="max-h-8"
|
searchInputClassName="max-h-8"
|
||||||
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
|
||||||
fetcher={fetchData}
|
fetcher={fetchData}
|
||||||
|
onBeforeOpen={handleDropdownBeforeOpen}
|
||||||
renderOption={(item) => (
|
renderOption={(item) => (
|
||||||
<div className="truncate" title={item}>
|
<div className="truncate" title={item}>
|
||||||
{item}
|
{item}
|
||||||
|
|
@ -223,6 +301,9 @@ const GraphLabels = () => {
|
||||||
|
|
||||||
// Update the label to trigger data loading
|
// Update the label to trigger data loading
|
||||||
useSettingsStore.getState().setQueryLabel(newLabel);
|
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
|
clearable={false} // Prevent clearing value on reselect
|
||||||
debounceTime={500}
|
debounceTime={500}
|
||||||
|
|
|
||||||
70
lightrag_webui/src/components/graph/MergeDialog.tsx
Normal file
70
lightrag_webui/src/components/graph/MergeDialog.tsx
Normal 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
|
||||||
|
|
@ -225,8 +225,8 @@ const PropertyRow = ({
|
||||||
formattedTooltip += `\n(Truncated: ${truncate})`
|
formattedTooltip += `\n(Truncated: ${truncate})`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use EditablePropertyRow for editable fields (description, entity_id and keywords)
|
// Use EditablePropertyRow for editable fields (description, entity_id and entity_type)
|
||||||
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'keywords')) {
|
if (isEditable && (name === 'description' || name === 'entity_id' || name === 'entity_type' || name === 'keywords')) {
|
||||||
return (
|
return (
|
||||||
<EditablePropertyRow
|
<EditablePropertyRow
|
||||||
name={name}
|
name={name}
|
||||||
|
|
@ -325,7 +325,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
||||||
nodeId={String(node.id)}
|
nodeId={String(node.id)}
|
||||||
entityId={node.properties['entity_id']}
|
entityId={node.properties['entity_id']}
|
||||||
entityType="node"
|
entityType="node"
|
||||||
isEditable={name === 'description' || name === 'entity_id'}
|
isEditable={name === 'description' || name === 'entity_id' || name === 'entity_type'}
|
||||||
truncate={node.properties['truncate']}
|
truncate={node.properties['truncate']}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,16 @@ import {
|
||||||
DialogDescription
|
DialogDescription
|
||||||
} from '@/components/ui/Dialog'
|
} from '@/components/ui/Dialog'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
|
import Checkbox from '@/components/ui/Checkbox'
|
||||||
|
|
||||||
interface PropertyEditDialogProps {
|
interface PropertyEditDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (value: string) => void
|
onSave: (value: string, options?: { allowMerge?: boolean }) => void
|
||||||
propertyName: string
|
propertyName: string
|
||||||
initialValue: string
|
initialValue: string
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
|
errorMessage?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,17 +31,18 @@ const PropertyEditDialog = ({
|
||||||
onSave,
|
onSave,
|
||||||
propertyName,
|
propertyName,
|
||||||
initialValue,
|
initialValue,
|
||||||
isSubmitting = false
|
isSubmitting = false,
|
||||||
|
errorMessage = null
|
||||||
}: PropertyEditDialogProps) => {
|
}: PropertyEditDialogProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
// Add error state to display save failure messages
|
const [allowMerge, setAllowMerge] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Initialize value when dialog opens
|
// Initialize value when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setValue(initialValue)
|
setValue(initialValue)
|
||||||
|
setAllowMerge(false)
|
||||||
}
|
}
|
||||||
}, [isOpen, initialValue])
|
}, [isOpen, initialValue])
|
||||||
|
|
||||||
|
|
@ -85,19 +88,10 @@ const PropertyEditDialog = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (value.trim() !== '') {
|
const trimmedValue = value.trim()
|
||||||
// Clear previous error messages
|
if (trimmedValue !== '') {
|
||||||
setError(null)
|
const options = propertyName === 'entity_id' ? { allowMerge } : undefined
|
||||||
try {
|
await onSave(trimmedValue, options)
|
||||||
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'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,9 +110,9 @@ const PropertyEditDialog = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Display error message if save fails */}
|
{/* Display error message if save fails */}
|
||||||
{error && (
|
{errorMessage && (
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm mt-2">
|
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm">
|
||||||
{error}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -146,6 +140,25 @@ const PropertyEditDialog = ({
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</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>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ export interface AsyncSelectProps<T> {
|
||||||
value: string
|
value: string
|
||||||
/** Callback when selection changes */
|
/** Callback when selection changes */
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
|
/** Callback before opening the dropdown (async supported) */
|
||||||
|
onBeforeOpen?: () => void | Promise<void>
|
||||||
/** Accessibility label for the select field */
|
/** Accessibility label for the select field */
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
/** Placeholder text when no selection */
|
/** Placeholder text when no selection */
|
||||||
|
|
@ -83,6 +85,7 @@ export function AsyncSelect<T>({
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onBeforeOpen,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
|
|
@ -196,8 +199,18 @@ export function AsyncSelect<T>({
|
||||||
[selectedValue, onChange, clearable, options, getOptionValue]
|
[selectedValue, onChange, clearable, options, getOptionValue]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
async (newOpen: boolean) => {
|
||||||
|
if (newOpen && onBeforeOpen) {
|
||||||
|
await onBeforeOpen()
|
||||||
|
}
|
||||||
|
setOpen(newOpen)
|
||||||
|
},
|
||||||
|
[onBeforeOpen]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -226,13 +226,13 @@ const GraphViewer = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPropertyPanel && (
|
{showPropertyPanel && (
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2 z-10">
|
||||||
<PropertiesView />
|
<PropertiesView />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showLegend && (
|
{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" />
|
<Legend className="bg-background/60 backdrop-blur-lg" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ export default function SiteHeader() {
|
||||||
? `${coreVersion}/${apiVersion}`
|
? `${coreVersion}/${apiVersion}`
|
||||||
: null;
|
: 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 = () => {
|
const handleLogout = () => {
|
||||||
navigationService.navigateToLogin();
|
navigationService.navigateToLogin();
|
||||||
}
|
}
|
||||||
|
|
@ -106,9 +112,18 @@ export default function SiteHeader() {
|
||||||
<nav className="w-[200px] flex items-center justify-end">
|
<nav className="w-[200px] flex items-center justify-end">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{versionDisplay && (
|
{versionDisplay && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1">
|
<TooltipProvider>
|
||||||
v{versionDisplay}
|
<Tooltip>
|
||||||
</span>
|
<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')}>
|
<Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}>
|
||||||
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
|
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
|
||||||
|
|
|
||||||
|
|
@ -10,225 +10,18 @@ import { useBackendState } from '@/stores/state'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
import seedrandom from 'seedrandom'
|
import seedrandom from 'seedrandom'
|
||||||
|
import { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor'
|
||||||
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
|
|
||||||
];
|
|
||||||
|
|
||||||
// Select color based on node type
|
// Select color based on node type
|
||||||
const getNodeColorByType = (nodeType: string | undefined): string => {
|
const getNodeColorByType = (nodeType: string | undefined): string => {
|
||||||
|
const state = useGraphStore.getState()
|
||||||
|
const { color, map, updated } = resolveNodeColor(nodeType, state.typeColorMap)
|
||||||
|
|
||||||
const defaultColor = '#5D6D7E';
|
if (updated) {
|
||||||
|
useGraphStore.setState({ typeColorMap: map })
|
||||||
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 (standardType) {
|
return color || DEFAULT_NODE_COLOR
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"api": "واجهة برمجة التطبيقات",
|
"api": "واجهة برمجة التطبيقات",
|
||||||
"projectRepository": "مستودع المشروع",
|
"projectRepository": "مستودع المشروع",
|
||||||
"logout": "تسجيل الخروج",
|
"logout": "تسجيل الخروج",
|
||||||
|
"frontendNeedsRebuild": "الواجهة الأمامية تحتاج إلى إعادة البناء",
|
||||||
"themeToggle": {
|
"themeToggle": {
|
||||||
"switchToLight": "التحويل إلى السمة الفاتحة",
|
"switchToLight": "التحويل إلى السمة الفاتحة",
|
||||||
"switchToDark": "التحويل إلى السمة الداكنة"
|
"switchToDark": "التحويل إلى السمة الداكنة"
|
||||||
|
|
@ -305,11 +306,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "اسم العقدة موجود بالفعل",
|
"duplicateName": "اسم العقدة موجود بالفعل",
|
||||||
"updateFailed": "فشل تحديث العقدة",
|
"updateFailed": "فشل تحديث العقدة",
|
||||||
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا"
|
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا",
|
||||||
|
"updateSuccessButMergeFailed": "تم تحديث الخصائص، لكن الدمج فشل: {{error}}",
|
||||||
|
"mergeFailed": "فشل الدمج: {{error}}"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"entityUpdated": "تم تحديث العقدة بنجاح",
|
"entityUpdated": "تم تحديث العقدة بنجاح",
|
||||||
"relationUpdated": "تم تحديث العلاقة بنجاح"
|
"relationUpdated": "تم تحديث العلاقة بنجاح",
|
||||||
|
"entityMerged": "تم دمج العقد بنجاح"
|
||||||
|
},
|
||||||
|
"mergeOptionLabel": "دمج تلقائي عند العثور على اسم مكرر",
|
||||||
|
"mergeOptionDescription": "عند التفعيل، سيتم دمج هذه العقدة تلقائيًا في العقدة الموجودة بدلاً من ظهور خطأ عند إعادة التسمية بنفس الاسم.",
|
||||||
|
"mergeDialog": {
|
||||||
|
"title": "تم دمج العقدة",
|
||||||
|
"description": "\"{{source}}\" تم دمجها في \"{{target}}\".",
|
||||||
|
"refreshHint": "يجب تحديث الرسم البياني لتحميل البنية الأحدث.",
|
||||||
|
"keepCurrentStart": "تحديث مع الحفاظ على عقدة البدء الحالية",
|
||||||
|
"useMergedStart": "تحديث واستخدام العقدة المدمجة كنقطة بدء",
|
||||||
|
"refreshing": "جارٍ تحديث الرسم البياني..."
|
||||||
},
|
},
|
||||||
"node": {
|
"node": {
|
||||||
"title": "عقدة",
|
"title": "عقدة",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"projectRepository": "Project Repository",
|
"projectRepository": "Project Repository",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
|
"frontendNeedsRebuild": "Frontend needs rebuild",
|
||||||
"themeToggle": {
|
"themeToggle": {
|
||||||
"switchToLight": "Switch to light theme",
|
"switchToLight": "Switch to light theme",
|
||||||
"switchToDark": "Switch to dark theme"
|
"switchToDark": "Switch to dark theme"
|
||||||
|
|
@ -305,11 +306,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "Node name already exists",
|
"duplicateName": "Node name already exists",
|
||||||
"updateFailed": "Failed to update node",
|
"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": {
|
"success": {
|
||||||
"entityUpdated": "Node updated successfully",
|
"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": {
|
"node": {
|
||||||
"title": "Node",
|
"title": "Node",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"projectRepository": "Référentiel du projet",
|
"projectRepository": "Référentiel du projet",
|
||||||
"logout": "Déconnexion",
|
"logout": "Déconnexion",
|
||||||
|
"frontendNeedsRebuild": "Le frontend nécessite une reconstruction",
|
||||||
"themeToggle": {
|
"themeToggle": {
|
||||||
"switchToLight": "Passer au thème clair",
|
"switchToLight": "Passer au thème clair",
|
||||||
"switchToDark": "Passer au thème sombre"
|
"switchToDark": "Passer au thème sombre"
|
||||||
|
|
@ -305,11 +306,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "Le nom du nœud existe déjà",
|
"duplicateName": "Le nom du nœud existe déjà",
|
||||||
"updateFailed": "Échec de la mise à jour du nœud",
|
"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": {
|
"success": {
|
||||||
"entityUpdated": "Nœud mis à jour avec succès",
|
"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": {
|
"node": {
|
||||||
"title": "Nœud",
|
"title": "Nœud",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"projectRepository": "项目仓库",
|
"projectRepository": "项目仓库",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录",
|
||||||
|
"frontendNeedsRebuild": "前端代码需重新构建",
|
||||||
"themeToggle": {
|
"themeToggle": {
|
||||||
"switchToLight": "切换到浅色主题",
|
"switchToLight": "切换到浅色主题",
|
||||||
"switchToDark": "切换到深色主题"
|
"switchToDark": "切换到深色主题"
|
||||||
|
|
@ -305,11 +306,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "节点名称已存在",
|
"duplicateName": "节点名称已存在",
|
||||||
"updateFailed": "更新节点失败",
|
"updateFailed": "更新节点失败",
|
||||||
"tryAgainLater": "请稍后重试"
|
"tryAgainLater": "请稍后重试",
|
||||||
|
"updateSuccessButMergeFailed": "属性已更新,但合并失败:{{error}}",
|
||||||
|
"mergeFailed": "合并失败:{{error}}"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"entityUpdated": "节点更新成功",
|
"entityUpdated": "节点更新成功",
|
||||||
"relationUpdated": "关系更新成功"
|
"relationUpdated": "关系更新成功",
|
||||||
|
"entityMerged": "节点合并成功"
|
||||||
|
},
|
||||||
|
"mergeOptionLabel": "重名时自动合并",
|
||||||
|
"mergeOptionDescription": "勾选后,重命名为已存在的名称会将当前节点自动合并过去,而不会报错。",
|
||||||
|
"mergeDialog": {
|
||||||
|
"title": "节点已合并",
|
||||||
|
"description": "\"{{source}}\" 已合并到 \"{{target}}\"。",
|
||||||
|
"refreshHint": "请刷新图谱以获取最新结构。",
|
||||||
|
"keepCurrentStart": "刷新并保持当前起始节点",
|
||||||
|
"useMergedStart": "刷新并以合并后的节点为起始节点",
|
||||||
|
"refreshing": "正在刷新图谱..."
|
||||||
},
|
},
|
||||||
"node": {
|
"node": {
|
||||||
"title": "节点",
|
"title": "节点",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"projectRepository": "專案庫",
|
"projectRepository": "專案庫",
|
||||||
"logout": "登出",
|
"logout": "登出",
|
||||||
|
"frontendNeedsRebuild": "前端程式碼需重新建置",
|
||||||
"themeToggle": {
|
"themeToggle": {
|
||||||
"switchToLight": "切換至淺色主題",
|
"switchToLight": "切換至淺色主題",
|
||||||
"switchToDark": "切換至深色主題"
|
"switchToDark": "切換至深色主題"
|
||||||
|
|
@ -305,11 +306,24 @@
|
||||||
"errors": {
|
"errors": {
|
||||||
"duplicateName": "節點名稱已存在",
|
"duplicateName": "節點名稱已存在",
|
||||||
"updateFailed": "更新節點失敗",
|
"updateFailed": "更新節點失敗",
|
||||||
"tryAgainLater": "請稍後重試"
|
"tryAgainLater": "請稍後重試",
|
||||||
|
"updateSuccessButMergeFailed": "屬性已更新,但合併失敗:{{error}}",
|
||||||
|
"mergeFailed": "合併失敗:{{error}}"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"entityUpdated": "節點更新成功",
|
"entityUpdated": "節點更新成功",
|
||||||
"relationUpdated": "關係更新成功"
|
"relationUpdated": "關係更新成功",
|
||||||
|
"entityMerged": "節點合併成功"
|
||||||
|
},
|
||||||
|
"mergeOptionLabel": "遇到重名時自動合併",
|
||||||
|
"mergeOptionDescription": "勾選後,重新命名為既有名稱時會自動將當前節點合併過去,不再報錯。",
|
||||||
|
"mergeDialog": {
|
||||||
|
"title": "節點已合併",
|
||||||
|
"description": "\"{{source}}\" 已合併到 \"{{target}}\"。",
|
||||||
|
"refreshHint": "請重新整理圖譜以取得最新結構。",
|
||||||
|
"keepCurrentStart": "重新整理並保留目前的起始節點",
|
||||||
|
"useMergedStart": "重新整理並以合併後的節點為起始節點",
|
||||||
|
"refreshing": "正在重新整理圖譜..."
|
||||||
},
|
},
|
||||||
"node": {
|
"node": {
|
||||||
"title": "節點",
|
"title": "節點",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { create } from 'zustand'
|
||||||
import { createSelectors } from '@/lib/utils'
|
import { createSelectors } from '@/lib/utils'
|
||||||
import { DirectedGraph } from 'graphology'
|
import { DirectedGraph } from 'graphology'
|
||||||
import MiniSearch from 'minisearch'
|
import MiniSearch from 'minisearch'
|
||||||
|
import { resolveNodeColor, DEFAULT_NODE_COLOR } from '@/utils/graphColor'
|
||||||
|
|
||||||
export type RawNodeType = {
|
export type RawNodeType = {
|
||||||
// for NetworkX: id is identical to properties['entity_id']
|
// 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)
|
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')) {
|
if ((nodeId === entityId) && (propertyName === 'entity_id')) {
|
||||||
// Create new node with updated ID but same attributes
|
// Create new node with updated ID but same attributes
|
||||||
sigmaGraph.addNode(newValue, { ...nodeAttributes, label: newValue })
|
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
|
// For non-NetworkX nodes or non-entity_id changes
|
||||||
const nodeIndex = rawGraph.nodeIdMap[String(nodeId)]
|
const nodeIndex = rawGraph.nodeIdMap[String(nodeId)]
|
||||||
if (nodeIndex !== undefined) {
|
if (nodeIndex !== undefined) {
|
||||||
rawGraph.nodes[nodeIndex].properties[propertyName] = newValue
|
const nodeRef = rawGraph.nodes[nodeIndex]
|
||||||
|
nodeRef.properties[propertyName] = newValue
|
||||||
if (propertyName === 'entity_id') {
|
if (propertyName === 'entity_id') {
|
||||||
rawGraph.nodes[nodeIndex].labels = [newValue]
|
nodeRef.labels = [newValue]
|
||||||
sigmaGraph.setNodeAttribute(String(nodeId), 'label', 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
|
// Trigger a re-render by incrementing the version counter
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ interface SettingsState {
|
||||||
|
|
||||||
currentTab: Tab
|
currentTab: Tab
|
||||||
setCurrentTab: (tab: Tab) => void
|
setCurrentTab: (tab: Tab) => void
|
||||||
|
|
||||||
|
// Search label dropdown refresh trigger (non-persistent, runtime only)
|
||||||
|
searchLabelDropdownRefreshTrigger: number
|
||||||
|
triggerSearchLabelDropdownRefresh: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSettingsStoreBase = create<SettingsState>()(
|
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',
|
name: 'settings-storage',
|
||||||
|
|
|
||||||
228
lightrag_webui/src/utils/graphColor.ts
Normal file
228
lightrag_webui/src/utils/graphColor.ts
Normal 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 }
|
||||||
|
|
@ -40,7 +40,7 @@ export default defineConfig({
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: endpoint === '/api' ?
|
rewrite: endpoint === '/api' ?
|
||||||
(path) => path.replace(/^\/api/, '') :
|
(path) => path.replace(/^\/api/, '') :
|
||||||
endpoint === '/docs' || endpoint === '/redoc' || endpoint === '/openapi.json' ?
|
endpoint === '/docs' || endpoint === '/redoc' || endpoint === '/openapi.json' || endpoint === '/static' ?
|
||||||
(path) => path : undefined
|
(path) => path : undefined
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -80,15 +80,16 @@ api = [
|
||||||
# Offline deployment dependencies (layered design for flexibility)
|
# Offline deployment dependencies (layered design for flexibility)
|
||||||
offline-docs = [
|
offline-docs = [
|
||||||
# Document processing dependencies
|
# Document processing dependencies
|
||||||
|
"openpyxl>=3.0.0,<4.0.0",
|
||||||
|
"pycryptodome>=3.0.0,<4.0.0",
|
||||||
"pypdf2>=3.0.0",
|
"pypdf2>=3.0.0",
|
||||||
"python-docx>=0.8.11,<2.0.0",
|
"python-docx>=0.8.11,<2.0.0",
|
||||||
"python-pptx>=0.6.21,<2.0.0",
|
"python-pptx>=0.6.21,<2.0.0",
|
||||||
"openpyxl>=3.0.0,<4.0.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
offline-storage = [
|
offline-storage = [
|
||||||
# Storage backend dependencies
|
# Storage backend dependencies
|
||||||
"redis>=5.0.0,<7.0.0",
|
"redis>=5.0.0,<8.0.0",
|
||||||
"neo4j>=5.0.0,<7.0.0",
|
"neo4j>=5.0.0,<7.0.0",
|
||||||
"pymilvus>=2.6.2,<3.0.0",
|
"pymilvus>=2.6.2,<3.0.0",
|
||||||
"pymongo>=4.0.0,<5.0.0",
|
"pymongo>=4.0.0,<5.0.0",
|
||||||
|
|
@ -112,6 +113,11 @@ offline = [
|
||||||
"lightrag-hku[offline-docs,offline-storage,offline-llm]",
|
"lightrag-hku[offline-docs,offline-storage,offline-llm]",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
observability = [
|
||||||
|
# LLM observability and tracing dependencies
|
||||||
|
"langfuse>=3.8.1",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
lightrag-server = "lightrag.api.lightrag_server:main"
|
lightrag-server = "lightrag.api.lightrag_server:main"
|
||||||
lightrag-gunicorn = "lightrag.api.run_with_gunicorn:main"
|
lightrag-gunicorn = "lightrag.api.run_with_gunicorn:main"
|
||||||
|
|
@ -134,7 +140,7 @@ include-package-data = true
|
||||||
version = {attr = "lightrag.__version__"}
|
version = {attr = "lightrag.__version__"}
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
lightrag = ["api/webui/**/*"]
|
lightrag = ["api/webui/**/*", "api/static/**/*"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
# Document processing dependencies (with version constraints matching pyproject.toml)
|
# Document processing dependencies (with version constraints matching pyproject.toml)
|
||||||
openpyxl>=3.0.0,<4.0.0
|
openpyxl>=3.0.0,<4.0.0
|
||||||
|
pycryptodome>=3.0.0,<4.0.0
|
||||||
pypdf2>=3.0.0
|
pypdf2>=3.0.0
|
||||||
python-docx>=0.8.11,<2.0.0
|
python-docx>=0.8.11,<2.0.0
|
||||||
python-pptx>=0.6.21,<2.0.0
|
python-pptx>=0.6.21,<2.0.0
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,4 @@ neo4j>=5.0.0,<7.0.0
|
||||||
pymilvus>=2.6.2,<3.0.0
|
pymilvus>=2.6.2,<3.0.0
|
||||||
pymongo>=4.0.0,<5.0.0
|
pymongo>=4.0.0,<5.0.0
|
||||||
qdrant-client>=1.7.0,<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
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ neo4j>=5.0.0,<7.0.0
|
||||||
ollama>=0.1.0,<1.0.0
|
ollama>=0.1.0,<1.0.0
|
||||||
openai>=1.0.0,<3.0.0
|
openai>=1.0.0,<3.0.0
|
||||||
openpyxl>=3.0.0,<4.0.0
|
openpyxl>=3.0.0,<4.0.0
|
||||||
|
pycryptodome>=3.0.0,<4.0.0
|
||||||
pymilvus>=2.6.2,<3.0.0
|
pymilvus>=2.6.2,<3.0.0
|
||||||
pymongo>=4.0.0,<5.0.0
|
pymongo>=4.0.0,<5.0.0
|
||||||
pypdf2>=3.0.0
|
pypdf2>=3.0.0
|
||||||
python-docx>=0.8.11,<2.0.0
|
python-docx>=0.8.11,<2.0.0
|
||||||
python-pptx>=0.6.21,<2.0.0
|
python-pptx>=0.6.21,<2.0.0
|
||||||
qdrant-client>=1.7.0,<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
|
voyageai>=0.2.0,<1.0.0
|
||||||
zhipuai>=2.0.0,<3.0.0
|
zhipuai>=2.0.0,<3.0.0
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue