Merge branch 'improve-property-tooltip' into feat-node-expand

This commit is contained in:
yangdx
2025-03-12 14:41:26 +08:00
43 changed files with 1586 additions and 398 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="./logo.png" /> <link rel="icon" type="image/svg+xml" href="./logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title> <title>Lightrag</title>
<script type="module" crossorigin src="./assets/index-C_HczF2h.js"></script> <script type="module" crossorigin src="./assets/index-B9TRs-Wk.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CQ75jPFs.css"> <link rel="stylesheet" crossorigin href="./assets/index-DRGuXfZw.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -127,6 +127,30 @@ class BaseVectorStorage(StorageNameSpace, ABC):
async def delete_entity_relation(self, entity_name: str) -> None: async def delete_entity_relation(self, entity_name: str) -> None:
"""Delete relations for a given entity.""" """Delete relations for a given entity."""
@abstractmethod
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
pass
@abstractmethod
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
pass
@dataclass @dataclass
class BaseKVStorage(StorageNameSpace, ABC): class BaseKVStorage(StorageNameSpace, ABC):

View File

@@ -156,7 +156,9 @@ class ChromaVectorDBStorage(BaseVectorStorage):
logger.error(f"Error during ChromaDB upsert: {str(e)}") logger.error(f"Error during ChromaDB upsert: {str(e)}")
raise raise
async def query(self, query: str, top_k: int) -> list[dict[str, Any]]: async def query(
self, query: str, top_k: int, ids: list[str] | None = None
) -> list[dict[str, Any]]:
try: try:
embedding = await self.embedding_func([query]) embedding = await self.embedding_func([query])
@@ -269,3 +271,67 @@ class ChromaVectorDBStorage(BaseVectorStorage):
except Exception as e: except Exception as e:
logger.error(f"Error during prefix search in ChromaDB: {str(e)}") logger.error(f"Error during prefix search in ChromaDB: {str(e)}")
raise raise
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Query the collection for a single vector by ID
result = self._collection.get(
ids=[id], include=["metadatas", "embeddings", "documents"]
)
if not result or not result["ids"] or len(result["ids"]) == 0:
return None
# Format the result to match the expected structure
return {
"id": result["ids"][0],
"vector": result["embeddings"][0],
"content": result["documents"][0],
**result["metadatas"][0],
}
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Query the collection for multiple vectors by IDs
result = self._collection.get(
ids=ids, include=["metadatas", "embeddings", "documents"]
)
if not result or not result["ids"] or len(result["ids"]) == 0:
return []
# Format the results to match the expected structure
return [
{
"id": result["ids"][i],
"vector": result["embeddings"][i],
"content": result["documents"][i],
**result["metadatas"][i],
}
for i in range(len(result["ids"]))
]
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []

View File

@@ -171,7 +171,9 @@ class FaissVectorDBStorage(BaseVectorStorage):
logger.info(f"Upserted {len(list_data)} vectors into Faiss index.") logger.info(f"Upserted {len(list_data)} vectors into Faiss index.")
return [m["__id__"] for m in list_data] return [m["__id__"] for m in list_data]
async def query(self, query: str, top_k: int) -> list[dict[str, Any]]: async def query(
self, query: str, top_k: int, ids: list[str] | None = None
) -> list[dict[str, Any]]:
""" """
Search by a textual query; returns top_k results with their metadata + similarity distance. Search by a textual query; returns top_k results with their metadata + similarity distance.
""" """
@@ -392,3 +394,46 @@ class FaissVectorDBStorage(BaseVectorStorage):
logger.debug(f"Found {len(matching_records)} records with prefix '{prefix}'") logger.debug(f"Found {len(matching_records)} records with prefix '{prefix}'")
return matching_records return matching_records
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
# Find the Faiss internal ID for the custom ID
fid = self._find_faiss_id_by_custom_id(id)
if fid is None:
return None
# Get the metadata for the found ID
metadata = self._id_to_meta.get(fid, {})
if not metadata:
return None
return {**metadata, "id": metadata.get("__id__")}
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
results = []
for id in ids:
fid = self._find_faiss_id_by_custom_id(id)
if fid is not None:
metadata = self._id_to_meta.get(fid, {})
if metadata:
results.append({**metadata, "id": metadata.get("__id__")})
return results

View File

@@ -101,7 +101,9 @@ class MilvusVectorDBStorage(BaseVectorStorage):
results = self._client.upsert(collection_name=self.namespace, data=list_data) results = self._client.upsert(collection_name=self.namespace, data=list_data)
return results return results
async def query(self, query: str, top_k: int) -> list[dict[str, Any]]: async def query(
self, query: str, top_k: int, ids: list[str] | None = None
) -> list[dict[str, Any]]:
embedding = await self.embedding_func([query]) embedding = await self.embedding_func([query])
results = self._client.search( results = self._client.search(
collection_name=self.namespace, collection_name=self.namespace,
@@ -231,3 +233,57 @@ class MilvusVectorDBStorage(BaseVectorStorage):
except Exception as e: except Exception as e:
logger.error(f"Error searching for records with prefix '{prefix}': {e}") logger.error(f"Error searching for records with prefix '{prefix}': {e}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Query Milvus for a specific ID
result = self._client.query(
collection_name=self.namespace,
filter=f'id == "{id}"',
output_fields=list(self.meta_fields) + ["id"],
)
if not result or len(result) == 0:
return None
return result[0]
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Prepare the ID filter expression
id_list = '", "'.join(ids)
filter_expr = f'id in ["{id_list}"]'
# Query Milvus with the filter
result = self._client.query(
collection_name=self.namespace,
filter=filter_expr,
output_fields=list(self.meta_fields) + ["id"],
)
return result or []
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []

View File

@@ -938,7 +938,9 @@ class MongoVectorDBStorage(BaseVectorStorage):
return list_data return list_data
async def query(self, query: str, top_k: int) -> list[dict[str, Any]]: async def query(
self, query: str, top_k: int, ids: list[str] | None = None
) -> list[dict[str, Any]]:
"""Queries the vector database using Atlas Vector Search.""" """Queries the vector database using Atlas Vector Search."""
# Generate the embedding # Generate the embedding
embedding = await self.embedding_func([query]) embedding = await self.embedding_func([query])
@@ -1071,6 +1073,59 @@ class MongoVectorDBStorage(BaseVectorStorage):
logger.error(f"Error searching by prefix in {self.namespace}: {str(e)}") logger.error(f"Error searching by prefix in {self.namespace}: {str(e)}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Search for the specific ID in MongoDB
result = await self._data.find_one({"_id": id})
if result:
# Format the result to include id field expected by API
result_dict = dict(result)
if "_id" in result_dict and "id" not in result_dict:
result_dict["id"] = result_dict["_id"]
return result_dict
return None
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Query MongoDB for multiple IDs
cursor = self._data.find({"_id": {"$in": ids}})
results = await cursor.to_list(length=None)
# Format results to include id field expected by API
formatted_results = []
for result in results:
result_dict = dict(result)
if "_id" in result_dict and "id" not in result_dict:
result_dict["id"] = result_dict["_id"]
formatted_results.append(result_dict)
return formatted_results
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []
async def get_or_create_collection(db: AsyncIOMotorDatabase, collection_name: str): async def get_or_create_collection(db: AsyncIOMotorDatabase, collection_name: str):
collection_names = await db.list_collection_names() collection_names = await db.list_collection_names()

View File

@@ -120,7 +120,9 @@ class NanoVectorDBStorage(BaseVectorStorage):
f"embedding is not 1-1 with data, {len(embeddings)} != {len(list_data)}" f"embedding is not 1-1 with data, {len(embeddings)} != {len(list_data)}"
) )
async def query(self, query: str, top_k: int) -> list[dict[str, Any]]: async def query(
self, query: str, top_k: int, ids: list[str] | None = None
) -> list[dict[str, Any]]:
# Execute embedding outside of lock to avoid long lock times # Execute embedding outside of lock to avoid long lock times
embedding = await self.embedding_func([query]) embedding = await self.embedding_func([query])
embedding = embedding[0] embedding = embedding[0]
@@ -256,3 +258,33 @@ class NanoVectorDBStorage(BaseVectorStorage):
logger.debug(f"Found {len(matching_records)} records with prefix '{prefix}'") logger.debug(f"Found {len(matching_records)} records with prefix '{prefix}'")
return matching_records return matching_records
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
client = await self._get_client()
result = client.get([id])
if result:
return result[0]
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
client = await self._get_client()
return client.get(ids)

View File

@@ -417,7 +417,9 @@ class OracleVectorDBStorage(BaseVectorStorage):
self.db = None self.db = None
#################### query method ############### #################### query method ###############
async def query(self, query: str, top_k: int) -> list[dict[str, Any]]: async def query(
self, query: str, top_k: int, ids: list[str] | None = None
) -> list[dict[str, Any]]:
embeddings = await self.embedding_func([query]) embeddings = await self.embedding_func([query])
embedding = embeddings[0] embedding = embeddings[0]
# 转换精度 # 转换精度
@@ -529,6 +531,80 @@ class OracleVectorDBStorage(BaseVectorStorage):
logger.error(f"Error searching records with prefix '{prefix}': {e}") logger.error(f"Error searching records with prefix '{prefix}': {e}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Determine the table name based on namespace
table_name = namespace_to_table_name(self.namespace)
if not table_name:
logger.error(f"Unknown namespace for ID lookup: {self.namespace}")
return None
# Create the appropriate ID field name based on namespace
id_field = "entity_id" if "NODES" in table_name else "relation_id"
if "CHUNKS" in table_name:
id_field = "chunk_id"
# Prepare and execute the query
query = f"""
SELECT * FROM {table_name}
WHERE {id_field} = :id AND workspace = :workspace
"""
params = {"id": id, "workspace": self.db.workspace}
result = await self.db.query(query, params)
return result
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Determine the table name based on namespace
table_name = namespace_to_table_name(self.namespace)
if not table_name:
logger.error(f"Unknown namespace for IDs lookup: {self.namespace}")
return []
# Create the appropriate ID field name based on namespace
id_field = "entity_id" if "NODES" in table_name else "relation_id"
if "CHUNKS" in table_name:
id_field = "chunk_id"
# Format the list of IDs for SQL IN clause
ids_list = ", ".join([f"'{id}'" for id in ids])
# Prepare and execute the query
query = f"""
SELECT * FROM {table_name}
WHERE {id_field} IN ({ids_list}) AND workspace = :workspace
"""
params = {"workspace": self.db.workspace}
results = await self.db.query(query, params, multirows=True)
return results or []
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []
@final @final
@dataclass @dataclass

View File

@@ -621,6 +621,60 @@ class PGVectorStorage(BaseVectorStorage):
logger.error(f"Error during prefix search for '{prefix}': {e}") logger.error(f"Error during prefix search for '{prefix}': {e}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
table_name = namespace_to_table_name(self.namespace)
if not table_name:
logger.error(f"Unknown namespace for ID lookup: {self.namespace}")
return None
query = f"SELECT * FROM {table_name} WHERE workspace=$1 AND id=$2"
params = {"workspace": self.db.workspace, "id": id}
try:
result = await self.db.query(query, params)
if result:
return dict(result)
return None
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
table_name = namespace_to_table_name(self.namespace)
if not table_name:
logger.error(f"Unknown namespace for IDs lookup: {self.namespace}")
return []
ids_str = ",".join([f"'{id}'" for id in ids])
query = f"SELECT * FROM {table_name} WHERE workspace=$1 AND id IN ({ids_str})"
params = {"workspace": self.db.workspace}
try:
results = await self.db.query(query, params, multirows=True)
return [dict(record) for record in results]
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []
@final @final
@dataclass @dataclass

View File

@@ -123,7 +123,9 @@ class QdrantVectorDBStorage(BaseVectorStorage):
) )
return results return results
async def query(self, query: str, top_k: int) -> list[dict[str, Any]]: async def query(
self, query: str, top_k: int, ids: list[str] | None = None
) -> list[dict[str, Any]]:
embedding = await self.embedding_func([query]) embedding = await self.embedding_func([query])
results = self._client.search( results = self._client.search(
collection_name=self.namespace, collection_name=self.namespace,

View File

@@ -306,7 +306,9 @@ class TiDBVectorDBStorage(BaseVectorStorage):
await ClientManager.release_client(self.db) await ClientManager.release_client(self.db)
self.db = None self.db = None
async def query(self, query: str, top_k: int) -> list[dict[str, Any]]: async def query(
self, query: str, top_k: int, ids: list[str] | None = None
) -> list[dict[str, Any]]:
"""Search from tidb vector""" """Search from tidb vector"""
embeddings = await self.embedding_func([query]) embeddings = await self.embedding_func([query])
embedding = embeddings[0] embedding = embeddings[0]
@@ -463,6 +465,100 @@ class TiDBVectorDBStorage(BaseVectorStorage):
logger.error(f"Error searching records with prefix '{prefix}': {e}") logger.error(f"Error searching records with prefix '{prefix}': {e}")
return [] return []
async def get_by_id(self, id: str) -> dict[str, Any] | None:
"""Get vector data by its ID
Args:
id: The unique identifier of the vector
Returns:
The vector data if found, or None if not found
"""
try:
# Determine which table to query based on namespace
if self.namespace == NameSpace.VECTOR_STORE_ENTITIES:
sql_template = """
SELECT entity_id as id, name as entity_name, entity_type, description, content
FROM LIGHTRAG_GRAPH_NODES
WHERE entity_id = :entity_id AND workspace = :workspace
"""
params = {"entity_id": id, "workspace": self.db.workspace}
elif self.namespace == NameSpace.VECTOR_STORE_RELATIONSHIPS:
sql_template = """
SELECT relation_id as id, source_name as src_id, target_name as tgt_id,
keywords, description, content
FROM LIGHTRAG_GRAPH_EDGES
WHERE relation_id = :relation_id AND workspace = :workspace
"""
params = {"relation_id": id, "workspace": self.db.workspace}
elif self.namespace == NameSpace.VECTOR_STORE_CHUNKS:
sql_template = """
SELECT chunk_id as id, content, tokens, chunk_order_index, full_doc_id
FROM LIGHTRAG_DOC_CHUNKS
WHERE chunk_id = :chunk_id AND workspace = :workspace
"""
params = {"chunk_id": id, "workspace": self.db.workspace}
else:
logger.warning(
f"Namespace {self.namespace} not supported for get_by_id"
)
return None
result = await self.db.query(sql_template, params=params)
return result
except Exception as e:
logger.error(f"Error retrieving vector data for ID {id}: {e}")
return None
async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]:
"""Get multiple vector data by their IDs
Args:
ids: List of unique identifiers
Returns:
List of vector data objects that were found
"""
if not ids:
return []
try:
# Format IDs for SQL IN clause
ids_str = ", ".join([f"'{id}'" for id in ids])
# Determine which table to query based on namespace
if self.namespace == NameSpace.VECTOR_STORE_ENTITIES:
sql_template = f"""
SELECT entity_id as id, name as entity_name, entity_type, description, content
FROM LIGHTRAG_GRAPH_NODES
WHERE entity_id IN ({ids_str}) AND workspace = :workspace
"""
elif self.namespace == NameSpace.VECTOR_STORE_RELATIONSHIPS:
sql_template = f"""
SELECT relation_id as id, source_name as src_id, target_name as tgt_id,
keywords, description, content
FROM LIGHTRAG_GRAPH_EDGES
WHERE relation_id IN ({ids_str}) AND workspace = :workspace
"""
elif self.namespace == NameSpace.VECTOR_STORE_CHUNKS:
sql_template = f"""
SELECT chunk_id as id, content, tokens, chunk_order_index, full_doc_id
FROM LIGHTRAG_DOC_CHUNKS
WHERE chunk_id IN ({ids_str}) AND workspace = :workspace
"""
else:
logger.warning(
f"Namespace {self.namespace} not supported for get_by_ids"
)
return []
params = {"workspace": self.db.workspace}
results = await self.db.query(sql_template, params=params, multirows=True)
return results if results else []
except Exception as e:
logger.error(f"Error retrieving vector data for IDs {ids}: {e}")
return []
@final @final
@dataclass @dataclass

View File

@@ -1710,19 +1710,7 @@ class LightRAG:
async def get_entity_info( async def get_entity_info(
self, entity_name: str, include_vector_data: bool = False self, entity_name: str, include_vector_data: bool = False
) -> dict[str, str | None | dict[str, str]]: ) -> dict[str, str | None | dict[str, str]]:
"""Get detailed information of an entity """Get detailed information of an entity"""
Args:
entity_name: Entity name (no need for quotes)
include_vector_data: Whether to include data from the vector database
Returns:
dict: A dictionary containing entity information, including:
- entity_name: Entity name
- source_id: Source document ID
- graph_data: Complete node data from the graph database
- vector_data: (optional) Data from the vector database
"""
# Get information from the graph # Get information from the graph
node_data = await self.chunk_entity_relation_graph.get_node(entity_name) node_data = await self.chunk_entity_relation_graph.get_node(entity_name)
@@ -1737,29 +1725,15 @@ class LightRAG:
# Optional: Get vector database information # Optional: Get vector database information
if include_vector_data: if include_vector_data:
entity_id = compute_mdhash_id(entity_name, prefix="ent-") entity_id = compute_mdhash_id(entity_name, prefix="ent-")
vector_data = self.entities_vdb._client.get([entity_id]) vector_data = await self.entities_vdb.get_by_id(entity_id)
result["vector_data"] = vector_data[0] if vector_data else None result["vector_data"] = vector_data
return result return result
async def get_relation_info( async def get_relation_info(
self, src_entity: str, tgt_entity: str, include_vector_data: bool = False self, src_entity: str, tgt_entity: str, include_vector_data: bool = False
) -> dict[str, str | None | dict[str, str]]: ) -> dict[str, str | None | dict[str, str]]:
"""Get detailed information of a relationship """Get detailed information of a relationship"""
Args:
src_entity: Source entity name (no need for quotes)
tgt_entity: Target entity name (no need for quotes)
include_vector_data: Whether to include data from the vector database
Returns:
dict: A dictionary containing relationship information, including:
- src_entity: Source entity name
- tgt_entity: Target entity name
- source_id: Source document ID
- graph_data: Complete edge data from the graph database
- vector_data: (optional) Data from the vector database
"""
# Get information from the graph # Get information from the graph
edge_data = await self.chunk_entity_relation_graph.get_edge( edge_data = await self.chunk_entity_relation_graph.get_edge(
@@ -1777,8 +1751,8 @@ class LightRAG:
# Optional: Get vector database information # Optional: Get vector database information
if include_vector_data: if include_vector_data:
rel_id = compute_mdhash_id(src_entity + tgt_entity, prefix="rel-") rel_id = compute_mdhash_id(src_entity + tgt_entity, prefix="rel-")
vector_data = self.relationships_vdb._client.get([rel_id]) vector_data = await self.relationships_vdb.get_by_id(rel_id)
result["vector_data"] = vector_data[0] if vector_data else None result["vector_data"] = vector_data
return result return result

View File

@@ -34,11 +34,13 @@
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"graphology": "^0.26.0", "graphology": "^0.26.0",
"graphology-generators": "^0.11.2", "graphology-generators": "^0.11.2",
"i18next": "^24.2.2",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"minisearch": "^7.1.2", "minisearch": "^7.1.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.6", "react-dropzone": "^14.3.6",
"react-i18next": "^15.4.1",
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-number-format": "^5.4.3", "react-number-format": "^5.4.3",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
@@ -60,6 +62,7 @@
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/react-i18next": "^8.1.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@vitejs/plugin-react-swc": "^3.8.0", "@vitejs/plugin-react-swc": "^3.8.0",
@@ -441,6 +444,8 @@
"@types/react-dom": ["@types/react-dom@19.0.4", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg=="], "@types/react-dom": ["@types/react-dom@19.0.4", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg=="],
"@types/react-i18next": ["@types/react-i18next@8.1.0", "", { "dependencies": { "react-i18next": "*" } }, "sha512-d4xhcjX5b3roNMObRNMfb1HinHQlQLPo8xlDj60dnHeeAw2bBymR2cy/l1giJpHzo/ZFgSvgVUvIWr4kCrenCg=="],
"@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="],
"@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="], "@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="],
@@ -765,8 +770,12 @@
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"i18next": ["i18next@24.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -1093,6 +1102,8 @@
"react-dropzone": ["react-dropzone@14.3.6", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-U792j+x0rcwH/U/Slv/OBNU/LGFYbDLHKKiJoPhNaOianayZevCt4Y5S0CraPssH/6/wT6xhKDfzdXUgCBS0HQ=="], "react-dropzone": ["react-dropzone@14.3.6", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-U792j+x0rcwH/U/Slv/OBNU/LGFYbDLHKKiJoPhNaOianayZevCt4Y5S0CraPssH/6/wT6xhKDfzdXUgCBS0HQ=="],
"react-i18next": ["react-i18next@15.4.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="], "react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="],
@@ -1271,6 +1282,8 @@
"vite": ["vite@6.1.1", "", { "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.5.2", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA=="], "vite": ["vite@6.1.1", "", { "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.5.2", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],

View File

@@ -43,11 +43,13 @@
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"graphology": "^0.26.0", "graphology": "^0.26.0",
"graphology-generators": "^0.11.2", "graphology-generators": "^0.11.2",
"i18next": "^24.2.2",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"minisearch": "^7.1.2", "minisearch": "^7.1.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.6", "react-dropzone": "^14.3.6",
"react-i18next": "^15.4.1",
"react-markdown": "^9.1.0", "react-markdown": "^9.1.0",
"react-number-format": "^5.4.3", "react-number-format": "^5.4.3",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
@@ -69,6 +71,7 @@
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/react-i18next": "^8.1.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@vitejs/plugin-react-swc": "^3.8.0", "@vitejs/plugin-react-swc": "^3.8.0",

View File

@@ -0,0 +1,66 @@
import { useState, useCallback } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import Button from '@/components/ui/Button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'
import { useSettingsStore } from '@/stores/settings'
import { PaletteIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function AppSettings() {
const [opened, setOpened] = useState<boolean>(false)
const { t } = useTranslation()
const language = useSettingsStore.use.language()
const setLanguage = useSettingsStore.use.setLanguage()
const theme = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
const handleLanguageChange = useCallback((value: string) => {
setLanguage(value as 'en' | 'zh')
}, [setLanguage])
const handleThemeChange = useCallback((value: string) => {
setTheme(value as 'light' | 'dark' | 'system')
}, [setTheme])
return (
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<PaletteIcon className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.language')}</label>
<Select value={language} onValueChange={handleLanguageChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.theme')}</label>
<Select value={theme} onValueChange={handleThemeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">{t('settings.light')}</SelectItem>
<SelectItem value="dark">{t('settings.dark')}</SelectItem>
<SelectItem value="system">{t('settings.system')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,24 @@
import { StrictMode, useEffect, useState } from 'react'
import { initializeI18n } from '@/i18n'
import App from '@/App'
export const Root = () => {
const [isI18nInitialized, setIsI18nInitialized] = useState(false)
useEffect(() => {
// Initialize i18n immediately with persisted language
initializeI18n().then(() => {
setIsI18nInitialized(true)
})
}, [])
if (!isI18nInitialized) {
return null // or a loading spinner
}
return (
<StrictMode>
<App />
</StrictMode>
)
}

View File

@@ -1,4 +1,4 @@
import { createContext, useEffect, useState } from 'react' import { createContext, useEffect } from 'react'
import { Theme, useSettingsStore } from '@/stores/settings' import { Theme, useSettingsStore } from '@/stores/settings'
type ThemeProviderProps = { type ThemeProviderProps = {
@@ -21,30 +21,32 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
* Component that provides the theme state and setter function to its children. * Component that provides the theme state and setter function to its children.
*/ */
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) { export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(useSettingsStore.getState().theme) const theme = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
useEffect(() => { useEffect(() => {
const root = window.document.documentElement const root = window.document.documentElement
root.classList.remove('light', 'dark') root.classList.remove('light', 'dark')
if (theme === 'system') { if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
? 'dark' const handleChange = (e: MediaQueryListEvent) => {
: 'light' root.classList.remove('light', 'dark')
root.classList.add(systemTheme) root.classList.add(e.matches ? 'dark' : 'light')
setTheme(systemTheme) }
return
root.classList.add(mediaQuery.matches ? 'dark' : 'light')
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
} else {
root.classList.add(theme)
} }
root.classList.add(theme)
}, [theme]) }, [theme])
const value = { const value = {
theme, theme,
setTheme: (theme: Theme) => { setTheme
useSettingsStore.getState().setTheme(theme)
setTheme(theme)
}
} }
return ( return (

View File

@@ -3,6 +3,7 @@ import useTheme from '@/hooks/useTheme'
import { MoonIcon, SunIcon } from 'lucide-react' import { MoonIcon, SunIcon } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from 'react-i18next'
/** /**
* Component that toggles the theme between light and dark. * Component that toggles the theme between light and dark.
@@ -11,13 +12,14 @@ export default function ThemeToggle() {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const setLight = useCallback(() => setTheme('light'), [setTheme]) const setLight = useCallback(() => setTheme('light'), [setTheme])
const setDark = useCallback(() => setTheme('dark'), [setTheme]) const setDark = useCallback(() => setTheme('dark'), [setTheme])
const { t } = useTranslation()
if (theme === 'dark') { if (theme === 'dark') {
return ( return (
<Button <Button
onClick={setLight} onClick={setLight}
variant={controlButtonVariant} variant={controlButtonVariant}
tooltip="Switch to light theme" tooltip={t('header.themeToggle.switchToLight')}
size="icon" size="icon"
side="bottom" side="bottom"
> >
@@ -29,7 +31,7 @@ export default function ThemeToggle() {
<Button <Button
onClick={setDark} onClick={setDark}
variant={controlButtonVariant} variant={controlButtonVariant}
tooltip="Switch to dark theme" tooltip={t('header.themeToggle.switchToDark')}
size="icon" size="icon"
side="bottom" side="bottom"
> >

View File

@@ -13,38 +13,40 @@ import { errorMessage } from '@/lib/utils'
import { clearDocuments } from '@/api/lightrag' import { clearDocuments } from '@/api/lightrag'
import { EraserIcon } from 'lucide-react' import { EraserIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function ClearDocumentsDialog() { export default function ClearDocumentsDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const handleClear = useCallback(async () => { const handleClear = useCallback(async () => {
try { try {
const result = await clearDocuments() const result = await clearDocuments()
if (result.status === 'success') { if (result.status === 'success') {
toast.success('Documents cleared successfully') toast.success(t('documentPanel.clearDocuments.success'))
setOpen(false) setOpen(false)
} else { } else {
toast.error(`Clear Documents Failed:\n${result.message}`) toast.error(t('documentPanel.clearDocuments.failed', { message: result.message }))
} }
} catch (err) { } catch (err) {
toast.error('Clear Documents Failed:\n' + errorMessage(err)) toast.error(t('documentPanel.clearDocuments.error', { error: errorMessage(err) }))
} }
}, [setOpen]) }, [setOpen])
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" side="bottom" tooltip='Clear documents' size="sm"> <Button variant="outline" side="bottom" tooltip={t('documentPanel.clearDocuments.tooltip')} size="sm">
<EraserIcon/> Clear <EraserIcon/> {t('documentPanel.clearDocuments.button')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}> <DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Clear documents</DialogTitle> <DialogTitle>{t('documentPanel.clearDocuments.title')}</DialogTitle>
<DialogDescription>Do you really want to clear all documents?</DialogDescription> <DialogDescription>{t('documentPanel.clearDocuments.confirm')}</DialogDescription>
</DialogHeader> </DialogHeader>
<Button variant="destructive" onClick={handleClear}> <Button variant="destructive" onClick={handleClear}>
YES {t('documentPanel.clearDocuments.confirmButton')}
</Button> </Button>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -14,8 +14,10 @@ import { errorMessage } from '@/lib/utils'
import { uploadDocument } from '@/api/lightrag' import { uploadDocument } from '@/api/lightrag'
import { UploadIcon } from 'lucide-react' import { UploadIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function UploadDocumentsDialog() { export default function UploadDocumentsDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [progresses, setProgresses] = useState<Record<string, number>>({}) const [progresses, setProgresses] = useState<Record<string, number>>({})
@@ -29,24 +31,24 @@ export default function UploadDocumentsDialog() {
filesToUpload.map(async (file) => { filesToUpload.map(async (file) => {
try { try {
const result = await uploadDocument(file, (percentCompleted: number) => { const result = await uploadDocument(file, (percentCompleted: number) => {
console.debug(`Uploading ${file.name}: ${percentCompleted}%`) console.debug(t('documentPanel.uploadDocuments.uploading', { name: file.name, percent: percentCompleted }))
setProgresses((pre) => ({ setProgresses((pre) => ({
...pre, ...pre,
[file.name]: percentCompleted [file.name]: percentCompleted
})) }))
}) })
if (result.status === 'success') { if (result.status === 'success') {
toast.success(`Upload Success:\n${file.name} uploaded successfully`) toast.success(t('documentPanel.uploadDocuments.success', { name: file.name }))
} else { } else {
toast.error(`Upload Failed:\n${file.name}\n${result.message}`) toast.error(t('documentPanel.uploadDocuments.failed', { name: file.name, message: result.message }))
} }
} catch (err) { } catch (err) {
toast.error(`Upload Failed:\n${file.name}\n${errorMessage(err)}`) toast.error(t('documentPanel.uploadDocuments.error', { name: file.name, error: errorMessage(err) }))
} }
}) })
) )
} catch (err) { } catch (err) {
toast.error('Upload Failed\n' + errorMessage(err)) toast.error(t('documentPanel.uploadDocuments.generalError', { error: errorMessage(err) }))
} finally { } finally {
setIsUploading(false) setIsUploading(false)
// setOpen(false) // setOpen(false)
@@ -66,21 +68,21 @@ export default function UploadDocumentsDialog() {
}} }}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="default" side="bottom" tooltip="Upload documents" size="sm"> <Button variant="default" side="bottom" tooltip={t('documentPanel.uploadDocuments.tooltip')} size="sm">
<UploadIcon /> Upload <UploadIcon /> {t('documentPanel.uploadDocuments.button')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}> <DialogContent className="sm:max-w-xl" onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Upload documents</DialogTitle> <DialogTitle>{t('documentPanel.uploadDocuments.title')}</DialogTitle>
<DialogDescription> <DialogDescription>
Drag and drop your documents here or click to browse. {t('documentPanel.uploadDocuments.description')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<FileUploader <FileUploader
maxFileCount={Infinity} maxFileCount={Infinity}
maxSize={200 * 1024 * 1024} maxSize={200 * 1024 * 1024}
description="supported types: TXT, MD, DOCX, PDF, PPTX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS" description={t('documentPanel.uploadDocuments.fileTypes')}
onUpload={handleDocumentsUpload} onUpload={handleDocumentsUpload}
progresses={progresses} progresses={progresses}
disabled={isUploading} disabled={isUploading}

View File

@@ -2,21 +2,23 @@ import { useFullScreen } from '@react-sigma/core'
import { MaximizeIcon, MinimizeIcon } from 'lucide-react' import { MaximizeIcon, MinimizeIcon } from 'lucide-react'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { useTranslation } from 'react-i18next'
/** /**
* Component that toggles full screen mode. * Component that toggles full screen mode.
*/ */
const FullScreenControl = () => { const FullScreenControl = () => {
const { isFullScreen, toggle } = useFullScreen() const { isFullScreen, toggle } = useFullScreen()
const { t } = useTranslation()
return ( return (
<> <>
{isFullScreen ? ( {isFullScreen ? (
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Windowed" size="icon"> <Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.windowed')} size="icon">
<MinimizeIcon /> <MinimizeIcon />
</Button> </Button>
) : ( ) : (
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Full Screen" size="icon"> <Button variant={controlButtonVariant} onClick={toggle} tooltip={t('graphPanel.sideBar.fullScreenControl.fullScreen')} size="icon">
<MaximizeIcon /> <MaximizeIcon />
</Button> </Button>
)} )}

View File

@@ -4,8 +4,10 @@ import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import { labelListLimit } from '@/lib/constants' import { labelListLimit } from '@/lib/constants'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next'
const GraphLabels = () => { const GraphLabels = () => {
const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel() const label = useSettingsStore.use.queryLabel()
const graphLabels = useGraphStore.use.graphLabels() const graphLabels = useGraphStore.use.graphLabels()
@@ -45,7 +47,7 @@ const GraphLabels = () => {
return result.length <= labelListLimit return result.length <= labelListLimit
? result ? result
: [...result.slice(0, labelListLimit), `And ${result.length - labelListLimit} others`] : [...result.slice(0, labelListLimit), t('graphLabels.andOthers', { count: result.length - labelListLimit })]
}, },
[getSearchEngine] [getSearchEngine]
) )
@@ -68,14 +70,14 @@ const GraphLabels = () => {
className="ml-2" className="ml-2"
triggerClassName="max-h-8" triggerClassName="max-h-8"
searchInputClassName="max-h-8" searchInputClassName="max-h-8"
triggerTooltip="Select query label" triggerTooltip={t('graphPanel.graphLabels.selectTooltip')}
fetcher={fetchData} fetcher={fetchData}
renderOption={(item) => <div>{item}</div>} renderOption={(item) => <div>{item}</div>}
getOptionValue={(item) => item} getOptionValue={(item) => item}
getDisplayValue={(item) => <div>{item}</div>} getDisplayValue={(item) => <div>{item}</div>}
notFound={<div className="py-6 text-center text-sm">No labels found</div>} notFound={<div className="py-6 text-center text-sm">No labels found</div>}
label="Label" label={t('graphPanel.graphLabels.label')}
placeholder="Search labels..." placeholder={t('graphPanel.graphLabels.placeholder')}
value={label !== null ? label : ''} value={label !== null ? label : ''}
onChange={setQueryLabel} onChange={setQueryLabel}
clearable={false} // Prevent clearing value on reselect clearable={false} // Prevent clearing value on reselect

View File

@@ -9,6 +9,7 @@ import { AsyncSearch } from '@/components/ui/AsyncSearch'
import { searchResultLimit } from '@/lib/constants' import { searchResultLimit } from '@/lib/constants'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next'
interface OptionItem { interface OptionItem {
id: string id: string
@@ -44,6 +45,7 @@ export const GraphSearchInput = ({
onFocus?: GraphSearchInputProps['onFocus'] onFocus?: GraphSearchInputProps['onFocus']
value?: GraphSearchInputProps['value'] value?: GraphSearchInputProps['value']
}) => { }) => {
const { t } = useTranslation()
const graph = useGraphStore.use.sigmaGraph() const graph = useGraphStore.use.sigmaGraph()
const searchEngine = useMemo(() => { const searchEngine = useMemo(() => {
@@ -97,7 +99,7 @@ export const GraphSearchInput = ({
{ {
type: 'message', type: 'message',
id: messageId, id: messageId,
message: `And ${result.length - searchResultLimit} others` message: t('graphPanel.search.message', { count: result.length - searchResultLimit })
} }
] ]
}, },
@@ -118,7 +120,7 @@ export const GraphSearchInput = ({
if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null) if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null)
}} }}
label={'item'} label={'item'}
placeholder="Search nodes..." placeholder={t('graphPanel.search.placeholder')}
/> />
) )
} }

View File

@@ -16,6 +16,7 @@ import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react' import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
type LayoutName = type LayoutName =
| 'Circular' | 'Circular'
@@ -28,6 +29,7 @@ type LayoutName =
const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => { const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => {
const sigma = useSigma() const sigma = useSigma()
const { stop, start, isRunning } = layout const { stop, start, isRunning } = layout
const { t } = useTranslation()
/** /**
* Init component when Sigma or component settings change. * Init component when Sigma or component settings change.
@@ -61,7 +63,7 @@ const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) =
<Button <Button
size="icon" size="icon"
onClick={() => (isRunning ? stop() : start())} onClick={() => (isRunning ? stop() : start())}
tooltip={isRunning ? 'Stop the layout animation' : 'Start the layout animation'} tooltip={isRunning ? t('graphPanel.sideBar.layoutsControl.stopAnimation') : t('graphPanel.sideBar.layoutsControl.startAnimation')}
variant={controlButtonVariant} variant={controlButtonVariant}
> >
{isRunning ? <PauseIcon /> : <PlayIcon />} {isRunning ? <PauseIcon /> : <PlayIcon />}
@@ -74,6 +76,7 @@ const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) =
*/ */
const LayoutsControl = () => { const LayoutsControl = () => {
const sigma = useSigma() const sigma = useSigma()
const { t } = useTranslation()
const [layout, setLayout] = useState<LayoutName>('Circular') const [layout, setLayout] = useState<LayoutName>('Circular')
const [opened, setOpened] = useState<boolean>(false) const [opened, setOpened] = useState<boolean>(false)
@@ -149,7 +152,7 @@ const LayoutsControl = () => {
size="icon" size="icon"
variant={controlButtonVariant} variant={controlButtonVariant}
onClick={() => setOpened((e: boolean) => !e)} onClick={() => setOpened((e: boolean) => !e)}
tooltip="Layout Graph" tooltip={t('graphPanel.sideBar.layoutsControl.layoutGraph')}
> >
<GripIcon /> <GripIcon />
</Button> </Button>
@@ -166,7 +169,7 @@ const LayoutsControl = () => {
key={name} key={name}
className="cursor-pointer text-xs" className="cursor-pointer text-xs"
> >
{name} {t(`graphPanel.sideBar.layoutsControl.layouts.${name}`)}
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph' import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
import Text from '@/components/ui/Text' import Text from '@/components/ui/Text'
import useLightragGraph from '@/hooks/useLightragGraph' import useLightragGraph from '@/hooks/useLightragGraph'
import { useTranslation } from 'react-i18next'
/** /**
* Component that view properties of elements in graph. * Component that view properties of elements in graph.
@@ -147,21 +148,22 @@ const PropertyRow = ({
} }
const NodePropertiesView = ({ node }: { node: NodeType }) => { const NodePropertiesView = ({ node }: { node: NodeType }) => {
const { t } = useTranslation()
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-md pl-1 font-bold tracking-wide text-sky-300">Node</label> <label className="text-md pl-1 font-bold tracking-wide text-sky-300">{t('graphPanel.propertiesView.node.title')}</label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={'Id'} value={node.id} /> <PropertyRow name={t('graphPanel.propertiesView.node.id')} value={node.id} />
<PropertyRow <PropertyRow
name={'Labels'} name={t('graphPanel.propertiesView.node.labels')}
value={node.labels.join(', ')} value={node.labels.join(', ')}
onClick={() => { onClick={() => {
useGraphStore.getState().setSelectedNode(node.id, true) useGraphStore.getState().setSelectedNode(node.id, true)
}} }}
/> />
<PropertyRow name={'Degree'} value={node.degree} /> <PropertyRow name={t('graphPanel.propertiesView.node.degree')} value={node.degree} />
</div> </div>
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">Properties</label> <label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">{t('graphPanel.propertiesView.node.properties')}</label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(node.properties) {Object.keys(node.properties)
.sort() .sort()
@@ -172,7 +174,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
{node.relationships.length > 0 && ( {node.relationships.length > 0 && (
<> <>
<label className="text-md pl-1 font-bold tracking-wide text-teal-600/90"> <label className="text-md pl-1 font-bold tracking-wide text-teal-600/90">
Relationships {t('graphPanel.propertiesView.node.relationships')}
</label> </label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{node.relationships.map(({ type, id, label }) => { {node.relationships.map(({ type, id, label }) => {
@@ -195,28 +197,29 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
} }
const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => { const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
const { t } = useTranslation()
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-md pl-1 font-bold tracking-wide text-teal-600">Relationship</label> <label className="text-md pl-1 font-bold tracking-wide text-teal-600">{t('graphPanel.propertiesView.edge.title')}</label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
<PropertyRow name={'Id'} value={edge.id} /> <PropertyRow name={t('graphPanel.propertiesView.edge.id')} value={edge.id} />
{edge.type && <PropertyRow name={'Type'} value={edge.type} />} {edge.type && <PropertyRow name={t('graphPanel.propertiesView.edge.type')} value={edge.type} />}
<PropertyRow <PropertyRow
name={'Source'} name={t('graphPanel.propertiesView.edge.source')}
value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source} value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source}
onClick={() => { onClick={() => {
useGraphStore.getState().setSelectedNode(edge.source, true) useGraphStore.getState().setSelectedNode(edge.source, true)
}} }}
/> />
<PropertyRow <PropertyRow
name={'Target'} name={t('graphPanel.propertiesView.edge.target')}
value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target} value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target}
onClick={() => { onClick={() => {
useGraphStore.getState().setSelectedNode(edge.target, true) useGraphStore.getState().setSelectedNode(edge.target, true)
}} }}
/> />
</div> </div>
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">Properties</label> <label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">{t('graphPanel.propertiesView.edge.properties')}</label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1"> <div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{Object.keys(edge.properties) {Object.keys(edge.properties)
.sort() .sort()

View File

@@ -10,6 +10,7 @@ import { useSettingsStore } from '@/stores/settings'
import { useBackendState } from '@/stores/state' import { useBackendState } from '@/stores/state'
import { SettingsIcon, RefreshCwIcon } from 'lucide-react' import { SettingsIcon, RefreshCwIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next';
/** /**
* Component that displays a checkbox with a label. * Component that displays a checkbox with a label.
@@ -205,11 +206,13 @@ export default function Settings() {
[setTempApiKey] [setTempApiKey]
) )
const { t } = useTranslation();
return ( return (
<> <>
<Button <Button
variant={controlButtonVariant} variant={controlButtonVariant}
tooltip="Refresh Layout" tooltip={t('graphPanel.sideBar.settings.refreshLayout')}
size="icon" size="icon"
onClick={refreshLayout} onClick={refreshLayout}
> >
@@ -217,7 +220,7 @@ export default function Settings() {
</Button> </Button>
<Popover open={opened} onOpenChange={setOpened}> <Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant={controlButtonVariant} tooltip="Settings" size="icon"> <Button variant={controlButtonVariant} tooltip={t('graphPanel.sideBar.settings.settings')} size="icon">
<SettingsIcon /> <SettingsIcon />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -231,7 +234,7 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={enableHealthCheck} checked={enableHealthCheck}
onCheckedChange={setEnableHealthCheck} onCheckedChange={setEnableHealthCheck}
label="Health Check" label={t('graphPanel.sideBar.settings.healthCheck')}
/> />
<Separator /> <Separator />
@@ -239,12 +242,12 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={showPropertyPanel} checked={showPropertyPanel}
onCheckedChange={setShowPropertyPanel} onCheckedChange={setShowPropertyPanel}
label="Show Property Panel" label={t('graphPanel.sideBar.settings.showPropertyPanel')}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={showNodeSearchBar} checked={showNodeSearchBar}
onCheckedChange={setShowNodeSearchBar} onCheckedChange={setShowNodeSearchBar}
label="Show Search Bar" label={t('graphPanel.sideBar.settings.showSearchBar')}
/> />
<Separator /> <Separator />
@@ -252,12 +255,12 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={showNodeLabel} checked={showNodeLabel}
onCheckedChange={setShowNodeLabel} onCheckedChange={setShowNodeLabel}
label="Show Node Label" label={t('graphPanel.sideBar.settings.showNodeLabel')}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={enableNodeDrag} checked={enableNodeDrag}
onCheckedChange={setEnableNodeDrag} onCheckedChange={setEnableNodeDrag}
label="Node Draggable" label={t('graphPanel.sideBar.settings.nodeDraggable')}
/> />
<Separator /> <Separator />
@@ -265,51 +268,50 @@ export default function Settings() {
<LabeledCheckBox <LabeledCheckBox
checked={showEdgeLabel} checked={showEdgeLabel}
onCheckedChange={setShowEdgeLabel} onCheckedChange={setShowEdgeLabel}
label="Show Edge Label" label={t('graphPanel.sideBar.settings.showEdgeLabel')}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={enableHideUnselectedEdges} checked={enableHideUnselectedEdges}
onCheckedChange={setEnableHideUnselectedEdges} onCheckedChange={setEnableHideUnselectedEdges}
label="Hide Unselected Edges" label={t('graphPanel.sideBar.settings.hideUnselectedEdges')}
/> />
<LabeledCheckBox <LabeledCheckBox
checked={enableEdgeEvents} checked={enableEdgeEvents}
onCheckedChange={setEnableEdgeEvents} onCheckedChange={setEnableEdgeEvents}
label="Edge Events" label={t('graphPanel.sideBar.settings.edgeEvents')}
/> />
<Separator /> <Separator />
<LabeledNumberInput <LabeledNumberInput
label="Max Query Depth" label={t('graphPanel.sideBar.settings.maxQueryDepth')}
min={1} min={1}
value={graphQueryMaxDepth} value={graphQueryMaxDepth}
onEditFinished={setGraphQueryMaxDepth} onEditFinished={setGraphQueryMaxDepth}
/> />
<LabeledNumberInput <LabeledNumberInput
label="Minimum Degree" label={t('graphPanel.sideBar.settings.minDegree')}
min={0} min={0}
value={graphMinDegree} value={graphMinDegree}
onEditFinished={setGraphMinDegree} onEditFinished={setGraphMinDegree}
/> />
<LabeledNumberInput <LabeledNumberInput
label="Max Layout Iterations" label={t('graphPanel.sideBar.settings.maxLayoutIterations')}
min={1} min={1}
max={30} max={30}
value={graphLayoutMaxIterations} value={graphLayoutMaxIterations}
onEditFinished={setGraphLayoutMaxIterations} onEditFinished={setGraphLayoutMaxIterations}
/> />
<Separator /> <Separator />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium">API Key</label> <label className="text-sm font-medium">{t('graphPanel.sideBar.settings.apiKey')}</label>
<form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}> <form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}>
<div className="w-0 flex-1"> <div className="w-0 flex-1">
<Input <Input
type="password" type="password"
value={tempApiKey} value={tempApiKey}
onChange={handleTempApiKeyChange} onChange={handleTempApiKeyChange}
placeholder="Enter your API key" placeholder={t('graphPanel.sideBar.settings.enterYourAPIkey')}
className="max-h-full w-full min-w-0" className="max-h-full w-full min-w-0"
autoComplete="off" autoComplete="off"
/> />
@@ -320,7 +322,7 @@ export default function Settings() {
size="sm" size="sm"
className="max-h-full shrink-0" className="max-h-full shrink-0"
> >
Save {t('graphPanel.sideBar.settings.save')}
</Button> </Button>
</form> </form>
</div> </div>

View File

@@ -1,17 +1,19 @@
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
/** /**
* Component that displays current values of important graph settings * Component that displays current values of important graph settings
* Positioned to the right of the toolbar at the bottom-left corner * Positioned to the right of the toolbar at the bottom-left corner
*/ */
const SettingsDisplay = () => { const SettingsDisplay = () => {
const { t } = useTranslation()
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth() const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
const graphMinDegree = useSettingsStore.use.graphMinDegree() const graphMinDegree = useSettingsStore.use.graphMinDegree()
return ( return (
<div className="absolute bottom-2 left-[calc(2rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400"> <div className="absolute bottom-2 left-[calc(2rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
<div>Depth: {graphQueryMaxDepth}</div> <div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
<div>Degree: {graphMinDegree}</div> <div>{t('graphPanel.sideBar.settings.degree')}: {graphMinDegree}</div>
</div> </div>
) )
} }

View File

@@ -1,58 +1,60 @@
import { LightragStatus } from '@/api/lightrag' import { LightragStatus } from '@/api/lightrag'
import { useTranslation } from 'react-i18next'
const StatusCard = ({ status }: { status: LightragStatus | null }) => { const StatusCard = ({ status }: { status: LightragStatus | null }) => {
const { t } = useTranslation()
if (!status) { if (!status) {
return <div className="text-muted-foreground text-sm">Status information unavailable</div> return <div className="text-muted-foreground text-sm">{t('graphPanel.statusCard.unavailable')}</div>
} }
return ( return (
<div className="min-w-[300px] space-y-3 text-sm"> <div className="min-w-[300px] space-y-3 text-sm">
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-medium">Storage Info</h4> <h4 className="font-medium">{t('graphPanel.statusCard.storageInfo')}</h4>
<div className="text-muted-foreground grid grid-cols-2 gap-1"> <div className="text-muted-foreground grid grid-cols-2 gap-1">
<span>Working Directory:</span> <span>{t('graphPanel.statusCard.workingDirectory')}:</span>
<span className="truncate">{status.working_directory}</span> <span className="truncate">{status.working_directory}</span>
<span>Input Directory:</span> <span>{t('graphPanel.statusCard.inputDirectory')}:</span>
<span className="truncate">{status.input_directory}</span> <span className="truncate">{status.input_directory}</span>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-medium">LLM Configuration</h4> <h4 className="font-medium">{t('graphPanel.statusCard.llmConfig')}</h4>
<div className="text-muted-foreground grid grid-cols-2 gap-1"> <div className="text-muted-foreground grid grid-cols-2 gap-1">
<span>LLM Binding:</span> <span>{t('graphPanel.statusCard.llmBinding')}:</span>
<span>{status.configuration.llm_binding}</span> <span>{status.configuration.llm_binding}</span>
<span>LLM Binding Host:</span> <span>{t('graphPanel.statusCard.llmBindingHost')}:</span>
<span>{status.configuration.llm_binding_host}</span> <span>{status.configuration.llm_binding_host}</span>
<span>LLM Model:</span> <span>{t('graphPanel.statusCard.llmModel')}:</span>
<span>{status.configuration.llm_model}</span> <span>{status.configuration.llm_model}</span>
<span>Max Tokens:</span> <span>{t('graphPanel.statusCard.maxTokens')}:</span>
<span>{status.configuration.max_tokens}</span> <span>{status.configuration.max_tokens}</span>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-medium">Embedding Configuration</h4> <h4 className="font-medium">{t('graphPanel.statusCard.embeddingConfig')}</h4>
<div className="text-muted-foreground grid grid-cols-2 gap-1"> <div className="text-muted-foreground grid grid-cols-2 gap-1">
<span>Embedding Binding:</span> <span>{t('graphPanel.statusCard.embeddingBinding')}:</span>
<span>{status.configuration.embedding_binding}</span> <span>{status.configuration.embedding_binding}</span>
<span>Embedding Binding Host:</span> <span>{t('graphPanel.statusCard.embeddingBindingHost')}:</span>
<span>{status.configuration.embedding_binding_host}</span> <span>{status.configuration.embedding_binding_host}</span>
<span>Embedding Model:</span> <span>{t('graphPanel.statusCard.embeddingModel')}:</span>
<span>{status.configuration.embedding_model}</span> <span>{status.configuration.embedding_model}</span>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-medium">Storage Configuration</h4> <h4 className="font-medium">{t('graphPanel.statusCard.storageConfig')}</h4>
<div className="text-muted-foreground grid grid-cols-2 gap-1"> <div className="text-muted-foreground grid grid-cols-2 gap-1">
<span>KV Storage:</span> <span>{t('graphPanel.statusCard.kvStorage')}:</span>
<span>{status.configuration.kv_storage}</span> <span>{status.configuration.kv_storage}</span>
<span>Doc Status Storage:</span> <span>{t('graphPanel.statusCard.docStatusStorage')}:</span>
<span>{status.configuration.doc_status_storage}</span> <span>{status.configuration.doc_status_storage}</span>
<span>Graph Storage:</span> <span>{t('graphPanel.statusCard.graphStorage')}:</span>
<span>{status.configuration.graph_storage}</span> <span>{status.configuration.graph_storage}</span>
<span>Vector Storage:</span> <span>{t('graphPanel.statusCard.vectorStorage')}:</span>
<span>{status.configuration.vector_storage}</span> <span>{status.configuration.vector_storage}</span>
</div> </div>
</div> </div>

View File

@@ -3,8 +3,10 @@ import { useBackendState } from '@/stores/state'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import StatusCard from '@/components/graph/StatusCard' import StatusCard from '@/components/graph/StatusCard'
import { useTranslation } from 'react-i18next'
const StatusIndicator = () => { const StatusIndicator = () => {
const { t } = useTranslation()
const health = useBackendState.use.health() const health = useBackendState.use.health()
const lastCheckTime = useBackendState.use.lastCheckTime() const lastCheckTime = useBackendState.use.lastCheckTime()
const status = useBackendState.use.status() const status = useBackendState.use.status()
@@ -33,7 +35,7 @@ const StatusIndicator = () => {
)} )}
/> />
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{health ? 'Connected' : 'Disconnected'} {health ? t('graphPanel.statusIndicator.connected') : t('graphPanel.statusIndicator.disconnected')}
</span> </span>
</div> </div>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -3,12 +3,14 @@ import { useCallback } from 'react'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react' import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react'
import { controlButtonVariant } from '@/lib/constants' import { controlButtonVariant } from '@/lib/constants'
import { useTranslation } from "react-i18next";
/** /**
* Component that provides zoom controls for the graph viewer. * Component that provides zoom controls for the graph viewer.
*/ */
const ZoomControl = () => { const ZoomControl = () => {
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 }) const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
const { t } = useTranslation();
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn]) const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut]) const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
@@ -16,16 +18,16 @@ const ZoomControl = () => {
return ( return (
<> <>
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip="Zoom In" size="icon"> <Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip={t("graphPanel.sideBar.zoomControl.zoomIn")} size="icon">
<ZoomInIcon /> <ZoomInIcon />
</Button> </Button>
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip="Zoom Out" size="icon"> <Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip={t("graphPanel.sideBar.zoomControl.zoomOut")} size="icon">
<ZoomOutIcon /> <ZoomOutIcon />
</Button> </Button>
<Button <Button
variant={controlButtonVariant} variant={controlButtonVariant}
onClick={handleResetZoom} onClick={handleResetZoom}
tooltip="Reset Zoom" tooltip={t("graphPanel.sideBar.zoomControl.resetZoom")}
size="icon" size="icon"
> >
<FullscreenIcon /> <FullscreenIcon />

View File

@@ -15,18 +15,21 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism' import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'
import { LoaderIcon, CopyIcon } from 'lucide-react' import { LoaderIcon, CopyIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export type MessageWithError = Message & { export type MessageWithError = Message & {
isError?: boolean isError?: boolean
} }
export const ChatMessage = ({ message }: { message: MessageWithError }) => { export const ChatMessage = ({ message }: { message: MessageWithError }) => {
const { t } = useTranslation()
const handleCopyMarkdown = useCallback(async () => { const handleCopyMarkdown = useCallback(async () => {
if (message.content) { if (message.content) {
try { try {
await navigator.clipboard.writeText(message.content) await navigator.clipboard.writeText(message.content)
} catch (err) { } catch (err) {
console.error('Failed to copy:', err) console.error(t('chat.copyError'), err)
} }
} }
}, [message]) }, [message])
@@ -57,7 +60,7 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => {
<Button <Button
onClick={handleCopyMarkdown} onClick={handleCopyMarkdown}
className="absolute right-0 bottom-0 size-6 rounded-md opacity-20 transition-opacity hover:opacity-100" className="absolute right-0 bottom-0 size-6 rounded-md opacity-20 transition-opacity hover:opacity-100"
tooltip="Copy to clipboard" tooltip={t('retrievePanel.chatMessage.copyTooltip')}
variant="default" variant="default"
size="icon" size="icon"
> >

View File

@@ -14,8 +14,10 @@ import {
SelectValue SelectValue
} from '@/components/ui/Select' } from '@/components/ui/Select'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
export default function QuerySettings() { export default function QuerySettings() {
const { t } = useTranslation()
const querySettings = useSettingsStore((state) => state.querySettings) const querySettings = useSettingsStore((state) => state.querySettings)
const handleChange = useCallback((key: keyof QueryRequest, value: any) => { const handleChange = useCallback((key: keyof QueryRequest, value: any) => {
@@ -25,8 +27,8 @@ export default function QuerySettings() {
return ( return (
<Card className="flex shrink-0 flex-col"> <Card className="flex shrink-0 flex-col">
<CardHeader className="px-4 pt-4 pb-2"> <CardHeader className="px-4 pt-4 pb-2">
<CardTitle>Parameters</CardTitle> <CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle>
<CardDescription>Configure your query parameters</CardDescription> <CardDescription>{t('retrievePanel.querySettings.parametersDescription')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="m-0 flex grow flex-col p-0 text-xs"> <CardContent className="m-0 flex grow flex-col p-0 text-xs">
<div className="relative size-full"> <div className="relative size-full">
@@ -35,8 +37,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Query Mode" text={t('retrievePanel.querySettings.queryMode')}
tooltip="Select the retrieval strategy:\n• Naive: Basic search without advanced techniques\n• Local: Context-dependent information retrieval\n• Global: Utilizes global knowledge base\n• Hybrid: Combines local and global retrieval\n• Mix: Integrates knowledge graph with vector retrieval" tooltip={t('retrievePanel.querySettings.queryModeTooltip')}
side="left" side="left"
/> />
<Select <Select
@@ -48,11 +50,11 @@ export default function QuerySettings() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="naive">Naive</SelectItem> <SelectItem value="naive">{t('retrievePanel.querySettings.queryModeOptions.naive')}</SelectItem>
<SelectItem value="local">Local</SelectItem> <SelectItem value="local">{t('retrievePanel.querySettings.queryModeOptions.local')}</SelectItem>
<SelectItem value="global">Global</SelectItem> <SelectItem value="global">{t('retrievePanel.querySettings.queryModeOptions.global')}</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem> <SelectItem value="hybrid">{t('retrievePanel.querySettings.queryModeOptions.hybrid')}</SelectItem>
<SelectItem value="mix">Mix</SelectItem> <SelectItem value="mix">{t('retrievePanel.querySettings.queryModeOptions.mix')}</SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -62,8 +64,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Response Format" text={t('retrievePanel.querySettings.responseFormat')}
tooltip="Defines the response format. Examples:\n• Multiple Paragraphs\n• Single Paragraph\n• Bullet Points" tooltip={t('retrievePanel.querySettings.responseFormatTooltip')}
side="left" side="left"
/> />
<Select <Select
@@ -75,9 +77,9 @@ export default function QuerySettings() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="Multiple Paragraphs">Multiple Paragraphs</SelectItem> <SelectItem value="Multiple Paragraphs">{t('retrievePanel.querySettings.responseFormatOptions.multipleParagraphs')}</SelectItem>
<SelectItem value="Single Paragraph">Single Paragraph</SelectItem> <SelectItem value="Single Paragraph">{t('retrievePanel.querySettings.responseFormatOptions.singleParagraph')}</SelectItem>
<SelectItem value="Bullet Points">Bullet Points</SelectItem> <SelectItem value="Bullet Points">{t('retrievePanel.querySettings.responseFormatOptions.bulletPoints')}</SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -87,8 +89,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Top K Results" text={t('retrievePanel.querySettings.topK')}
tooltip="Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode" tooltip={t('retrievePanel.querySettings.topKTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -97,7 +99,7 @@ export default function QuerySettings() {
value={querySettings.top_k} value={querySettings.top_k}
onValueChange={(v) => handleChange('top_k', v)} onValueChange={(v) => handleChange('top_k', v)}
min={1} min={1}
placeholder="Number of results" placeholder={t('retrievePanel.querySettings.topKPlaceholder')}
/> />
</> </>
@@ -106,8 +108,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Max Tokens for Text Unit" text={t('retrievePanel.querySettings.maxTokensTextUnit')}
tooltip="Maximum number of tokens allowed for each retrieved text chunk" tooltip={t('retrievePanel.querySettings.maxTokensTextUnitTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -116,14 +118,14 @@ export default function QuerySettings() {
value={querySettings.max_token_for_text_unit} value={querySettings.max_token_for_text_unit}
onValueChange={(v) => handleChange('max_token_for_text_unit', v)} onValueChange={(v) => handleChange('max_token_for_text_unit', v)}
min={1} min={1}
placeholder="Max tokens for text unit" placeholder={t('retrievePanel.querySettings.maxTokensTextUnit')}
/> />
</> </>
<> <>
<Text <Text
text="Max Tokens for Global Context" text={t('retrievePanel.querySettings.maxTokensGlobalContext')}
tooltip="Maximum number of tokens allocated for relationship descriptions in global retrieval" tooltip={t('retrievePanel.querySettings.maxTokensGlobalContextTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -132,15 +134,15 @@ export default function QuerySettings() {
value={querySettings.max_token_for_global_context} value={querySettings.max_token_for_global_context}
onValueChange={(v) => handleChange('max_token_for_global_context', v)} onValueChange={(v) => handleChange('max_token_for_global_context', v)}
min={1} min={1}
placeholder="Max tokens for global context" placeholder={t('retrievePanel.querySettings.maxTokensGlobalContext')}
/> />
</> </>
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Max Tokens for Local Context" text={t('retrievePanel.querySettings.maxTokensLocalContext')}
tooltip="Maximum number of tokens allocated for entity descriptions in local retrieval" tooltip={t('retrievePanel.querySettings.maxTokensLocalContextTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -149,7 +151,7 @@ export default function QuerySettings() {
value={querySettings.max_token_for_local_context} value={querySettings.max_token_for_local_context}
onValueChange={(v) => handleChange('max_token_for_local_context', v)} onValueChange={(v) => handleChange('max_token_for_local_context', v)}
min={1} min={1}
placeholder="Max tokens for local context" placeholder={t('retrievePanel.querySettings.maxTokensLocalContext')}
/> />
</> </>
</> </>
@@ -158,8 +160,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="History Turns" text={t('retrievePanel.querySettings.historyTurns')}
tooltip="Number of complete conversation turns (user-assistant pairs) to consider in the response context" tooltip={t('retrievePanel.querySettings.historyTurnsTooltip')}
side="left" side="left"
/> />
<NumberInput <NumberInput
@@ -170,7 +172,7 @@ export default function QuerySettings() {
value={querySettings.history_turns} value={querySettings.history_turns}
onValueChange={(v) => handleChange('history_turns', v)} onValueChange={(v) => handleChange('history_turns', v)}
min={0} min={0}
placeholder="Number of history turns" placeholder={t('retrievePanel.querySettings.historyTurnsPlaceholder')}
/> />
</> </>
@@ -179,8 +181,8 @@ export default function QuerySettings() {
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="High-Level Keywords" text={t('retrievePanel.querySettings.hlKeywords')}
tooltip="List of high-level keywords to prioritize in retrieval. Separate with commas" tooltip={t('retrievePanel.querySettings.hlKeywordsTooltip')}
side="left" side="left"
/> />
<Input <Input
@@ -194,15 +196,15 @@ export default function QuerySettings() {
.filter((k) => k !== '') .filter((k) => k !== '')
handleChange('hl_keywords', keywords) handleChange('hl_keywords', keywords)
}} }}
placeholder="Enter keywords" placeholder={t('retrievePanel.querySettings.hlkeywordsPlaceHolder')}
/> />
</> </>
<> <>
<Text <Text
className="ml-1" className="ml-1"
text="Low-Level Keywords" text={t('retrievePanel.querySettings.llKeywords')}
tooltip="List of low-level keywords to refine retrieval focus. Separate with commas" tooltip={t('retrievePanel.querySettings.llKeywordsTooltip')}
side="left" side="left"
/> />
<Input <Input
@@ -216,7 +218,7 @@ export default function QuerySettings() {
.filter((k) => k !== '') .filter((k) => k !== '')
handleChange('ll_keywords', keywords) handleChange('ll_keywords', keywords)
}} }}
placeholder="Enter keywords" placeholder={t('retrievePanel.querySettings.hlkeywordsPlaceHolder')}
/> />
</> </>
</> </>
@@ -226,8 +228,8 @@ export default function QuerySettings() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text <Text
className="ml-1" className="ml-1"
text="Only Need Context" text={t('retrievePanel.querySettings.onlyNeedContext')}
tooltip="If True, only returns the retrieved context without generating a response" tooltip={t('retrievePanel.querySettings.onlyNeedContextTooltip')}
side="left" side="left"
/> />
<div className="grow" /> <div className="grow" />
@@ -242,8 +244,8 @@ export default function QuerySettings() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text <Text
className="ml-1" className="ml-1"
text="Only Need Prompt" text={t('retrievePanel.querySettings.onlyNeedPrompt')}
tooltip="If True, only returns the generated prompt without producing a response" tooltip={t('retrievePanel.querySettings.onlyNeedPromptTooltip')}
side="left" side="left"
/> />
<div className="grow" /> <div className="grow" />
@@ -258,8 +260,8 @@ export default function QuerySettings() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text <Text
className="ml-1" className="ml-1"
text="Stream Response" text={t('retrievePanel.querySettings.streamResponse')}
tooltip="If True, enables streaming output for real-time responses" tooltip={t('retrievePanel.querySettings.streamResponseTooltip')}
side="left" side="left"
/> />
<div className="grow" /> <div className="grow" />

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { import {
Table, Table,
@@ -22,6 +23,7 @@ import { useBackendState } from '@/stores/state'
import { RefreshCwIcon } from 'lucide-react' import { RefreshCwIcon } from 'lucide-react'
export default function DocumentManager() { export default function DocumentManager() {
const { t } = useTranslation()
const health = useBackendState.use.health() const health = useBackendState.use.health()
const [docs, setDocs] = useState<DocsStatusesResponse | null>(null) const [docs, setDocs] = useState<DocsStatusesResponse | null>(null)
@@ -44,7 +46,7 @@ export default function DocumentManager() {
setDocs(null) setDocs(null)
} }
} catch (err) { } catch (err) {
toast.error('Failed to load documents\n' + errorMessage(err)) toast.error(t('documentPanel.documentManager.errors.loadFailed', { error: errorMessage(err) }))
} }
}, [setDocs]) }, [setDocs])
@@ -57,7 +59,7 @@ export default function DocumentManager() {
const { status } = await scanNewDocuments() const { status } = await scanNewDocuments()
toast.message(status) toast.message(status)
} catch (err) { } catch (err) {
toast.error('Failed to load documents\n' + errorMessage(err)) toast.error(t('documentPanel.documentManager.errors.scanFailed', { error: errorMessage(err) }))
} }
}, []) }, [])
@@ -69,7 +71,7 @@ export default function DocumentManager() {
try { try {
await fetchDocuments() await fetchDocuments()
} catch (err) { } catch (err) {
toast.error('Failed to get scan progress\n' + errorMessage(err)) toast.error(t('documentPanel.documentManager.errors.scanProgressFailed', { error: errorMessage(err) }))
} }
}, 5000) }, 5000)
return () => clearInterval(interval) return () => clearInterval(interval)
@@ -78,7 +80,7 @@ export default function DocumentManager() {
return ( return (
<Card className="!size-full !rounded-none !border-none"> <Card className="!size-full !rounded-none !border-none">
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Document Management</CardTitle> <CardTitle className="text-lg">{t('documentPanel.documentManager.title')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex gap-2"> <div className="flex gap-2">
@@ -86,10 +88,10 @@ export default function DocumentManager() {
variant="outline" variant="outline"
onClick={scanDocuments} onClick={scanDocuments}
side="bottom" side="bottom"
tooltip="Scan documents" tooltip={t('documentPanel.documentManager.scanTooltip')}
size="sm" size="sm"
> >
<RefreshCwIcon /> Scan <RefreshCwIcon /> {t('documentPanel.documentManager.scanButton')}
</Button> </Button>
<div className="flex-1" /> <div className="flex-1" />
<ClearDocumentsDialog /> <ClearDocumentsDialog />
@@ -98,29 +100,29 @@ export default function DocumentManager() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Uploaded documents</CardTitle> <CardTitle>{t('documentPanel.documentManager.uploadedTitle')}</CardTitle>
<CardDescription>view the uploaded documents here</CardDescription> <CardDescription>{t('documentPanel.documentManager.uploadedDescription')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!docs && ( {!docs && (
<EmptyCard <EmptyCard
title="No documents uploaded" title={t('documentPanel.documentManager.emptyTitle')}
description="upload documents to see them here" description={t('documentPanel.documentManager.emptyDescription')}
/> />
)} )}
{docs && ( {docs && (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>ID</TableHead> <TableHead>{t('documentPanel.documentManager.columns.id')}</TableHead>
<TableHead>Summary</TableHead> <TableHead>{t('documentPanel.documentManager.columns.summary')}</TableHead>
<TableHead>Status</TableHead> <TableHead>{t('documentPanel.documentManager.columns.status')}</TableHead>
<TableHead>Length</TableHead> <TableHead>{t('documentPanel.documentManager.columns.length')}</TableHead>
<TableHead>Chunks</TableHead> <TableHead>{t('documentPanel.documentManager.columns.chunks')}</TableHead>
<TableHead>Created</TableHead> <TableHead>{t('documentPanel.documentManager.columns.created')}</TableHead>
<TableHead>Updated</TableHead> <TableHead>{t('documentPanel.documentManager.columns.updated')}</TableHead>
<TableHead>Metadata</TableHead> <TableHead>{t('documentPanel.documentManager.columns.metadata')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="text-sm"> <TableBody className="text-sm">
@@ -137,13 +139,13 @@ export default function DocumentManager() {
</TableCell> </TableCell>
<TableCell> <TableCell>
{status === 'processed' && ( {status === 'processed' && (
<span className="text-green-600">Completed</span> <span className="text-green-600">{t('documentPanel.documentManager.status.completed')}</span>
)} )}
{status === 'processing' && ( {status === 'processing' && (
<span className="text-blue-600">Processing</span> <span className="text-blue-600">{t('documentPanel.documentManager.status.processing')}</span>
)} )}
{status === 'pending' && <span className="text-yellow-600">Pending</span>} {status === 'pending' && <span className="text-yellow-600">{t('documentPanel.documentManager.status.pending')}</span>}
{status === 'failed' && <span className="text-red-600">Failed</span>} {status === 'failed' && <span className="text-red-600">{t('documentPanel.documentManager.status.failed')}</span>}
{doc.error && ( {doc.error && (
<span className="ml-2 text-red-500" title={doc.error}> <span className="ml-2 text-red-500" title={doc.error}>

View File

@@ -8,8 +8,10 @@ import { useDebounce } from '@/hooks/useDebounce'
import QuerySettings from '@/components/retrieval/QuerySettings' import QuerySettings from '@/components/retrieval/QuerySettings'
import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage' import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage'
import { EraserIcon, SendIcon } from 'lucide-react' import { EraserIcon, SendIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function RetrievalTesting() { export default function RetrievalTesting() {
const { t } = useTranslation()
const [messages, setMessages] = useState<MessageWithError[]>( const [messages, setMessages] = useState<MessageWithError[]>(
() => useSettingsStore.getState().retrievalHistory || [] () => useSettingsStore.getState().retrievalHistory || []
) )
@@ -89,7 +91,7 @@ export default function RetrievalTesting() {
} }
} catch (err) { } catch (err) {
// Handle error // Handle error
updateAssistantMessage(`Error: Failed to get response\n${errorMessage(err)}`, true) updateAssistantMessage(`${t('retrievePanel.retrieval.error')}\n${errorMessage(err)}`, true)
} finally { } finally {
// Clear loading and add messages to state // Clear loading and add messages to state
setIsLoading(false) setIsLoading(false)
@@ -98,7 +100,7 @@ export default function RetrievalTesting() {
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage]) .setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
} }
}, },
[inputValue, isLoading, messages, setMessages] [inputValue, isLoading, messages, setMessages, t]
) )
const debouncedMessages = useDebounce(messages, 100) const debouncedMessages = useDebounce(messages, 100)
@@ -117,7 +119,7 @@ export default function RetrievalTesting() {
<div className="flex min-h-0 flex-1 flex-col gap-2"> <div className="flex min-h-0 flex-1 flex-col gap-2">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-lg"> <div className="text-muted-foreground flex h-full items-center justify-center text-lg">
Start a retrieval by typing your query below {t('retrievePanel.retrieval.startPrompt')}
</div> </div>
) : ( ) : (
messages.map((message, idx) => ( messages.map((message, idx) => (
@@ -143,18 +145,18 @@ export default function RetrievalTesting() {
size="sm" size="sm"
> >
<EraserIcon /> <EraserIcon />
Clear {t('retrievePanel.retrieval.clear')}
</Button> </Button>
<Input <Input
className="flex-1" className="flex-1"
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
placeholder="Type your query..." placeholder={t('retrievePanel.retrieval.placeholder')}
disabled={isLoading} disabled={isLoading}
/> />
<Button type="submit" variant="default" disabled={isLoading} size="sm"> <Button type="submit" variant="default" disabled={isLoading} size="sm">
<SendIcon /> <SendIcon />
Send {t('retrievePanel.retrieval.send')}
</Button> </Button>
</form> </form>
</div> </div>

View File

@@ -1,9 +1,10 @@
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { SiteInfo } from '@/lib/constants' import { SiteInfo } from '@/lib/constants'
import ThemeToggle from '@/components/ThemeToggle' import AppSettings from '@/components/AppSettings'
import { TabsList, TabsTrigger } from '@/components/ui/Tabs' import { TabsList, TabsTrigger } from '@/components/ui/Tabs'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { ZapIcon, GithubIcon } from 'lucide-react' import { ZapIcon, GithubIcon } from 'lucide-react'
@@ -29,21 +30,22 @@ function NavigationTab({ value, currentTab, children }: NavigationTabProps) {
function TabsNavigation() { function TabsNavigation() {
const currentTab = useSettingsStore.use.currentTab() const currentTab = useSettingsStore.use.currentTab()
const { t } = useTranslation()
return ( return (
<div className="flex h-8 self-center"> <div className="flex h-8 self-center">
<TabsList className="h-full gap-2"> <TabsList className="h-full gap-2">
<NavigationTab value="documents" currentTab={currentTab}> <NavigationTab value="documents" currentTab={currentTab}>
Documents {t('header.documents')}
</NavigationTab> </NavigationTab>
<NavigationTab value="knowledge-graph" currentTab={currentTab}> <NavigationTab value="knowledge-graph" currentTab={currentTab}>
Knowledge Graph {t('header.knowledgeGraph')}
</NavigationTab> </NavigationTab>
<NavigationTab value="retrieval" currentTab={currentTab}> <NavigationTab value="retrieval" currentTab={currentTab}>
Retrieval {t('header.retrieval')}
</NavigationTab> </NavigationTab>
<NavigationTab value="api" currentTab={currentTab}> <NavigationTab value="api" currentTab={currentTab}>
API {t('header.api')}
</NavigationTab> </NavigationTab>
</TabsList> </TabsList>
</div> </div>
@@ -51,6 +53,7 @@ function TabsNavigation() {
} }
export default function SiteHeader() { export default function SiteHeader() {
const { t } = useTranslation()
return ( return (
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur"> <header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-10 w-full border-b px-4 backdrop-blur">
<a href="/" className="mr-6 flex items-center gap-2"> <a href="/" className="mr-6 flex items-center gap-2">
@@ -64,12 +67,14 @@ export default function SiteHeader() {
</div> </div>
<nav className="flex items-center"> <nav className="flex items-center">
<Button variant="ghost" size="icon" side="bottom" tooltip="Project Repository"> <div className="flex items-center gap-2">
<a href={SiteInfo.github} target="_blank" rel="noopener noreferrer"> <Button variant="ghost" size="icon" side="bottom" tooltip={t('header.projectRepository')}>
<GithubIcon className="size-4" aria-hidden="true" /> <a href={SiteInfo.github} target="_blank" rel="noopener noreferrer">
</a> <GithubIcon className="size-4" aria-hidden="true" />
</Button> </a>
<ThemeToggle /> </Button>
<AppSettings />
</div>
</nav> </nav>
</header> </header>
) )

View File

@@ -0,0 +1,37 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import { useSettingsStore } from '@/stores/settings'
import en from './locales/en.json'
import zh from './locales/zh.json'
// Function to sync i18n with store state
export const initializeI18n = async (): Promise<typeof i18n> => {
// Get initial language from store
const initialLanguage = useSettingsStore.getState().language
// Initialize with store language
await i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zh }
},
lng: initialLanguage,
fallbackLng: 'en',
interpolation: {
escapeValue: false
}
})
// Subscribe to language changes
useSettingsStore.subscribe((state) => {
const currentLanguage = state.language
if (i18n.language !== currentLanguage) {
i18n.changeLanguage(currentLanguage)
}
})
return i18n
}
export default i18n

View File

@@ -0,0 +1,244 @@
{
"settings": {
"language": "Language",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"header": {
"documents": "Documents",
"knowledgeGraph": "Knowledge Graph",
"retrieval": "Retrieval",
"api": "API",
"projectRepository": "Project Repository",
"themeToggle": {
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme"
}
},
"documentPanel": {
"clearDocuments": {
"button": "Clear",
"tooltip": "Clear documents",
"title": "Clear Documents",
"confirm": "Do you really want to clear all documents?",
"confirmButton": "YES",
"success": "Documents cleared successfully",
"failed": "Clear Documents Failed:\n{{message}}",
"error": "Clear Documents Failed:\n{{error}}"
},
"uploadDocuments": {
"button": "Upload",
"tooltip": "Upload documents",
"title": "Upload Documents",
"description": "Drag and drop your documents here or click to browse.",
"uploading": "Uploading {{name}}: {{percent}}%",
"success": "Upload Success:\n{{name}} uploaded successfully",
"failed": "Upload Failed:\n{{name}}\n{{message}}",
"error": "Upload Failed:\n{{name}}\n{{error}}",
"generalError": "Upload Failed\n{{error}}",
"fileTypes": "Supported types: TXT, MD, DOCX, PDF, PPTX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS"
},
"documentManager": {
"title": "Document Management",
"scanButton": "Scan",
"scanTooltip": "Scan documents",
"uploadedTitle": "Uploaded Documents",
"uploadedDescription": "List of uploaded documents and their statuses.",
"emptyTitle": "No Documents",
"emptyDescription": "There are no uploaded documents yet.",
"columns": {
"id": "ID",
"summary": "Summary",
"status": "Status",
"length": "Length",
"chunks": "Chunks",
"created": "Created",
"updated": "Updated",
"metadata": "Metadata"
},
"status": {
"completed": "Completed",
"processing": "Processing",
"pending": "Pending",
"failed": "Failed"
},
"errors": {
"loadFailed": "Failed to load documents\n{{error}}",
"scanFailed": "Failed to scan documents\n{{error}}",
"scanProgressFailed": "Failed to get scan progress\n{{error}}"
}
}
},
"graphPanel": {
"sideBar": {
"settings": {
"settings": "Settings",
"healthCheck": "Health Check",
"showPropertyPanel": "Show Property Panel",
"showSearchBar": "Show Search Bar",
"showNodeLabel": "Show Node Label",
"nodeDraggable": "Node Draggable",
"showEdgeLabel": "Show Edge Label",
"hideUnselectedEdges": "Hide Unselected Edges",
"edgeEvents": "Edge Events",
"maxQueryDepth": "Max Query Depth",
"minDegree": "Minimum Degree",
"maxLayoutIterations": "Max Layout Iterations",
"depth": "Depth",
"degree": "Degree",
"apiKey": "API Key",
"enterYourAPIkey": "Enter your API key",
"save": "Save",
"refreshLayout": "Refresh Layout"
},
"zoomControl": {
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"resetZoom": "Reset Zoom"
},
"layoutsControl": {
"startAnimation": "Start the layout animation",
"stopAnimation": "Stop the layout animation",
"layoutGraph": "Layout Graph",
"layouts": {
"Circular": "Circular",
"Circlepack": "Circlepack",
"Random": "Random",
"Noverlaps": "Noverlaps",
"Force Directed": "Force Directed",
"Force Atlas": "Force Atlas"
}
},
"fullScreenControl": {
"fullScreen": "Full Screen",
"windowed": "Windowed"
}
},
"statusIndicator": {
"connected": "Connected",
"disconnected": "Disconnected"
},
"statusCard": {
"unavailable": "Status information unavailable",
"storageInfo": "Storage Info",
"workingDirectory": "Working Directory",
"inputDirectory": "Input Directory",
"llmConfig": "LLM Configuration",
"llmBinding": "LLM Binding",
"llmBindingHost": "LLM Binding Host",
"llmModel": "LLM Model",
"maxTokens": "Max Tokens",
"embeddingConfig": "Embedding Configuration",
"embeddingBinding": "Embedding Binding",
"embeddingBindingHost": "Embedding Binding Host",
"embeddingModel": "Embedding Model",
"storageConfig": "Storage Configuration",
"kvStorage": "KV Storage",
"docStatusStorage": "Doc Status Storage",
"graphStorage": "Graph Storage",
"vectorStorage": "Vector Storage"
},
"propertiesView": {
"node": {
"title": "Node",
"id": "ID",
"labels": "Labels",
"degree": "Degree",
"properties": "Properties",
"relationships": "Relationships"
},
"edge": {
"title": "Relationship",
"id": "ID",
"type": "Type",
"source": "Source",
"target": "Target",
"properties": "Properties"
}
},
"search": {
"placeholder": "Search nodes...",
"message": "And {count} others"
},
"graphLabels": {
"selectTooltip": "Select query label",
"noLabels": "No labels found",
"label": "Label",
"placeholder": "Search labels...",
"andOthers": "And {count} others"
}
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "Copy to clipboard",
"copyError": "Failed to copy text to clipboard"
},
"retrieval": {
"startPrompt": "Start a retrieval by typing your query below",
"clear": "Clear",
"send": "Send",
"placeholder": "Type your query...",
"error": "Error: Failed to get response"
},
"querySettings": {
"parametersTitle": "Parameters",
"parametersDescription": "Configure your query parameters",
"queryMode": "Query Mode",
"queryModeTooltip": "Select the retrieval strategy:\n• Naive: Basic search without advanced techniques\n• Local: Context-dependent information retrieval\n• Global: Utilizes global knowledge base\n• Hybrid: Combines local and global retrieval\n• Mix: Integrates knowledge graph with vector retrieval",
"queryModeOptions": {
"naive": "Naive",
"local": "Local",
"global": "Global",
"hybrid": "Hybrid",
"mix": "Mix"
},
"responseFormat": "Response Format",
"responseFormatTooltip": "Defines the response format. Examples:\n• Multiple Paragraphs\n• Single Paragraph\n• Bullet Points",
"responseFormatOptions": {
"multipleParagraphs": "Multiple Paragraphs",
"singleParagraph": "Single Paragraph",
"bulletPoints": "Bullet Points"
},
"topK": "Top K Results",
"topKTooltip": "Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode",
"topKPlaceholder": "Number of results",
"maxTokensTextUnit": "Max Tokens for Text Unit",
"maxTokensTextUnitTooltip": "Maximum number of tokens allowed for each retrieved text chunk",
"maxTokensGlobalContext": "Max Tokens for Global Context",
"maxTokensGlobalContextTooltip": "Maximum number of tokens allocated for relationship descriptions in global retrieval",
"maxTokensLocalContext": "Max Tokens for Local Context",
"maxTokensLocalContextTooltip": "Maximum number of tokens allocated for entity descriptions in local retrieval",
"historyTurns": "History Turns",
"historyTurnsTooltip": "Number of complete conversation turns (user-assistant pairs) to consider in the response context",
"historyTurnsPlaceholder": "Number of history turns",
"hlKeywords": "High-Level Keywords",
"hlKeywordsTooltip": "List of high-level keywords to prioritize in retrieval. Separate with commas",
"hlkeywordsPlaceHolder": "Enter keywords",
"llKeywords": "Low-Level Keywords",
"llKeywordsTooltip": "List of low-level keywords to refine retrieval focus. Separate with commas",
"onlyNeedContext": "Only Need Context",
"onlyNeedContextTooltip": "If True, only returns the retrieved context without generating a response",
"onlyNeedPrompt": "Only Need Prompt",
"onlyNeedPromptTooltip": "If True, only returns the generated prompt without producing a response",
"streamResponse": "Stream Response",
"streamResponseTooltip": "If True, enables streaming output for real-time responses"
}
}
}

View File

@@ -0,0 +1,229 @@
{
"settings": {
"language": "语言",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "系统"
},
"header": {
"documents": "文档",
"knowledgeGraph": "知识图谱",
"retrieval": "检索",
"api": "API",
"projectRepository": "项目仓库",
"themeToggle": {
"switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题"
}
},
"documentPanel": {
"clearDocuments": {
"button": "清空",
"tooltip": "清空文档",
"title": "清空文档",
"confirm": "确定要清空所有文档吗?",
"confirmButton": "确定",
"success": "文档清空成功",
"failed": "清空文档失败:\n{{message}}",
"error": "清空文档失败:\n{{error}}"
},
"uploadDocuments": {
"button": "上传",
"tooltip": "上传文档",
"title": "上传文档",
"description": "拖拽文件到此处或点击浏览",
"uploading": "正在上传 {{name}}{{percent}}%",
"success": "上传成功:\n{{name}} 上传完成",
"failed": "上传失败:\n{{name}}\n{{message}}",
"error": "上传失败:\n{{name}}\n{{error}}",
"generalError": "上传失败\n{{error}}",
"fileTypes": "支持的文件类型TXT, MD, DOCX, PDF, PPTX, RTF, ODT, EPUB, HTML, HTM, TEX, JSON, XML, YAML, YML, CSV, LOG, CONF, INI, PROPERTIES, SQL, BAT, SH, C, CPP, PY, JAVA, JS, TS, SWIFT, GO, RB, PHP, CSS, SCSS, LESS"
},
"documentManager": {
"title": "文档管理",
"scanButton": "扫描",
"scanTooltip": "扫描文档",
"uploadedTitle": "已上传文档",
"uploadedDescription": "已上传文档列表及其状态",
"emptyTitle": "无文档",
"emptyDescription": "还没有上传任何文档",
"columns": {
"id": "ID",
"summary": "摘要",
"status": "状态",
"length": "长度",
"chunks": "分块",
"created": "创建时间",
"updated": "更新时间",
"metadata": "元数据"
},
"status": {
"completed": "已完成",
"processing": "处理中",
"pending": "等待中",
"failed": "失败"
},
"errors": {
"loadFailed": "加载文档失败\n{{error}}",
"scanFailed": "扫描文档失败\n{{error}}",
"scanProgressFailed": "获取扫描进度失败\n{{error}}"
}
}
},
"graphPanel": {
"sideBar": {
"settings": {
"settings": "设置",
"healthCheck": "健康检查",
"showPropertyPanel": "显示属性面板",
"showSearchBar": "显示搜索栏",
"showNodeLabel": "显示节点标签",
"nodeDraggable": "节点可拖动",
"showEdgeLabel": "显示边标签",
"hideUnselectedEdges": "隐藏未选中的边",
"edgeEvents": "边事件",
"maxQueryDepth": "最大查询深度",
"minDegree": "最小度数",
"maxLayoutIterations": "最大布局迭代次数",
"depth": "深度",
"degree": "邻边",
"apiKey": "API密钥",
"enterYourAPIkey": "输入您的API密钥",
"save": "保存",
"refreshLayout": "刷新布局"
},
"zoomControl": {
"zoomIn": "放大",
"zoomOut": "缩小",
"resetZoom": "重置缩放"
},
"layoutsControl": {
"startAnimation": "开始布局动画",
"stopAnimation": "停止布局动画",
"layoutGraph": "图布局",
"layouts": {
"Circular": "环形",
"Circlepack": "圆形打包",
"Random": "随机",
"Noverlaps": "无重叠",
"Force Directed": "力导向",
"Force Atlas": "力图"
}
},
"fullScreenControl": {
"fullScreen": "全屏",
"windowed": "窗口"
}
},
"statusIndicator": {
"connected": "已连接",
"disconnected": "未连接"
},
"statusCard": {
"unavailable": "状态信息不可用",
"storageInfo": "存储信息",
"workingDirectory": "工作目录",
"inputDirectory": "输入目录",
"llmConfig": "LLM配置",
"llmBinding": "LLM绑定",
"llmBindingHost": "LLM绑定主机",
"llmModel": "LLM模型",
"maxTokens": "最大令牌数",
"embeddingConfig": "嵌入配置",
"embeddingBinding": "嵌入绑定",
"embeddingBindingHost": "嵌入绑定主机",
"embeddingModel": "嵌入模型",
"storageConfig": "存储配置",
"kvStorage": "KV存储",
"docStatusStorage": "文档状态存储",
"graphStorage": "图存储",
"vectorStorage": "向量存储"
},
"propertiesView": {
"node": {
"title": "节点",
"id": "ID",
"labels": "标签",
"degree": "度数",
"properties": "属性",
"relationships": "关系"
},
"edge": {
"title": "关系",
"id": "ID",
"type": "类型",
"source": "源节点",
"target": "目标节点",
"properties": "属性"
}
},
"search": {
"placeholder": "搜索节点...",
"message": "还有 {count} 个"
},
"graphLabels": {
"selectTooltip": "选择查询标签",
"noLabels": "未找到标签",
"label": "标签",
"placeholder": "搜索标签...",
"andOthers": "还有 {count} 个"
}
},
"retrievePanel": {
"chatMessage": {
"copyTooltip": "复制到剪贴板",
"copyError": "复制文本到剪贴板失败"
},
"retrieval": {
"startPrompt": "输入查询开始检索",
"clear": "清空",
"send": "发送",
"placeholder": "输入查询...",
"error": "错误:获取响应失败"
},
"querySettings": {
"parametersTitle": "参数",
"parametersDescription": "配置查询参数",
"queryMode": "查询模式",
"queryModeTooltip": "选择检索策略:\n• Naive基础搜索无高级技术\n• Local上下文相关信息检索\n• Global利用全局知识库\n• Hybrid结合本地和全局检索\n• Mix整合知识图谱和向量检索",
"queryModeOptions": {
"naive": "朴素",
"local": "本地",
"global": "全局",
"hybrid": "混合",
"mix": "混合"
},
"responseFormat": "响应格式",
"responseFormatTooltip": "定义响应格式。例如:\n• 多段落\n• 单段落\n• 要点",
"responseFormatOptions": {
"multipleParagraphs": "多段落",
"singleParagraph": "单段落",
"bulletPoints": "要点"
},
"topK": "Top K结果",
"topKTooltip": "检索的顶部项目数。在'local'模式下表示实体,在'global'模式下表示关系",
"topKPlaceholder": "结果数量",
"maxTokensTextUnit": "文本单元最大令牌数",
"maxTokensTextUnitTooltip": "每个检索文本块允许的最大令牌数",
"maxTokensGlobalContext": "全局上下文最大令牌数",
"maxTokensGlobalContextTooltip": "全局检索中关系描述的最大令牌数",
"maxTokensLocalContext": "本地上下文最大令牌数",
"maxTokensLocalContextTooltip": "本地检索中实体描述的最大令牌数",
"historyTurns": "历史轮次",
"historyTurnsTooltip": "响应上下文中考虑的完整对话轮次(用户-助手对)数量",
"historyTurnsPlaceholder": "历史轮次数",
"hlKeywords": "高级关键词",
"hlKeywordsTooltip": "检索中优先考虑的高级关键词列表。用逗号分隔",
"hlkeywordsPlaceHolder": "输入关键词",
"llKeywords": "低级关键词",
"llKeywordsTooltip": "用于细化检索重点的低级关键词列表。用逗号分隔",
"onlyNeedContext": "仅需上下文",
"onlyNeedContextTooltip": "如果为True仅返回检索到的上下文而不生成响应",
"onlyNeedPrompt": "仅需提示",
"onlyNeedPromptTooltip": "如果为True仅返回生成的提示而不产生响应",
"streamResponse": "流式响应",
"streamResponseTooltip": "如果为True启用实时流式输出响应"
}
}
}

View File

@@ -1,10 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import { Root } from '@/components/Root'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(<Root />)
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -6,6 +6,7 @@ import { Message, QueryRequest } from '@/api/lightrag'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
type Theme = 'dark' | 'light' | 'system' type Theme = 'dark' | 'light' | 'system'
type Language = 'en' | 'zh'
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api' type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api'
interface SettingsState { interface SettingsState {
@@ -48,6 +49,9 @@ interface SettingsState {
theme: Theme theme: Theme
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void
language: Language
setLanguage: (lang: Language) => void
enableHealthCheck: boolean enableHealthCheck: boolean
setEnableHealthCheck: (enable: boolean) => void setEnableHealthCheck: (enable: boolean) => void
@@ -55,10 +59,27 @@ interface SettingsState {
setCurrentTab: (tab: Tab) => void setCurrentTab: (tab: Tab) => void
} }
// Helper to get initial state from localStorage
const getInitialState = () => {
try {
const stored = localStorage.getItem('settings-storage')
if (stored) {
const { state } = JSON.parse(stored)
return {
theme: state?.theme || 'system',
language: state?.language || 'zh'
}
}
} catch (e) {
console.error('Failed to parse settings from localStorage:', e)
}
return { theme: 'system', language: 'zh' }
}
const useSettingsStoreBase = create<SettingsState>()( const useSettingsStoreBase = create<SettingsState>()(
persist( persist(
(set) => ({ (set) => ({
theme: 'system', ...getInitialState(),
refreshLayout: () => { refreshLayout: () => {
const graphState = useGraphStore.getState(); const graphState = useGraphStore.getState();
const currentGraph = graphState.sigmaGraph; const currentGraph = graphState.sigmaGraph;
@@ -110,6 +131,16 @@ const useSettingsStoreBase = create<SettingsState>()(
setTheme: (theme: Theme) => set({ theme }), setTheme: (theme: Theme) => set({ theme }),
setLanguage: (language: Language) => {
set({ language })
// Update i18n after state is updated
import('i18next').then(({ default: i18n }) => {
if (i18n.language !== language) {
i18n.changeLanguage(language)
}
})
},
setGraphLayoutMaxIterations: (iterations: number) => setGraphLayoutMaxIterations: (iterations: number) =>
set({ set({
graphLayoutMaxIterations: iterations graphLayoutMaxIterations: iterations