Merge pull request #737 from ArnoChenFx/graph-viewer-webui
graph viewer webui
This commit is contained in:
@@ -44,7 +44,7 @@ rag = LightRAG(
|
||||
),
|
||||
kv_storage="MongoKVStorage",
|
||||
graph_storage="Neo4JStorage",
|
||||
vector_storage="MilvusVectorDBStorge",
|
||||
vector_storage="MilvusVectorDBStorage",
|
||||
)
|
||||
|
||||
file = "./book.txt"
|
||||
|
@@ -58,7 +58,7 @@ rag = LightRAG(
|
||||
chunk_overlap_token_size=256,
|
||||
kv_storage="RedisKVStorage",
|
||||
graph_storage="Neo4JStorage",
|
||||
vector_storage="MilvusVectorDBStorge",
|
||||
vector_storage="MilvusVectorDBStorage",
|
||||
doc_status_storage="RedisKVStorage",
|
||||
)
|
||||
|
||||
|
@@ -111,7 +111,7 @@ if milvus_uri:
|
||||
os.environ["MILVUS_USER"] = milvus_user
|
||||
os.environ["MILVUS_PASSWORD"] = milvus_password
|
||||
os.environ["MILVUS_DB_NAME"] = milvus_db_name
|
||||
rag_storage_config.VECTOR_STORAGE = "MilvusVectorDBStorge"
|
||||
rag_storage_config.VECTOR_STORAGE = "MilvusVectorDBStorage"
|
||||
|
||||
# Qdrant config
|
||||
qdrant_uri = config.get("qdrant", "uri", fallback=None)
|
||||
@@ -272,6 +272,10 @@ def display_splash_screen(args: argparse.Namespace) -> None:
|
||||
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/docs")
|
||||
ASCIIColors.white(" └─ Alternative Documentation (local): ", end="")
|
||||
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/redoc")
|
||||
ASCIIColors.white(" ├─ WebUI (local): ", end="")
|
||||
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/webui")
|
||||
ASCIIColors.white(" ├─ Graph Viewer (local): ", end="")
|
||||
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/graph-viewer")
|
||||
|
||||
ASCIIColors.yellow("\n📝 Note:")
|
||||
ASCIIColors.white(""" Since the server is running on 0.0.0.0:
|
||||
@@ -1424,8 +1428,8 @@ def create_app(args):
|
||||
|
||||
# query all graph
|
||||
@app.get("/graphs")
|
||||
async def get_graphs(label: str):
|
||||
return await rag.get_graps(nodel_label=label, max_depth=100)
|
||||
async def get_knowledge_graph(label: str):
|
||||
return await rag.get_knowledge_graph(nodel_label=label, max_depth=100)
|
||||
|
||||
# Add Ollama API routes
|
||||
ollama_api = OllamaAPI(rag)
|
||||
@@ -1463,19 +1467,18 @@ def create_app(args):
|
||||
},
|
||||
}
|
||||
|
||||
# webui mount /webui/index.html
|
||||
# app.mount(
|
||||
# "/webui",
|
||||
# StaticFiles(
|
||||
# directory=Path(__file__).resolve().parent / "webui" / "static", html=True
|
||||
# ),
|
||||
# name="webui_static",
|
||||
# )
|
||||
# Webui mount webui/index.html
|
||||
webui_dir = Path(__file__).parent / "webui"
|
||||
app.mount(
|
||||
"/graph-viewer",
|
||||
StaticFiles(directory=webui_dir, html=True),
|
||||
name="webui",
|
||||
)
|
||||
|
||||
# Serve the static files
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
static_dir.mkdir(exist_ok=True)
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
app.mount("/webui", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
|
||||
return app
|
||||
|
||||
|
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="./js/api.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
1
lightrag/api/webui/assets/index-BAeLPZpd.css
Normal file
1
lightrag/api/webui/assets/index-BAeLPZpd.css
Normal file
File diff suppressed because one or more lines are too long
967
lightrag/api/webui/assets/index-CF-pcoIl.js
Normal file
967
lightrag/api/webui/assets/index-CF-pcoIl.js
Normal file
File diff suppressed because one or more lines are too long
14
lightrag/api/webui/index.html
Normal file
14
lightrag/api/webui/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lightrag Graph Viewer</title>
|
||||
<script type="module" crossorigin src="./assets/index-CF-pcoIl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BAeLPZpd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
1
lightrag/api/webui/vite.svg
Normal file
1
lightrag/api/webui/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,86 +0,0 @@
|
||||
/* css/lightrag.css */
|
||||
|
||||
/*Modal box style*/
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--surface);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
width: 80%;
|
||||
max-width: 1200px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* graph style */
|
||||
.node {
|
||||
cursor: pointer;
|
||||
fill: var(--primary);
|
||||
stroke: var(--surface);
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
fill: var(--secondary);
|
||||
}
|
||||
|
||||
.link {
|
||||
stroke: var(--text-secondary);
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
fill: var(--text-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* edge style */
|
||||
.link {
|
||||
stroke: #999;
|
||||
stroke-width: 2px;
|
||||
stroke-opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Arrow color matches edge color */
|
||||
#arrow path {
|
||||
fill: #999 !important;
|
||||
}
|
@@ -1,385 +0,0 @@
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
line-height: 1.6;
|
||||
background-color: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* main container */
|
||||
.app-container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 2rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Navigation sidebar */
|
||||
.nav-panel {
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
height: fit-content;
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
.main-content {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* Card based module */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* File upload area */
|
||||
.file-dropzone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(241, 245, 249, 0.5);
|
||||
}
|
||||
|
||||
.file-dropzone.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(79, 70, 229, 0.05);
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4338CA;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 0.875rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.nav-panel {
|
||||
position: static;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: #EF4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: relative;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "···";
|
||||
animation: loading 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
content: "·";
|
||||
}
|
||||
33% {
|
||||
content: "··";
|
||||
}
|
||||
66% {
|
||||
content: "···";
|
||||
}
|
||||
}
|
||||
|
||||
.text-upload-form {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-indicator.success {
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-indicator.error {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progress-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-list {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.75rem 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.config-list dt {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-list dd {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.directory-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.directory-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.directory-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.directory-path {
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.health-status .status-badge {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10B981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.status-badge.loading {
|
||||
background: rgba(79, 70, 229, 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
.label-container {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.label-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.label-item:hover {
|
||||
transform: translateX(4px);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.label-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #EF4444;
|
||||
color: white;
|
||||
}
|
@@ -1,291 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LightRAG</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #4F46E5;
|
||||
--secondary: #10B981;
|
||||
--background: #F8FAFC;
|
||||
--surface: #FFFFFF;
|
||||
--text-primary: #1E293B;
|
||||
--text-secondary: #64748B;
|
||||
--border: #E2E8F0;
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" type="text/css" href="css/lightrag.css">
|
||||
<link rel="stylesheet" type="text/css" href="css/graph.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<nav class="nav-panel">
|
||||
<h2 class="nav-title">LightRAG</h2>
|
||||
<div class="nav-menu">
|
||||
<button class="btn btn-primary" style="width: 100%; margin-bottom: 1rem;"
|
||||
onclick="showSection('documents')">
|
||||
📁 DOC MANAGE
|
||||
</button>
|
||||
<button class="btn btn-primary" style="width: 100%; margin-bottom: 1rem;"
|
||||
onclick="showSection('text-upload')">
|
||||
📝 TEXT UPLOAD
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary" style="width: 100%; margin-bottom: 1rem;" onclick="showSection('query')">
|
||||
🔍 GRAPH SEARCH
|
||||
</button>
|
||||
<button class="btn btn-primary" style="width: 100%; margin-bottom: 1rem;"
|
||||
onclick="showSection('label-management'); loadLabels()">
|
||||
🏷️ GRAPH MANAGE
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="width: 100%;" onclick="showSection('status')">
|
||||
📊 SYSTEM STATUS
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<section id="documents" class="card">
|
||||
<h3 class="card-title">DOC MANAGE</h3>
|
||||
<div class="file-dropzone" id="dropzone">
|
||||
<input type="file" id="fileInput" hidden multiple>
|
||||
<p>Drap file</p>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('fileInput').click()">
|
||||
📤 select file
|
||||
</button>
|
||||
<div class="file-list" style="margin-top: 1rem;"></div>
|
||||
<div class="status-indicator" id="fileUploadStatus"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="text-upload" class="card" style="display: none;">
|
||||
<h3 class="card-title">TEXT UPLOAD</h3>
|
||||
<div class="text-upload-form">
|
||||
<div class="form-group">
|
||||
<label for="textDescription">Title(optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="textDescription"
|
||||
class="input-field"
|
||||
placeholder="Please enter a text title..."
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="textContent">TEXT CONTENT</label>
|
||||
<textarea
|
||||
id="textContent"
|
||||
class="input-field"
|
||||
placeholder="lease enter a text content..."
|
||||
rows="8"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<button class="btn btn-primary" onclick="handleTextUpload()">
|
||||
📤 submit
|
||||
</button>
|
||||
<div class="status-indicator" id="textUploadStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="label-management" class="card" style="display: none;">
|
||||
<h3 class="card-title">GRAPH MANAGE(neo4j)</h3>
|
||||
<div class="action-bar" style="margin-bottom: 1rem;">
|
||||
<button class="btn btn-secondary" onclick="refreshLabels()">
|
||||
🔄 refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="label-list" class="label-container">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- search -->
|
||||
<section id="query" class="card" style="display: none;">
|
||||
<h3 class="card-title">SEARCH API</h3>
|
||||
<select class="input-field" style="margin-bottom: 1rem;">
|
||||
<option value="local">local search</option>
|
||||
<option value="global">global search</option>
|
||||
<option value="native">native search</option>
|
||||
<option value="hybrid">hybrid search</option>
|
||||
<option value="mix">mix search</option>
|
||||
</select>
|
||||
<textarea
|
||||
class="input-field"
|
||||
placeholder="enter your content..."
|
||||
style="height: 120px; resize: vertical;"
|
||||
></textarea>
|
||||
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-primary">🔍 searching</button>
|
||||
<label class="btn btn-secondary">
|
||||
<input type="checkbox" style="margin-right: 0.5rem;">stream modal
|
||||
</label>
|
||||
</div>
|
||||
<div class="results"
|
||||
style="margin-top: 1rem; padding: 1rem; border: 1px solid var(--border); border-radius: 8px;"></div>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="status" class="card" style="display: none;">
|
||||
<h3 class="card-title">System Status</h3>
|
||||
<div class="status-grid">
|
||||
<div class="status-card health-status">
|
||||
<div class="status-header">
|
||||
<i class="status-icon fas fa-heartbeat"></i>
|
||||
<h4>HEATH STATUS</h4>
|
||||
</div>
|
||||
<div class="status-badge" id="healthStatus">checking...</div>
|
||||
</div>
|
||||
|
||||
<!-- 存储状态 -->
|
||||
<div class="status-card storage-status">
|
||||
<div class="status-header">
|
||||
<i class="status-icon fas fa-database"></i>
|
||||
<h4>Storage status</h4>
|
||||
</div>
|
||||
<div class="progress-container">
|
||||
<progress value="0" max="100" id="storageProgress"></progress>
|
||||
<div class="progress-labels">
|
||||
<span id="indexedFiles">Indexed files:0</span>
|
||||
<span id="storageUsage">Use ratio:0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-card model-config">
|
||||
<div class="status-header">
|
||||
<i class="status-icon fas fa-brain"></i>
|
||||
<h4>LLM configuration</h4>
|
||||
</div>
|
||||
<dl class="config-list">
|
||||
<dt>LLM MODEL</dt>
|
||||
<dd id="llmModel">Loading...</dd>
|
||||
|
||||
<dt>Embedded model</dt>
|
||||
<dd id="embedModel">Loading...</dd>
|
||||
|
||||
<dt>Max token</dt>
|
||||
<dd id="maxTokens">0</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="status-card directory-info">
|
||||
<div class="status-header">
|
||||
<i class="status-icon fas fa-folder-open"></i>
|
||||
<h4>Catalog information</h4>
|
||||
</div>
|
||||
<div class="directory-list">
|
||||
<div class="directory-item">
|
||||
<span class="directory-label">Work directory:</span>
|
||||
<span class="directory-path" id="workingDir"></span>
|
||||
</div>
|
||||
<div class="directory-item">
|
||||
<span class="directory-label">Input directory:</span>
|
||||
<span class="directory-path" id="inputDir"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-card directory-info">
|
||||
<div class="status-header">
|
||||
<i class="status-icon fas fa-folder-open"></i>
|
||||
<h4>Store information</h4>
|
||||
</div>
|
||||
<div class="directory-list">
|
||||
<div class="directory-item">
|
||||
<span class="directory-label">kv_storage:</span>
|
||||
<span class="directory-path" id="kv_storage"></span>
|
||||
</div>
|
||||
<div class="directory-item">
|
||||
<span class="directory-label">doc_status_storage:</span>
|
||||
<span class="directory-path" id="doc_status_storage"></span>
|
||||
</div>
|
||||
<div class="directory-item">
|
||||
<span class="directory-label">graph_storage:</span>
|
||||
<span class="directory-path" id="graph_storage"></span>
|
||||
</div>
|
||||
<div class="directory-item">
|
||||
<span class="directory-label">vector_storage:</span>
|
||||
<span class="directory-path" id="vector_storage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<div id="graph-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="graph-title">GRAPH MANAGE</h3>
|
||||
<button class="btn btn-close" onclick="closeGraphModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="graph-container">
|
||||
<svg id="graph-svg" width="100%" height="600px">
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeGraphModal()">SHUT DOWN</button>
|
||||
</div>
|
||||
<div class="resize-handle"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
|
||||
dropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('active');
|
||||
});
|
||||
|
||||
dropzone.addEventListener('dragleave', () => {
|
||||
dropzone.classList.remove('active');
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('active');
|
||||
const files = e.dataTransfer.files;
|
||||
handleFiles(files);
|
||||
});
|
||||
|
||||
function handleFiles(files) {
|
||||
console.log('selected file:', files);
|
||||
}
|
||||
|
||||
function showSection(sectionId) {
|
||||
// Hide all content blocks
|
||||
const sections = document.querySelectorAll('.main-content .card');
|
||||
sections.forEach(section => {
|
||||
section.style.display = 'none';
|
||||
});
|
||||
|
||||
// Display target block
|
||||
const targetSection = document.getElementById(sectionId);
|
||||
if (targetSection) {
|
||||
targetSection.style.display = 'block';
|
||||
}
|
||||
|
||||
// Update button activation status (optional)
|
||||
const buttons = document.querySelectorAll('.nav-menu button');
|
||||
buttons.forEach(button => {
|
||||
button.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
// Initialize the display document management module
|
||||
showSection('documents');
|
||||
</script>
|
||||
<script src="js/lightrag.js"></script>
|
||||
<script src="js/graph.js"></script>
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
</body>
|
||||
</html>
|
@@ -1,500 +0,0 @@
|
||||
// lightrag.js
|
||||
// init
|
||||
function initializeApp() {
|
||||
setupFileUpload();
|
||||
setupQueryHandler();
|
||||
setupSectionObserver();
|
||||
updateFileList();
|
||||
// textarea count
|
||||
const textArea = document.getElementById('textContent');
|
||||
if (textArea) {
|
||||
const charCount = document.createElement('div');
|
||||
charCount.className = 'char-count';
|
||||
textArea.parentNode.appendChild(charCount);
|
||||
|
||||
textArea.addEventListener('input', () => {
|
||||
const count = textArea.value.length;
|
||||
charCount.textContent = `input ${count} character`;
|
||||
charCount.style.color = count > 10000 ? '#ef4444' : 'var (--text-secondary)'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// api request
|
||||
async function apiRequest(endpoint, method = 'GET', body = null) {
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${endpoint}`, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`request failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error('API REQUEST ERROR:', error);
|
||||
showToast(error.message, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTextUpload() {
|
||||
const description = document.getElementById('textDescription').value;
|
||||
const content = document.getElementById('textContent').value.trim();
|
||||
const statusDiv = document.getElementById('textUploadStatus');
|
||||
|
||||
// clear status tip
|
||||
statusDiv.className = 'status-indicator';
|
||||
statusDiv.textContent = '';
|
||||
|
||||
// input valid
|
||||
if (!content) {
|
||||
showStatus('error', 'TEXT CONTENT NOT NULL', statusDiv);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showStatus('loading', 'UPLOADING...', statusDiv);
|
||||
|
||||
const payload = {
|
||||
text: content,
|
||||
...(description && {description})
|
||||
};
|
||||
|
||||
const response = await fetch(`/documents/text`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'UPLOAD FAILED');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
showStatus('success', `✅ ${result.message} (documents: ${result.document_count})`, statusDiv);
|
||||
|
||||
|
||||
document.getElementById('textContent').value = '';
|
||||
|
||||
// update file list
|
||||
updateFileList();
|
||||
|
||||
} catch (error) {
|
||||
showStatus('error', `❌ ERROR: ${error.message}`, statusDiv);
|
||||
console.error('FILE UPLOAD FAILED:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(type, message, container) {
|
||||
container.textContent = message;
|
||||
container.className = `status-indicator ${type}`;
|
||||
|
||||
//auto clear success status
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
container.textContent = '';
|
||||
container.className = 'status-indicator';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// upload file
|
||||
function setupFileUpload() {
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
|
||||
// Drag and drop event handling
|
||||
dropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('active');
|
||||
});
|
||||
|
||||
dropzone.addEventListener('dragleave', () => {
|
||||
dropzone.classList.remove('active');
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('active');
|
||||
await handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
await handleFiles(e.target.files);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFiles(files) {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append('file', file);
|
||||
}
|
||||
const statusDiv = document.getElementById('fileUploadStatus');
|
||||
|
||||
|
||||
statusDiv.className = 'status-indicator';
|
||||
statusDiv.textContent = '';
|
||||
try {
|
||||
showStatus('loading', 'UPLOADING...', statusDiv);
|
||||
const response = await fetch(`/documents/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const result = await response.json();
|
||||
showStatus('success', `✅ ${result.message} `, statusDiv);
|
||||
updateFileList();
|
||||
} catch (error) {
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function updateFileList() {
|
||||
const fileList = document.querySelector('.file-list');
|
||||
try {
|
||||
const status = await apiRequest('/health');
|
||||
fileList.innerHTML = `
|
||||
<div>INDEXED FILE: ${status.indexed_files_count}</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
fileList.innerHTML = 'UNABLE TO OBTAIN FILE LIST';
|
||||
}
|
||||
}
|
||||
|
||||
// Intelligent retrieval processing
|
||||
function setupQueryHandler() {
|
||||
document.querySelector('#query .btn-primary').addEventListener('click', handleQuery);
|
||||
}
|
||||
|
||||
async function handleQuery() {
|
||||
const queryInput = document.querySelector('#query textarea');
|
||||
const modeSelect = document.querySelector('#query select');
|
||||
const streamCheckbox = document.querySelector('#query input[type="checkbox"]');
|
||||
const resultsDiv = document.querySelector('#query .results');
|
||||
|
||||
const payload = {
|
||||
query: queryInput.value,
|
||||
mode: modeSelect.value,
|
||||
stream: streamCheckbox.checked
|
||||
};
|
||||
|
||||
resultsDiv.innerHTML = '<div class="loading">SEARCHING...</div>';
|
||||
|
||||
try {
|
||||
if (payload.stream) {
|
||||
await handleStreamingQuery(payload, resultsDiv);
|
||||
} else {
|
||||
const result = await apiRequest('/query', 'POST', payload);
|
||||
resultsDiv.innerHTML = `<div class="result">${result.response}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultsDiv.innerHTML = `<div class="error">SEARCH FAILED: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// handle stream api
|
||||
async function handleStreamingQuery(payload, resultsDiv) {
|
||||
try {
|
||||
const response = await fetch(`/query/stream`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
const validTypes = ['application/x-ndjson', 'application/json'];
|
||||
if (!validTypes.some(t => contentType.includes(t))) {
|
||||
throw new Error(`INVALID CONTENT TYPE: ${contentType}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
|
||||
// Split by line break (NDJSON format requirement)
|
||||
let lineEndIndex;
|
||||
while ((lineEndIndex = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, lineEndIndex).trim();
|
||||
buffer = buffer.slice(lineEndIndex + 1);
|
||||
|
||||
if (!line) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
|
||||
if (data.response) {
|
||||
resultsDiv.innerHTML += data.response;
|
||||
resultsDiv.scrollTop = resultsDiv.scrollHeight;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
resultsDiv.innerHTML += `<div class="error">${data.error}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('JSON PARSING FAILED:', {
|
||||
error,
|
||||
rawLine: line,
|
||||
bufferRemaining: buffer
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining data
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(buffer.trim());
|
||||
if (data.response) {
|
||||
resultsDiv.innerHTML += data.response;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('TAIL DATA PARSING FAILED:', error);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
resultsDiv.innerHTML = `<div class="error">REQUEST FAILED: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Knowledge Q&A Processing
|
||||
function setupChatHandler() {
|
||||
const sendButton = document.querySelector('#chat button');
|
||||
const chatInput = document.querySelector('#chat input');
|
||||
|
||||
sendButton.addEventListener('click', () => handleChat(chatInput));
|
||||
chatInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') handleChat(chatInput);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChat(chatInput) {
|
||||
const chatHistory = document.querySelector('#chat .chat-history');
|
||||
|
||||
|
||||
const userMessage = document.createElement('div');
|
||||
userMessage.className = 'message user';
|
||||
userMessage.textContent = chatInput.value;
|
||||
chatHistory.appendChild(userMessage);
|
||||
|
||||
|
||||
const botMessage = document.createElement('div');
|
||||
botMessage.className = 'message bot loading';
|
||||
botMessage.textContent = 'THINKING...';
|
||||
chatHistory.appendChild(botMessage);
|
||||
chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
messages: [{role: "user", content: chatInput.value}],
|
||||
stream: true
|
||||
})
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
botMessage.classList.remove('loading');
|
||||
botMessage.textContent = '';
|
||||
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const data = JSON.parse(chunk);
|
||||
botMessage.textContent += data.message?.content || '';
|
||||
chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||
}
|
||||
} catch (error) {
|
||||
botMessage.textContent = `ERROR: ${error.message}`;
|
||||
botMessage.classList.add('error');
|
||||
}
|
||||
|
||||
chatInput.value = '';
|
||||
}
|
||||
|
||||
// system status update
|
||||
async function updateSystemStatus() {
|
||||
const statusElements = {
|
||||
health: document.getElementById('healthStatus'),
|
||||
storageProgress: document.getElementById('storageProgress'),
|
||||
indexedFiles: document.getElementById('indexedFiles'),
|
||||
storageUsage: document.getElementById('storageUsage'),
|
||||
llmModel: document.getElementById('llmModel'),
|
||||
embedModel: document.getElementById('embedModel'),
|
||||
maxTokens: document.getElementById('maxTokens'),
|
||||
workingDir: document.getElementById('workingDir'),
|
||||
inputDir: document.getElementById('inputDir'),
|
||||
kv_storage: document.getElementById("kv_storage"),
|
||||
doc_status_storage: document.getElementById("doc_status_storage"),
|
||||
graph_storage: document.getElementById("graph_storage"),
|
||||
vector_storage: document.getElementById("vector_storage")
|
||||
};
|
||||
|
||||
try {
|
||||
const status = await apiRequest('/health');
|
||||
|
||||
// heath status
|
||||
statusElements.health.className = 'status-badge';
|
||||
statusElements.health.textContent = status.status === 'healthy' ?
|
||||
'✅ Healthy operation in progress' : '⚠️ Service exception';
|
||||
|
||||
// kv status
|
||||
const progressValue = Math.min(Math.round((status.indexed_files_count / 1000) * 100), 100);
|
||||
statusElements.storageProgress.value = progressValue;
|
||||
statusElements.indexedFiles.textContent = `INDEXED FILES:${status.indexed_files_count}`;
|
||||
statusElements.storageUsage.textContent = `USE PERCENT:${progressValue}%`;
|
||||
|
||||
// model state
|
||||
statusElements.llmModel.textContent = `${status.configuration.llm_model} (${status.configuration.llm_binding})`;
|
||||
statusElements.embedModel.textContent = `${status.configuration.embedding_model} (${status.configuration.embedding_binding})`;
|
||||
statusElements.maxTokens.textContent = status.configuration.max_tokens.toLocaleString();
|
||||
|
||||
// dir msg
|
||||
statusElements.workingDir.textContent = status.working_directory;
|
||||
statusElements.inputDir.textContent = status.input_directory;
|
||||
|
||||
// stack msg
|
||||
statusElements.kv_storage.textContent = status.configuration.kv_storage;
|
||||
statusElements.doc_status_storage.textContent = status.configuration.doc_status_storage;
|
||||
statusElements.graph_storage.textContent = status.configuration.graph_storage;
|
||||
statusElements.vector_storage.textContent = status.configuration.vector_storage
|
||||
|
||||
} catch (error) {
|
||||
statusElements.health.className = 'status-badge error';
|
||||
statusElements.health.textContent = '❌GET STATUS FAILED';
|
||||
statusElements.storageProgress.value = 0;
|
||||
statusElements.indexedFiles.textContent = 'INDEXED FILES:GET FAILED';
|
||||
console.error('STATUS UPDATE FAILED:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Area switching monitoring
|
||||
function setupSectionObserver() {
|
||||
const observer = new MutationObserver(mutations => {
|
||||
mutations.forEach(mutation => {
|
||||
if (mutation.attributeName === 'style') {
|
||||
const isVisible = mutation.target.style.display !== 'none';
|
||||
if (isVisible && mutation.target.id === 'status') {
|
||||
updateSystemStatus();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.card').forEach(section => {
|
||||
observer.observe(section, {attributes: true});
|
||||
});
|
||||
}
|
||||
|
||||
// Display prompt information
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
|
||||
// Dynamically load tag list
|
||||
async function loadLabels() {
|
||||
try {
|
||||
const response = await fetch('/graph/label/list');
|
||||
const labels = await response.json();
|
||||
renderLabels(labels);
|
||||
} catch (error) {
|
||||
console.error('DYNAMICALLY LOAD TAG LIST FAILED:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGraph(label) {
|
||||
try {
|
||||
// render label list
|
||||
openGraphModal(label)
|
||||
} catch (error) {
|
||||
console.error('LOADING LABEL FAILED:', error);
|
||||
|
||||
|
||||
const labelList = document.getElementById("label-list");
|
||||
labelList.innerHTML = `
|
||||
<div class="error-message">
|
||||
LOADING ERROR: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// render graph label list
|
||||
function renderLabels(labels) {
|
||||
const container = document.getElementById('label-list');
|
||||
container.innerHTML = labels.map(label => `
|
||||
<div class="label-item">
|
||||
<span style="font-weight: 500; color: var(--text-primary);">
|
||||
${label}
|
||||
</span>
|
||||
<div class="label-actions">
|
||||
<button class="btn btn-primary"
|
||||
onclick="handleLabelAction('${label}')">
|
||||
📋 graph
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
|
||||
function handleLabelAction(label) {
|
||||
loadGraph(label)
|
||||
}
|
||||
|
||||
|
||||
function refreshLabels() {
|
||||
showToast('LOADING GRAPH LABELS...', 'info');
|
||||
loadLabels();
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializeApp);
|
@@ -13,6 +13,7 @@ from typing import (
|
||||
import numpy as np
|
||||
|
||||
from .utils import EmbeddingFunc
|
||||
from .types import KnowledgeGraph
|
||||
|
||||
|
||||
class TextChunkSchema(TypedDict):
|
||||
@@ -175,7 +176,7 @@ class BaseGraphStorage(StorageNameSpace):
|
||||
|
||||
async def get_knowledge_graph(
|
||||
self, node_label: str, max_depth: int = 5
|
||||
) -> dict[str, list[dict]]:
|
||||
) -> KnowledgeGraph:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
|
@@ -14,7 +14,7 @@ from pymilvus import MilvusClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class MilvusVectorDBStorge(BaseVectorStorage):
|
||||
class MilvusVectorDBStorage(BaseVectorStorage):
|
||||
@staticmethod
|
||||
def create_collection_if_not_exist(
|
||||
client: MilvusClient, collection_name: str, **kwargs
|
||||
@@ -37,7 +37,7 @@ class MilvusVectorDBStorge(BaseVectorStorage):
|
||||
db_name=os.environ.get("MILVUS_DB_NAME", ""),
|
||||
)
|
||||
self._max_batch_size = self.global_config["embedding_batch_num"]
|
||||
MilvusVectorDBStorge.create_collection_if_not_exist(
|
||||
MilvusVectorDBStorage.create_collection_if_not_exist(
|
||||
self._client,
|
||||
self.namespace,
|
||||
dimension=self.embedding_func.embedding_dim,
|
||||
|
@@ -25,6 +25,7 @@ from tenacity import (
|
||||
|
||||
from ..utils import logger
|
||||
from ..base import BaseGraphStorage
|
||||
from ..types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -365,7 +366,7 @@ class Neo4JStorage(BaseGraphStorage):
|
||||
|
||||
async def get_knowledge_graph(
|
||||
self, node_label: str, max_depth: int = 5
|
||||
) -> Dict[str, List[Dict]]:
|
||||
) -> KnowledgeGraph:
|
||||
"""
|
||||
Get complete connected subgraph for specified node (including the starting node itself)
|
||||
|
||||
@@ -376,32 +377,41 @@ class Neo4JStorage(BaseGraphStorage):
|
||||
4. Add depth control
|
||||
"""
|
||||
label = node_label.strip('"')
|
||||
result = {"nodes": [], "edges": []}
|
||||
result = KnowledgeGraph()
|
||||
seen_nodes = set()
|
||||
seen_edges = set()
|
||||
|
||||
async with self._driver.session(database=self._DATABASE) as session:
|
||||
try:
|
||||
# Critical debug step: first verify if starting node exists
|
||||
validate_query = f"MATCH (n:`{label}`) RETURN n LIMIT 1"
|
||||
validate_result = await session.run(validate_query)
|
||||
if not await validate_result.single():
|
||||
logger.warning(f"Starting node {label} does not exist!")
|
||||
return result
|
||||
main_query = ""
|
||||
if label == "*":
|
||||
main_query = """
|
||||
MATCH (n)
|
||||
WITH collect(DISTINCT n) AS nodes
|
||||
MATCH ()-[r]-()
|
||||
RETURN nodes, collect(DISTINCT r) AS relationships;
|
||||
"""
|
||||
else:
|
||||
# Critical debug step: first verify if starting node exists
|
||||
validate_query = f"MATCH (n:`{label}`) RETURN n LIMIT 1"
|
||||
validate_result = await session.run(validate_query)
|
||||
if not await validate_result.single():
|
||||
logger.warning(f"Starting node {label} does not exist!")
|
||||
return result
|
||||
|
||||
# Optimized query (including direction handling and self-loops)
|
||||
main_query = f"""
|
||||
MATCH (start:`{label}`)
|
||||
WITH start
|
||||
CALL apoc.path.subgraphAll(start, {{
|
||||
relationshipFilter: '>',
|
||||
minLevel: 0,
|
||||
maxLevel: {max_depth},
|
||||
bfs: true
|
||||
}})
|
||||
YIELD nodes, relationships
|
||||
RETURN nodes, relationships
|
||||
"""
|
||||
# Optimized query (including direction handling and self-loops)
|
||||
main_query = f"""
|
||||
MATCH (start:`{label}`)
|
||||
WITH start
|
||||
CALL apoc.path.subgraphAll(start, {{
|
||||
relationshipFilter: '>',
|
||||
minLevel: 0,
|
||||
maxLevel: {max_depth},
|
||||
bfs: true
|
||||
}})
|
||||
YIELD nodes, relationships
|
||||
RETURN nodes, relationships
|
||||
"""
|
||||
result_set = await session.run(main_query)
|
||||
record = await result_set.single()
|
||||
|
||||
@@ -409,35 +419,36 @@ class Neo4JStorage(BaseGraphStorage):
|
||||
# Handle nodes (compatible with multi-label cases)
|
||||
for node in record["nodes"]:
|
||||
# Use node ID + label combination as unique identifier
|
||||
node_id = f"{node.id}_{'_'.join(node.labels)}"
|
||||
node_id = node.id
|
||||
if node_id not in seen_nodes:
|
||||
node_data = dict(node)
|
||||
node_data["labels"] = list(node.labels) # Keep all labels
|
||||
result["nodes"].append(node_data)
|
||||
result.nodes.append(
|
||||
KnowledgeGraphNode(
|
||||
id=f"{node_id}",
|
||||
labels=list(node.labels),
|
||||
properties=dict(node),
|
||||
)
|
||||
)
|
||||
seen_nodes.add(node_id)
|
||||
|
||||
# Handle relationships (including direction information)
|
||||
for rel in record["relationships"]:
|
||||
edge_id = f"{rel.id}_{rel.type}"
|
||||
edge_id = rel.id
|
||||
if edge_id not in seen_edges:
|
||||
start = rel.start_node
|
||||
end = rel.end_node
|
||||
edge_data = dict(rel)
|
||||
edge_data.update(
|
||||
{
|
||||
"source": f"{start.id}_{'_'.join(start.labels)}",
|
||||
"target": f"{end.id}_{'_'.join(end.labels)}",
|
||||
"type": rel.type,
|
||||
"direction": rel.element_id.split(
|
||||
"->" if rel.end_node == end else "<-"
|
||||
)[1],
|
||||
}
|
||||
result.edges.append(
|
||||
KnowledgeGraphEdge(
|
||||
id=f"{edge_id}",
|
||||
type=rel.type,
|
||||
source=f"{start.id}",
|
||||
target=f"{end.id}",
|
||||
properties=dict(rel),
|
||||
)
|
||||
)
|
||||
result["edges"].append(edge_data)
|
||||
seen_edges.add(edge_id)
|
||||
|
||||
logger.info(
|
||||
f"Subgraph query successful | Node count: {len(result['nodes'])} | Edge count: {len(result['edges'])}"
|
||||
f"Subgraph query successful | Node count: {len(result.nodes)} | Edge count: {len(result.edges)}"
|
||||
)
|
||||
|
||||
except neo4jExceptions.ClientError as e:
|
||||
|
@@ -34,17 +34,18 @@ from .utils import (
|
||||
logger,
|
||||
set_logger,
|
||||
)
|
||||
from .types import KnowledgeGraph
|
||||
|
||||
STORAGES = {
|
||||
"NetworkXStorage": ".kg.networkx_impl",
|
||||
"JsonKVStorage": ".kg.json_kv_impl",
|
||||
"NanoVectorDBStorage": ".kg.nano_vector_db_impl",
|
||||
"JsonDocStatusStorage": ".kg.jsondocstatus_impl",
|
||||
"JsonDocStatusStorage": ".kg.json_doc_status_impl",
|
||||
"Neo4JStorage": ".kg.neo4j_impl",
|
||||
"OracleKVStorage": ".kg.oracle_impl",
|
||||
"OracleGraphStorage": ".kg.oracle_impl",
|
||||
"OracleVectorDBStorage": ".kg.oracle_impl",
|
||||
"MilvusVectorDBStorge": ".kg.milvus_impl",
|
||||
"MilvusVectorDBStorage": ".kg.milvus_impl",
|
||||
"MongoKVStorage": ".kg.mongo_impl",
|
||||
"MongoDocStatusStorage": ".kg.mongo_impl",
|
||||
"MongoGraphStorage": ".kg.mongo_impl",
|
||||
@@ -385,7 +386,9 @@ class LightRAG:
|
||||
text = await self.chunk_entity_relation_graph.get_all_labels()
|
||||
return text
|
||||
|
||||
async def get_graps(self, nodel_label: str, max_depth: int):
|
||||
async def get_knowledge_graph(
|
||||
self, nodel_label: str, max_depth: int
|
||||
) -> KnowledgeGraph:
|
||||
return await self.chunk_entity_relation_graph.get_knowledge_graph(
|
||||
node_label=nodel_label, max_depth=max_depth
|
||||
)
|
||||
|
@@ -1,7 +1,26 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
class GPTKeywordExtractionFormat(BaseModel):
|
||||
high_level_keywords: List[str]
|
||||
low_level_keywords: List[str]
|
||||
|
||||
|
||||
class KnowledgeGraphNode(BaseModel):
|
||||
id: str
|
||||
labels: List[str]
|
||||
properties: Dict[str, Any] # anything else goes here
|
||||
|
||||
|
||||
class KnowledgeGraphEdge(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
source: str # id of source node
|
||||
target: str # id of target node
|
||||
properties: Dict[str, Any] # anything else goes here
|
||||
|
||||
|
||||
class KnowledgeGraph(BaseModel):
|
||||
nodes: List[KnowledgeGraphNode] = []
|
||||
edges: List[KnowledgeGraphEdge] = []
|
||||
|
24
lightrag_webui/.gitignore
vendored
Normal file
24
lightrag_webui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
10
lightrag_webui/.prettierrc.json
Normal file
10
lightrag_webui/.prettierrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none",
|
||||
"endOfLine": "crlf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
46
lightrag_webui/README.md
Normal file
46
lightrag_webui/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# LightRAG WebUI
|
||||
|
||||
LightRAG WebUI is a React-based web interface for interacting with the LightRAG system. It provides a user-friendly interface for querying, managing, and exploring LightRAG's functionalities.
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Install Bun:**
|
||||
|
||||
If you haven't already installed Bun, follow the official documentation: [https://bun.sh/docs/installation](https://bun.sh/docs/installation)
|
||||
|
||||
2. **Install Dependencies:**
|
||||
|
||||
In the `lightrag_webui` directory, run the following command to install project dependencies:
|
||||
|
||||
```bash
|
||||
bun install --frozen-lockfile
|
||||
```
|
||||
|
||||
3. **Build the Project:**
|
||||
|
||||
Run the following command to build the project:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
This command will bundle the project and output the built files to the `lightrag/api/webui` directory.
|
||||
|
||||
## Development
|
||||
|
||||
- **Start the Development Server:**
|
||||
|
||||
If you want to run the WebUI in development mode, use the following command:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Script Commands
|
||||
|
||||
The following are some commonly used script commands defined in `package.json`:
|
||||
|
||||
- `bun install`: Installs project dependencies.
|
||||
- `bun run dev`: Starts the development server.
|
||||
- `bun run build`: Builds the project.
|
||||
- `bun run lint`: Runs the linter.
|
1019
lightrag_webui/bun.lock
Normal file
1019
lightrag_webui/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
21
lightrag_webui/components.json
Normal file
21
lightrag_webui/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
33
lightrag_webui/eslint.config.js
Normal file
33
lightrag_webui/eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import stylisticJs from '@stylistic/eslint-plugin-js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({ ignores: ['dist'] }, prettier, {
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser
|
||||
},
|
||||
settings: { react: { version: '19.0' } },
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
'@stylistic/js': stylisticJs,
|
||||
react
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
'@stylistic/js/indent': ['error', 2],
|
||||
'@stylistic/js/quotes': ['error', 'single'],
|
||||
'@typescript-eslint/no-explicit-any': ['off']
|
||||
}
|
||||
})
|
13
lightrag_webui/index.html
Normal file
13
lightrag_webui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lightrag Graph Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
70
lightrag_webui/package.json
Normal file
70
lightrag_webui/package.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "lightrag-webui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite",
|
||||
"build": "bunx --bun vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "bunx --bun vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^9.4.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@react-sigma/core": "^5.0.2",
|
||||
"@react-sigma/graph-search": "^5.0.3",
|
||||
"@react-sigma/layout-circlepack": "^5.0.2",
|
||||
"@react-sigma/layout-circular": "^5.0.2",
|
||||
"@react-sigma/layout-force": "^5.0.2",
|
||||
"@react-sigma/layout-forceatlas2": "^5.0.2",
|
||||
"@react-sigma/layout-noverlap": "^5.0.2",
|
||||
"@react-sigma/layout-random": "^5.0.2",
|
||||
"@react-sigma/minimap": "^5.0.2",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@sigma/node-border": "^3.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-generators": "^0.11.2",
|
||||
"lucide-react": "^0.475.0",
|
||||
"minisearch": "^7.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"seedrandom": "^3.0.5",
|
||||
"sigma": "^3.0.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@stylistic/eslint-plugin-js": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@types/bun": "^1.2.2",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"eslint": "^9.20.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.14.0",
|
||||
"graphology-types": "^0.24.8",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "~5.7.3",
|
||||
"typescript-eslint": "^8.24.0",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
1
lightrag_webui/public/vite.svg
Normal file
1
lightrag_webui/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
38
lightrag_webui/src/App.tsx
Normal file
38
lightrag_webui/src/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import ThemeProvider from '@/components/ThemeProvider'
|
||||
import MessageAlert from '@/components/MessageAlert'
|
||||
import StatusIndicator from '@/components/StatusIndicator'
|
||||
import GraphViewer from '@/GraphViewer'
|
||||
import { healthCheckInterval } from '@/lib/constants'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
function App() {
|
||||
const message = useBackendState.use.message()
|
||||
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
||||
|
||||
// health check
|
||||
useEffect(() => {
|
||||
if (!enableHealthCheck) return
|
||||
|
||||
// Check immediately
|
||||
useBackendState.getState().check()
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
await useBackendState.getState().check()
|
||||
}, healthCheckInterval * 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [enableHealthCheck])
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<div className="h-screen w-screen">
|
||||
<GraphViewer />
|
||||
</div>
|
||||
{enableHealthCheck && <StatusIndicator />}
|
||||
{message !== null && <MessageAlert />}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
185
lightrag_webui/src/GraphViewer.tsx
Normal file
185
lightrag_webui/src/GraphViewer.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
// import { MiniMap } from '@react-sigma/minimap'
|
||||
import { SigmaContainer, useRegisterEvents, useSigma } from '@react-sigma/core'
|
||||
import { Settings as SigmaSettings } from 'sigma/settings'
|
||||
import { GraphSearchOption, OptionItem } from '@react-sigma/graph-search'
|
||||
import { EdgeArrowProgram, NodePointProgram, NodeCircleProgram } from 'sigma/rendering'
|
||||
import { NodeBorderProgram } from '@sigma/node-border'
|
||||
import EdgeCurveProgram, { EdgeCurvedArrowProgram } from '@sigma/edge-curve'
|
||||
|
||||
import FocusOnNode from '@/components/FocusOnNode'
|
||||
import LayoutsControl from '@/components/LayoutsControl'
|
||||
import GraphControl from '@/components/GraphControl'
|
||||
import ThemeToggle from '@/components/ThemeToggle'
|
||||
import ZoomControl from '@/components/ZoomControl'
|
||||
import FullScreenControl from '@/components/FullScreenControl'
|
||||
import Settings from '@/components/Settings'
|
||||
import GraphSearch from '@/components/GraphSearch'
|
||||
import GraphLabels from '@/components/GraphLabels'
|
||||
import PropertiesView from '@/components/PropertiesView'
|
||||
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
import '@react-sigma/core/lib/style.css'
|
||||
import '@react-sigma/graph-search/lib/style.css'
|
||||
|
||||
// Sigma settings
|
||||
const defaultSigmaSettings: Partial<SigmaSettings> = {
|
||||
allowInvalidContainer: true,
|
||||
defaultNodeType: 'default',
|
||||
defaultEdgeType: 'curvedArrow',
|
||||
renderEdgeLabels: false,
|
||||
edgeProgramClasses: {
|
||||
arrow: EdgeArrowProgram,
|
||||
curvedArrow: EdgeCurvedArrowProgram,
|
||||
curvedNoArrow: EdgeCurveProgram
|
||||
},
|
||||
nodeProgramClasses: {
|
||||
default: NodeBorderProgram,
|
||||
circel: NodeCircleProgram,
|
||||
point: NodePointProgram
|
||||
},
|
||||
labelGridCellSize: 60,
|
||||
labelRenderedSizeThreshold: 12,
|
||||
enableEdgeEvents: true,
|
||||
labelColor: {
|
||||
color: '#000',
|
||||
attribute: 'labelColor'
|
||||
},
|
||||
edgeLabelColor: {
|
||||
color: '#000',
|
||||
attribute: 'labelColor'
|
||||
},
|
||||
edgeLabelSize: 8,
|
||||
labelSize: 12
|
||||
// minEdgeThickness: 2
|
||||
// labelFont: 'Lato, sans-serif'
|
||||
}
|
||||
|
||||
const GraphEvents = () => {
|
||||
const registerEvents = useRegisterEvents()
|
||||
const sigma = useSigma()
|
||||
const [draggedNode, setDraggedNode] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Register the events
|
||||
registerEvents({
|
||||
downNode: (e) => {
|
||||
setDraggedNode(e.node)
|
||||
sigma.getGraph().setNodeAttribute(e.node, 'highlighted', true)
|
||||
},
|
||||
// On mouse move, if the drag mode is enabled, we change the position of the draggedNode
|
||||
mousemovebody: (e) => {
|
||||
if (!draggedNode) return
|
||||
// Get new position of node
|
||||
const pos = sigma.viewportToGraph(e)
|
||||
sigma.getGraph().setNodeAttribute(draggedNode, 'x', pos.x)
|
||||
sigma.getGraph().setNodeAttribute(draggedNode, 'y', pos.y)
|
||||
|
||||
// Prevent sigma to move camera:
|
||||
e.preventSigmaDefault()
|
||||
e.original.preventDefault()
|
||||
e.original.stopPropagation()
|
||||
},
|
||||
// On mouse up, we reset the autoscale and the dragging mode
|
||||
mouseup: () => {
|
||||
if (draggedNode) {
|
||||
setDraggedNode(null)
|
||||
sigma.getGraph().removeNodeAttribute(draggedNode, 'highlighted')
|
||||
}
|
||||
},
|
||||
// Disable the autoscale at the first down interaction
|
||||
mousedown: () => {
|
||||
if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox())
|
||||
}
|
||||
})
|
||||
}, [registerEvents, sigma, draggedNode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const GraphViewer = () => {
|
||||
const [sigmaSettings, setSigmaSettings] = useState(defaultSigmaSettings)
|
||||
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const moveToSelectedNode = useGraphStore.use.moveToSelectedNode()
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
const renderLabels = useSettingsStore.use.showNodeLabel()
|
||||
|
||||
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
|
||||
|
||||
useEffect(() => {
|
||||
setSigmaSettings({
|
||||
...defaultSigmaSettings,
|
||||
enableEdgeEvents,
|
||||
renderEdgeLabels,
|
||||
renderLabels
|
||||
})
|
||||
}, [renderLabels, enableEdgeEvents, renderEdgeLabels])
|
||||
|
||||
const onSearchFocus = useCallback((value: GraphSearchOption | null) => {
|
||||
if (value === null) useGraphStore.getState().setFocusedNode(null)
|
||||
else if (value.type === 'nodes') useGraphStore.getState().setFocusedNode(value.id)
|
||||
}, [])
|
||||
|
||||
const onSearchSelect = useCallback((value: GraphSearchOption | null) => {
|
||||
if (value === null) {
|
||||
useGraphStore.getState().setSelectedNode(null)
|
||||
} else if (value.type === 'nodes') {
|
||||
useGraphStore.getState().setSelectedNode(value.id, true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const autoFocusedNode = useMemo(() => focusedNode ?? selectedNode, [focusedNode, selectedNode])
|
||||
const searchInitSelectedNode = useMemo(
|
||||
(): OptionItem | null => (selectedNode ? { type: 'nodes', id: selectedNode } : null),
|
||||
[selectedNode]
|
||||
)
|
||||
|
||||
return (
|
||||
<SigmaContainer settings={sigmaSettings} className="!bg-background !size-full overflow-hidden">
|
||||
<GraphControl />
|
||||
|
||||
{enableNodeDrag && <GraphEvents />}
|
||||
|
||||
<FocusOnNode node={autoFocusedNode} move={moveToSelectedNode} />
|
||||
|
||||
<div className="absolute top-2 left-2 flex items-start gap-2">
|
||||
<GraphLabels />
|
||||
{showNodeSearchBar && (
|
||||
<GraphSearch
|
||||
value={searchInitSelectedNode}
|
||||
onFocus={onSearchFocus}
|
||||
onChange={onSearchSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background/60 absolute bottom-2 left-2 flex flex-col rounded-xl border-2 backdrop-blur-lg">
|
||||
<Settings />
|
||||
<ZoomControl />
|
||||
<LayoutsControl />
|
||||
<FullScreenControl />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{showPropertyPanel && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<PropertiesView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className="absolute bottom-2 right-2 flex flex-col rounded-xl border-2">
|
||||
<MiniMap width="100px" height="100px" />
|
||||
</div> */}
|
||||
</SigmaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default GraphViewer
|
278
lightrag_webui/src/api/lightrag.ts
Normal file
278
lightrag_webui/src/api/lightrag.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { backendBaseUrl } from '@/lib/constants'
|
||||
import { errorMessage } from '@/lib/utils'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
// Types
|
||||
export type LightragNodeType = {
|
||||
id: string
|
||||
labels: string[]
|
||||
properties: Record<string, any>
|
||||
}
|
||||
|
||||
export type LightragEdgeType = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
type: string
|
||||
properties: Record<string, any>
|
||||
}
|
||||
|
||||
export type LightragGraphType = {
|
||||
nodes: LightragNodeType[]
|
||||
edges: LightragEdgeType[]
|
||||
}
|
||||
|
||||
export type LightragStatus = {
|
||||
status: 'healthy'
|
||||
working_directory: string
|
||||
input_directory: string
|
||||
indexed_files: string[]
|
||||
indexed_files_count: number
|
||||
configuration: {
|
||||
llm_binding: string
|
||||
llm_binding_host: string
|
||||
llm_model: string
|
||||
embedding_binding: string
|
||||
embedding_binding_host: string
|
||||
embedding_model: string
|
||||
max_tokens: number
|
||||
kv_storage: string
|
||||
doc_status_storage: string
|
||||
graph_storage: string
|
||||
vector_storage: string
|
||||
}
|
||||
}
|
||||
|
||||
export type LightragDocumentsScanProgress = {
|
||||
is_scanning: boolean
|
||||
current_file: string
|
||||
indexed_count: number
|
||||
total_files: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
export type QueryMode = 'naive' | 'local' | 'global' | 'hybrid' | 'mix'
|
||||
|
||||
export type QueryRequest = {
|
||||
query: string
|
||||
mode: QueryMode
|
||||
stream?: boolean
|
||||
only_need_context?: boolean
|
||||
}
|
||||
|
||||
export type QueryResponse = {
|
||||
response: string
|
||||
}
|
||||
|
||||
export const InvalidApiKeyError = 'Invalid API Key'
|
||||
export const RequireApiKeError = 'API Key required'
|
||||
|
||||
// Helper functions
|
||||
const getResponseContent = async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (contentType) {
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json()
|
||||
return JSON.stringify(data, undefined, 2)
|
||||
} else if (contentType.startsWith('text/')) {
|
||||
return await response.text()
|
||||
} else if (contentType.includes('application/xml') || contentType.includes('text/xml')) {
|
||||
return await response.text()
|
||||
} else if (contentType.includes('application/octet-stream')) {
|
||||
const buffer = await response.arrayBuffer()
|
||||
const decoder = new TextDecoder('utf-8', { fatal: false, ignoreBOM: true })
|
||||
return decoder.decode(buffer)
|
||||
} else {
|
||||
try {
|
||||
return await response.text()
|
||||
} catch (error) {
|
||||
console.warn('Failed to decode as text, may be binary:', error)
|
||||
return `[Could not decode response body. Content-Type: ${contentType}]`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return await response.text()
|
||||
} catch (error) {
|
||||
console.warn('Failed to decode as text, may be binary:', error)
|
||||
return '[Could not decode response body. No Content-Type header.]'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const fetchWithAuth = async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||
const apiKey = useSettingsStore.getState().apiKey
|
||||
const headers = {
|
||||
...(options.headers || {}),
|
||||
...(apiKey ? { 'X-API-Key': apiKey } : {})
|
||||
}
|
||||
|
||||
const response = await fetch(backendBaseUrl + url, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${response.status} ${response.statusText}\n${await getResponseContent(response)}\n${response.url}`
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// API methods
|
||||
export const queryGraphs = async (label: string): Promise<LightragGraphType> => {
|
||||
const response = await fetchWithAuth(`/graphs?label=${label}`)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const getGraphLabels = async (): Promise<string[]> => {
|
||||
const response = await fetchWithAuth('/graph/label/list')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const checkHealth = async (): Promise<
|
||||
LightragStatus | { status: 'error'; message: string }
|
||||
> => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/health')
|
||||
return await response.json()
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: errorMessage(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getDocuments = async (): Promise<string[]> => {
|
||||
const response = await fetchWithAuth('/documents')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const getDocumentsScanProgress = async (): Promise<LightragDocumentsScanProgress> => {
|
||||
const response = await fetchWithAuth('/documents/scan-progress')
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const uploadDocument = async (
|
||||
file: File
|
||||
): Promise<{
|
||||
status: string
|
||||
message: string
|
||||
total_documents: number
|
||||
}> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetchWithAuth('/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const startDocumentScan = async (): Promise<{ status: string }> => {
|
||||
const response = await fetchWithAuth('/documents/scan', {
|
||||
method: 'POST'
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const queryText = async (request: QueryRequest): Promise<QueryResponse> => {
|
||||
const response = await fetchWithAuth('/query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const queryTextStream = async (request: QueryRequest, onChunk: (chunk: string) => void) => {
|
||||
const response = await fetchWithAuth('/query/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
})
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line) {
|
||||
try {
|
||||
const data = JSON.parse(line)
|
||||
if (data.response) {
|
||||
onChunk(data.response)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing stream chunk:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text insertion API
|
||||
export const insertText = async (
|
||||
text: string,
|
||||
description?: string
|
||||
): Promise<{
|
||||
status: string
|
||||
message: string
|
||||
document_count: number
|
||||
}> => {
|
||||
const response = await fetchWithAuth('/documents/text', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ text, description })
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Batch file upload API
|
||||
export const uploadBatchDocuments = async (
|
||||
files: File[]
|
||||
): Promise<{
|
||||
status: string
|
||||
message: string
|
||||
document_count: number
|
||||
}> => {
|
||||
const formData = new FormData()
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
const response = await fetchWithAuth('/documents/batch', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Clear all documents API
|
||||
export const clearDocuments = async (): Promise<{
|
||||
status: string
|
||||
message: string
|
||||
document_count: number
|
||||
}> => {
|
||||
const response = await fetchWithAuth('/documents', {
|
||||
method: 'DELETE'
|
||||
})
|
||||
return await response.json()
|
||||
}
|
31
lightrag_webui/src/components/FocusOnNode.tsx
Normal file
31
lightrag_webui/src/components/FocusOnNode.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useCamera, useSigma } from '@react-sigma/core'
|
||||
import { useEffect } from 'react'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
/**
|
||||
* Component that highlights a node and centers the camera on it.
|
||||
*/
|
||||
const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) => {
|
||||
const sigma = useSigma()
|
||||
const { gotoNode } = useCamera()
|
||||
|
||||
/**
|
||||
* When the selected item changes, highlighted the node and center the camera on it.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!node) return
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
|
||||
if (move) {
|
||||
gotoNode(node)
|
||||
useGraphStore.getState().setMoveToSelectedNode(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
|
||||
}
|
||||
}, [node, move, sigma, gotoNode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default FocusOnNode
|
27
lightrag_webui/src/components/FullScreenControl.tsx
Normal file
27
lightrag_webui/src/components/FullScreenControl.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useFullScreen } from '@react-sigma/core'
|
||||
import { MaximizeIcon, MinimizeIcon } from 'lucide-react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import Button from '@/components/ui/Button'
|
||||
|
||||
/**
|
||||
* Component that toggles full screen mode.
|
||||
*/
|
||||
const FullScreenControl = () => {
|
||||
const { isFullScreen, toggle } = useFullScreen()
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFullScreen ? (
|
||||
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Windowed" size="icon">
|
||||
<MinimizeIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant={controlButtonVariant} onClick={toggle} tooltip="Full Screen" size="icon">
|
||||
<MaximizeIcon />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullScreenControl
|
185
lightrag_webui/src/components/GraphControl.tsx
Normal file
185
lightrag_webui/src/components/GraphControl.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
|
||||
// import { useLayoutCircular } from '@react-sigma/layout-circular'
|
||||
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'
|
||||
import useLightragGraph, { EdgeType, NodeType } from '@/hooks/useLightragGraph'
|
||||
import useTheme from '@/hooks/useTheme'
|
||||
import * as Constants from '@/lib/constants'
|
||||
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
|
||||
if (ev.type.startsWith('mouse')) {
|
||||
if ((ev as MouseEvent).buttons !== 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {
|
||||
const { lightrageGraph } = useLightragGraph()
|
||||
const sigma = useSigma<NodeType, EdgeType>()
|
||||
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
|
||||
const setSettings = useSetSettings<NodeType, EdgeType>()
|
||||
const loadGraph = useLoadGraph<NodeType, EdgeType>()
|
||||
const { assign: assignLayout } = useLayoutForceAtlas2({
|
||||
iterations: 20
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const selectedEdge = useGraphStore.use.selectedEdge()
|
||||
const focusedEdge = useGraphStore.use.focusedEdge()
|
||||
|
||||
/**
|
||||
* When component mount
|
||||
* => load the graph
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Create & load the graph
|
||||
const graph = lightrageGraph()
|
||||
loadGraph(graph)
|
||||
if (!(graph as any).__force_applied) {
|
||||
assignLayout()
|
||||
Object.assign(graph, { __force_applied: true })
|
||||
}
|
||||
|
||||
const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =
|
||||
useGraphStore.getState()
|
||||
|
||||
// Register the events
|
||||
registerEvents({
|
||||
enterNode: (event) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedNode(event.node)
|
||||
}
|
||||
},
|
||||
leaveNode: (event) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedNode(null)
|
||||
}
|
||||
},
|
||||
clickNode: (event) => {
|
||||
setSelectedNode(event.node)
|
||||
setSelectedEdge(null)
|
||||
},
|
||||
clickEdge: (event) => {
|
||||
setSelectedEdge(event.edge)
|
||||
setSelectedNode(null)
|
||||
},
|
||||
enterEdge: (event) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedEdge(event.edge)
|
||||
}
|
||||
},
|
||||
leaveEdge: (event) => {
|
||||
if (!isButtonPressed(event.event.original)) {
|
||||
setFocusedEdge(null)
|
||||
}
|
||||
},
|
||||
clickStage: () => clearSelection()
|
||||
})
|
||||
}, [assignLayout, loadGraph, registerEvents, lightrageGraph])
|
||||
|
||||
/**
|
||||
* When component mount or hovered node change
|
||||
* => Setting the sigma reducers
|
||||
*/
|
||||
useEffect(() => {
|
||||
const isDarkTheme = theme === 'dark'
|
||||
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
|
||||
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
|
||||
|
||||
setSettings({
|
||||
nodeReducer: (node, data) => {
|
||||
const graph = sigma.getGraph()
|
||||
const newData: NodeType & {
|
||||
labelColor?: string
|
||||
borderColor?: string
|
||||
} = { ...data, highlighted: data.highlighted || false, labelColor }
|
||||
|
||||
if (!disableHoverEffect) {
|
||||
newData.highlighted = false
|
||||
const _focusedNode = focusedNode || selectedNode
|
||||
const _focusedEdge = focusedEdge || selectedEdge
|
||||
|
||||
if (_focusedNode) {
|
||||
if (node === _focusedNode || graph.neighbors(_focusedNode).includes(node)) {
|
||||
newData.highlighted = true
|
||||
if (node === selectedNode) {
|
||||
newData.borderColor = Constants.nodeBorderColorSelected
|
||||
}
|
||||
}
|
||||
} else if (_focusedEdge) {
|
||||
if (graph.extremities(_focusedEdge).includes(node)) {
|
||||
newData.highlighted = true
|
||||
newData.size = 3
|
||||
}
|
||||
} else {
|
||||
return newData
|
||||
}
|
||||
|
||||
if (newData.highlighted) {
|
||||
if (isDarkTheme) {
|
||||
newData.labelColor = Constants.LabelColorHighlightedDarkTheme
|
||||
}
|
||||
} else {
|
||||
newData.color = Constants.nodeColorDisabled
|
||||
}
|
||||
}
|
||||
return newData
|
||||
},
|
||||
edgeReducer: (edge, data) => {
|
||||
const graph = sigma.getGraph()
|
||||
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
|
||||
|
||||
if (!disableHoverEffect) {
|
||||
const _focusedNode = focusedNode || selectedNode
|
||||
|
||||
if (_focusedNode) {
|
||||
if (hideUnselectedEdges) {
|
||||
if (!graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.hidden = true
|
||||
}
|
||||
} else {
|
||||
if (graph.extremities(edge).includes(_focusedNode)) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (focusedEdge || selectedEdge) {
|
||||
if (edge === selectedEdge) {
|
||||
newData.color = Constants.edgeColorSelected
|
||||
} else if (edge === focusedEdge) {
|
||||
newData.color = Constants.edgeColorHighlighted
|
||||
} else if (hideUnselectedEdges) {
|
||||
newData.hidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newData
|
||||
}
|
||||
})
|
||||
}, [
|
||||
selectedNode,
|
||||
focusedNode,
|
||||
selectedEdge,
|
||||
focusedEdge,
|
||||
setSettings,
|
||||
sigma,
|
||||
disableHoverEffect,
|
||||
theme,
|
||||
hideUnselectedEdges
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default GraphControl
|
87
lightrag_webui/src/components/GraphLabels.tsx
Normal file
87
lightrag_webui/src/components/GraphLabels.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { AsyncSelect } from '@/components/ui/AsyncSelect'
|
||||
import { getGraphLabels } from '@/api/lightrag'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import MiniSearch from 'minisearch'
|
||||
|
||||
const GraphLabels = () => {
|
||||
const label = useSettingsStore.use.queryLabel()
|
||||
const [labels, setLabels] = useState<{
|
||||
labels: string[]
|
||||
searchEngine: MiniSearch | null
|
||||
}>({
|
||||
labels: [],
|
||||
searchEngine: null
|
||||
})
|
||||
const [fetched, setFetched] = useState(false)
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (query?: string): Promise<string[]> => {
|
||||
let _labels = labels.labels
|
||||
let _searchEngine = labels.searchEngine
|
||||
|
||||
if (!fetched || !_searchEngine) {
|
||||
_labels = ['*'].concat(await getGraphLabels())
|
||||
|
||||
// Ensure query label exists
|
||||
if (!_labels.includes(useSettingsStore.getState().queryLabel)) {
|
||||
useSettingsStore.getState().setQueryLabel(_labels[0])
|
||||
}
|
||||
|
||||
// Create search engine
|
||||
_searchEngine = new MiniSearch({
|
||||
idField: 'id',
|
||||
fields: ['value'],
|
||||
searchOptions: {
|
||||
prefix: true,
|
||||
fuzzy: 0.2,
|
||||
boost: {
|
||||
label: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Add documents
|
||||
const documents = _labels.map((str, index) => ({ id: index, value: str }))
|
||||
_searchEngine.addAll(documents)
|
||||
|
||||
setLabels({
|
||||
labels: _labels,
|
||||
searchEngine: _searchEngine
|
||||
})
|
||||
setFetched(true)
|
||||
}
|
||||
if (!query) {
|
||||
return _labels
|
||||
}
|
||||
|
||||
// Search labels
|
||||
return _searchEngine.search(query).map((result) => _labels[result.id])
|
||||
},
|
||||
[labels, fetched, setLabels, setFetched]
|
||||
)
|
||||
|
||||
const setQueryLabel = useCallback((label: string) => {
|
||||
useSettingsStore.getState().setQueryLabel(label)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AsyncSelect<string>
|
||||
className="ml-2"
|
||||
triggerClassName="max-h-8"
|
||||
searchInputClassName="max-h-8"
|
||||
triggerTooltip="Select query label"
|
||||
fetcher={fetchData}
|
||||
renderOption={(item) => <div>{item}</div>}
|
||||
getOptionValue={(item) => item}
|
||||
getDisplayValue={(item) => <div>{item}</div>}
|
||||
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
|
||||
label="Label"
|
||||
placeholder="Search labels..."
|
||||
value={label !== null ? label : ''}
|
||||
onChange={setQueryLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default GraphLabels
|
133
lightrag_webui/src/components/GraphSearch.tsx
Normal file
133
lightrag_webui/src/components/GraphSearch.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { FC, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
EdgeById,
|
||||
NodeById,
|
||||
GraphSearchInputProps,
|
||||
GraphSearchContextProviderProps
|
||||
} from '@react-sigma/graph-search'
|
||||
import { AsyncSearch } from '@/components/ui/AsyncSearch'
|
||||
import { searchResultLimit } from '@/lib/constants'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import MiniSearch from 'minisearch'
|
||||
|
||||
interface OptionItem {
|
||||
id: string
|
||||
type: 'nodes' | 'edges' | 'message'
|
||||
message?: string
|
||||
}
|
||||
|
||||
function OptionComponent(item: OptionItem) {
|
||||
return (
|
||||
<div>
|
||||
{item.type === 'nodes' && <NodeById id={item.id} />}
|
||||
{item.type === 'edges' && <EdgeById id={item.id} />}
|
||||
{item.type === 'message' && <div>{item.message}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const messageId = '__message_item'
|
||||
const lastGraph: any = {
|
||||
graph: null,
|
||||
searchEngine: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Component thats display the search input.
|
||||
*/
|
||||
export const GraphSearchInput = ({
|
||||
onChange,
|
||||
onFocus,
|
||||
value
|
||||
}: {
|
||||
onChange: GraphSearchInputProps['onChange']
|
||||
onFocus?: GraphSearchInputProps['onFocus']
|
||||
value?: GraphSearchInputProps['value']
|
||||
}) => {
|
||||
const graph = useGraphStore.use.sigmaGraph()
|
||||
|
||||
const search = useMemo(() => {
|
||||
if (lastGraph.graph == graph) {
|
||||
return lastGraph.searchEngine
|
||||
}
|
||||
if (!graph || graph.nodes().length == 0) return
|
||||
|
||||
lastGraph.graph = graph
|
||||
|
||||
const searchEngine = new MiniSearch({
|
||||
idField: 'id',
|
||||
fields: ['label'],
|
||||
searchOptions: {
|
||||
prefix: true,
|
||||
fuzzy: 0.2,
|
||||
boost: {
|
||||
label: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Add documents
|
||||
const documents = graph.nodes().map((id: string) => ({
|
||||
id: id,
|
||||
label: graph.getNodeAttribute(id, 'label')
|
||||
}))
|
||||
searchEngine.addAll(documents)
|
||||
|
||||
lastGraph.searchEngine = searchEngine
|
||||
return searchEngine
|
||||
}, [graph])
|
||||
|
||||
/**
|
||||
* Loading the options while the user is typing.
|
||||
*/
|
||||
const loadOptions = useCallback(
|
||||
async (query?: string): Promise<OptionItem[]> => {
|
||||
if (onFocus) onFocus(null)
|
||||
if (!query || !search) return []
|
||||
const result: OptionItem[] = search.search(query).map((result) => ({
|
||||
id: result.id,
|
||||
type: 'nodes'
|
||||
}))
|
||||
|
||||
// prettier-ignore
|
||||
return result.length <= searchResultLimit
|
||||
? result
|
||||
: [
|
||||
...result.slice(0, searchResultLimit),
|
||||
{
|
||||
type: 'message',
|
||||
id: messageId,
|
||||
message: `And ${result.length - searchResultLimit} others`
|
||||
}
|
||||
]
|
||||
},
|
||||
[search, onFocus]
|
||||
)
|
||||
|
||||
return (
|
||||
<AsyncSearch
|
||||
className="bg-background/60 w-24 rounded-xl border-1 opacity-60 backdrop-blur-lg transition-all hover:w-fit hover:opacity-100"
|
||||
fetcher={loadOptions}
|
||||
renderOption={OptionComponent}
|
||||
getOptionValue={(item) => item.id}
|
||||
value={value && value.type !== 'message' ? value.id : null}
|
||||
onChange={(id) => {
|
||||
if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null)
|
||||
}}
|
||||
onFocus={(id) => {
|
||||
if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null)
|
||||
}}
|
||||
label={'item'}
|
||||
placeholder="Search nodes..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that display the search.
|
||||
*/
|
||||
const GraphSearch: FC<GraphSearchInputProps & GraphSearchContextProviderProps> = ({ ...props }) => {
|
||||
return <GraphSearchInput {...props} />
|
||||
}
|
||||
|
||||
export default GraphSearch
|
179
lightrag_webui/src/components/LayoutsControl.tsx
Normal file
179
lightrag_webui/src/components/LayoutsControl.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useSigma } from '@react-sigma/core'
|
||||
import { animateNodes } from 'sigma/utils'
|
||||
import { useLayoutCirclepack } from '@react-sigma/layout-circlepack'
|
||||
import { useLayoutCircular } from '@react-sigma/layout-circular'
|
||||
import { LayoutHook, LayoutWorkerHook, WorkerLayoutControlProps } from '@react-sigma/layout-core'
|
||||
import { useLayoutForce, useWorkerLayoutForce } from '@react-sigma/layout-force'
|
||||
import { useLayoutForceAtlas2, useWorkerLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
|
||||
import { useLayoutNoverlap, useWorkerLayoutNoverlap } from '@react-sigma/layout-noverlap'
|
||||
import { useLayoutRandom } from '@react-sigma/layout-random'
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react'
|
||||
|
||||
import Button from '@/components/ui/Button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
|
||||
import { GripIcon, PlayIcon, PauseIcon } from 'lucide-react'
|
||||
|
||||
type LayoutName =
|
||||
| 'Circular'
|
||||
| 'Circlepack'
|
||||
| 'Random'
|
||||
| 'Noverlaps'
|
||||
| 'Force Directed'
|
||||
| 'Force Atlas'
|
||||
|
||||
const WorkerLayoutControl = ({ layout, autoRunFor }: WorkerLayoutControlProps) => {
|
||||
const sigma = useSigma()
|
||||
const { stop, start, isRunning } = layout
|
||||
|
||||
/**
|
||||
* Init component when Sigma or component settings change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!sigma) {
|
||||
return
|
||||
}
|
||||
|
||||
// we run the algo
|
||||
let timeout: number | null = null
|
||||
if (autoRunFor !== undefined && autoRunFor > -1 && sigma.getGraph().order > 0) {
|
||||
start()
|
||||
// set a timeout to stop it
|
||||
timeout =
|
||||
autoRunFor > 0
|
||||
? window.setTimeout(() => { stop() }, autoRunFor) // prettier-ignore
|
||||
: null
|
||||
}
|
||||
|
||||
//cleaning
|
||||
return () => {
|
||||
stop()
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [autoRunFor, start, stop, sigma])
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => (isRunning ? stop() : start())}
|
||||
tooltip={isRunning ? 'Stop the layout animation' : 'Start the layout animation'}
|
||||
variant={controlButtonVariant}
|
||||
>
|
||||
{isRunning ? <PauseIcon /> : <PlayIcon />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that controls the layout of the graph.
|
||||
*/
|
||||
const LayoutsControl = () => {
|
||||
const sigma = useSigma()
|
||||
const [layout, setLayout] = useState<LayoutName>('Circular')
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
|
||||
const layoutCircular = useLayoutCircular()
|
||||
const layoutCirclepack = useLayoutCirclepack()
|
||||
const layoutRandom = useLayoutRandom()
|
||||
const layoutNoverlap = useLayoutNoverlap({ settings: { margin: 1 } })
|
||||
const layoutForce = useLayoutForce({ maxIterations: 20 })
|
||||
const layoutForceAtlas2 = useLayoutForceAtlas2({ iterations: 20 })
|
||||
const workerNoverlap = useWorkerLayoutNoverlap()
|
||||
const workerForce = useWorkerLayoutForce()
|
||||
const workerForceAtlas2 = useWorkerLayoutForceAtlas2()
|
||||
|
||||
const layouts = useMemo(() => {
|
||||
return {
|
||||
Circular: {
|
||||
layout: layoutCircular
|
||||
},
|
||||
Circlepack: {
|
||||
layout: layoutCirclepack
|
||||
},
|
||||
Random: {
|
||||
layout: layoutRandom
|
||||
},
|
||||
Noverlaps: {
|
||||
layout: layoutNoverlap,
|
||||
worker: workerNoverlap
|
||||
},
|
||||
'Force Directed': {
|
||||
layout: layoutForce,
|
||||
worker: workerForce
|
||||
},
|
||||
'Force Atlas': {
|
||||
layout: layoutForceAtlas2,
|
||||
worker: workerForceAtlas2
|
||||
}
|
||||
} as { [key: string]: { layout: LayoutHook; worker?: LayoutWorkerHook } }
|
||||
}, [
|
||||
layoutCirclepack,
|
||||
layoutCircular,
|
||||
layoutForce,
|
||||
layoutForceAtlas2,
|
||||
layoutNoverlap,
|
||||
layoutRandom,
|
||||
workerForce,
|
||||
workerNoverlap,
|
||||
workerForceAtlas2
|
||||
])
|
||||
|
||||
const runLayout = useCallback(
|
||||
(newLayout: LayoutName) => {
|
||||
console.debug(newLayout)
|
||||
const { positions } = layouts[newLayout].layout
|
||||
animateNodes(sigma.getGraph(), positions(), { duration: 500 })
|
||||
setLayout(newLayout)
|
||||
},
|
||||
[layouts, sigma]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{layouts[layout] && 'worker' in layouts[layout] && (
|
||||
<WorkerLayoutControl layout={layouts[layout].worker!} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={controlButtonVariant}
|
||||
onClick={() => setOpened((e: boolean) => !e)}
|
||||
tooltip="Layout Graph"
|
||||
>
|
||||
<GripIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" align="center" className="p-1">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{Object.keys(layouts).map((name) => (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
runLayout(name as LayoutName)
|
||||
}}
|
||||
key={name}
|
||||
className="cursor-pointer text-xs"
|
||||
>
|
||||
{name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutsControl
|
55
lightrag_webui/src/components/MessageAlert.tsx
Normal file
55
lightrag_webui/src/components/MessageAlert.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// import Button from '@/components/ui/Button'
|
||||
// import { controlButtonVariant } from '@/lib/constants'
|
||||
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
const MessageAlert = () => {
|
||||
const health = useBackendState.use.health()
|
||||
const message = useBackendState.use.message()
|
||||
const messageTitle = useBackendState.use.messageTitle()
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsMounted(true)
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant={health ? 'default' : 'destructive'}
|
||||
className={cn(
|
||||
'bg-background/90 absolute top-2 left-1/2 flex w-auto -translate-x-1/2 transform items-center gap-4 shadow-md backdrop-blur-lg transition-all duration-500 ease-in-out',
|
||||
isMounted ? 'translate-y-0 opacity-100' : '-translate-y-20 opacity-0'
|
||||
)}
|
||||
>
|
||||
{!health && (
|
||||
<div>
|
||||
<AlertCircle className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<AlertTitle className="font-bold">{messageTitle}</AlertTitle>
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</div>
|
||||
{/* <div className="flex">
|
||||
<div className="flex-auto" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant={controlButtonVariant}
|
||||
className="text-primary max-h-8 border !p-2 text-xs"
|
||||
onClick={() => useBackendState.getState().clear()}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div> */}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageAlert
|
231
lightrag_webui/src/components/PropertiesView.tsx
Normal file
231
lightrag_webui/src/components/PropertiesView.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useGraphStore, RawNodeType, RawEdgeType } from '@/stores/graph'
|
||||
import Text from '@/components/ui/Text'
|
||||
import useLightragGraph from '@/hooks/useLightragGraph'
|
||||
|
||||
/**
|
||||
* Component that view properties of elements in graph.
|
||||
*/
|
||||
const PropertiesView = () => {
|
||||
const { getNode, getEdge } = useLightragGraph()
|
||||
const selectedNode = useGraphStore.use.selectedNode()
|
||||
const focusedNode = useGraphStore.use.focusedNode()
|
||||
const selectedEdge = useGraphStore.use.selectedEdge()
|
||||
const focusedEdge = useGraphStore.use.focusedEdge()
|
||||
|
||||
const [currentElement, setCurrentElement] = useState<NodeType | EdgeType | null>(null)
|
||||
const [currentType, setCurrentType] = useState<'node' | 'edge' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let type: 'node' | 'edge' | null = null
|
||||
let element: RawNodeType | RawEdgeType | null = null
|
||||
if (focusedNode) {
|
||||
type = 'node'
|
||||
element = getNode(focusedNode)
|
||||
} else if (selectedNode) {
|
||||
type = 'node'
|
||||
element = getNode(selectedNode)
|
||||
} else if (focusedEdge) {
|
||||
type = 'edge'
|
||||
element = getEdge(focusedEdge, true)
|
||||
} else if (selectedEdge) {
|
||||
type = 'edge'
|
||||
element = getEdge(selectedEdge, true)
|
||||
}
|
||||
|
||||
if (element) {
|
||||
if (type == 'node') {
|
||||
setCurrentElement(refineNodeProperties(element as any))
|
||||
} else {
|
||||
setCurrentElement(refineEdgeProperties(element as any))
|
||||
}
|
||||
setCurrentType(type)
|
||||
} else {
|
||||
setCurrentElement(null)
|
||||
setCurrentType(null)
|
||||
}
|
||||
}, [
|
||||
focusedNode,
|
||||
selectedNode,
|
||||
focusedEdge,
|
||||
selectedEdge,
|
||||
setCurrentElement,
|
||||
setCurrentType,
|
||||
getNode,
|
||||
getEdge
|
||||
])
|
||||
|
||||
if (!currentElement) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<div className="bg-background/80 max-w-xs rounded-lg border-2 p-2 text-xs backdrop-blur-lg">
|
||||
{currentType == 'node' ? (
|
||||
<NodePropertiesView node={currentElement as any} />
|
||||
) : (
|
||||
<EdgePropertiesView edge={currentElement as any} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type NodeType = RawNodeType & {
|
||||
relationships: {
|
||||
type: string
|
||||
id: string
|
||||
label: string
|
||||
}[]
|
||||
}
|
||||
|
||||
type EdgeType = RawEdgeType & {
|
||||
sourceNode?: RawNodeType
|
||||
targetNode?: RawNodeType
|
||||
}
|
||||
|
||||
const refineNodeProperties = (node: RawNodeType): NodeType => {
|
||||
const state = useGraphStore.getState()
|
||||
const relationships = []
|
||||
|
||||
if (state.sigmaGraph && state.rawGraph) {
|
||||
for (const edgeId of state.sigmaGraph.edges(node.id)) {
|
||||
const edge = state.rawGraph.getEdge(edgeId, true)
|
||||
if (edge) {
|
||||
const isTarget = node.id === edge.source
|
||||
const neighbourId = isTarget ? edge.target : edge.source
|
||||
const neighbour = state.rawGraph.getNode(neighbourId)
|
||||
if (neighbour) {
|
||||
relationships.push({
|
||||
type: isTarget ? 'Target' : 'Source',
|
||||
id: neighbourId,
|
||||
label: neighbour.labels.join(', ')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
relationships
|
||||
}
|
||||
}
|
||||
|
||||
const refineEdgeProperties = (edge: RawEdgeType): EdgeType => {
|
||||
const state = useGraphStore.getState()
|
||||
const sourceNode = state.rawGraph?.getNode(edge.source)
|
||||
const targetNode = state.rawGraph?.getNode(edge.target)
|
||||
return {
|
||||
...edge,
|
||||
sourceNode,
|
||||
targetNode
|
||||
}
|
||||
}
|
||||
|
||||
const PropertyRow = ({
|
||||
name,
|
||||
value,
|
||||
onClick,
|
||||
tooltip
|
||||
}: {
|
||||
name: string
|
||||
value: any
|
||||
onClick?: () => void
|
||||
tooltip?: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-primary/60 tracking-wide">{name}</label>:
|
||||
<Text
|
||||
className="hover:bg-primary/20 rounded p-1 text-ellipsis"
|
||||
tooltipClassName="max-w-80"
|
||||
text={value}
|
||||
tooltip={tooltip || value}
|
||||
side="left"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NodePropertiesView = ({ node }: { node: NodeType }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-sky-300">Node</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={'Id'} value={node.id} />
|
||||
<PropertyRow
|
||||
name={'Labels'}
|
||||
value={node.labels.join(', ')}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(node.id, true)
|
||||
}}
|
||||
/>
|
||||
<PropertyRow name={'Degree'} value={node.degree} />
|
||||
</div>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">Properties</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(node.properties)
|
||||
.sort()
|
||||
.map((name) => {
|
||||
return <PropertyRow key={name} name={name} value={node.properties[name]} />
|
||||
})}
|
||||
</div>
|
||||
{node.relationships.length > 0 && (
|
||||
<>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600/90">
|
||||
Relationships
|
||||
</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{node.relationships.map(({ type, id, label }) => {
|
||||
return (
|
||||
<PropertyRow
|
||||
key={id}
|
||||
name={type}
|
||||
value={label}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(id, true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EdgePropertiesView = ({ edge }: { edge: EdgeType }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-teal-600">Relationship</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
<PropertyRow name={'Id'} value={edge.id} />
|
||||
<PropertyRow name={'Type'} value={edge.type} />
|
||||
<PropertyRow
|
||||
name={'Source'}
|
||||
value={edge.sourceNode ? edge.sourceNode.labels.join(', ') : edge.source}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(edge.source, true)
|
||||
}}
|
||||
/>
|
||||
<PropertyRow
|
||||
name={'Target'}
|
||||
value={edge.targetNode ? edge.targetNode.labels.join(', ') : edge.target}
|
||||
onClick={() => {
|
||||
useGraphStore.getState().setSelectedNode(edge.target, true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-md pl-1 font-bold tracking-wide text-yellow-400/90">Properties</label>
|
||||
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
|
||||
{Object.keys(edge.properties)
|
||||
.sort()
|
||||
.map((name) => {
|
||||
return <PropertyRow key={name} name={name} value={edge.properties[name]} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PropertiesView
|
211
lightrag_webui/src/components/Settings.tsx
Normal file
211
lightrag_webui/src/components/Settings.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Separator from '@/components/ui/Separator'
|
||||
import Input from '@/components/ui/Input'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Component that displays a checkbox with a label.
|
||||
*/
|
||||
const LabeledCheckBox = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: () => void
|
||||
label: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays a popover with settings options.
|
||||
*/
|
||||
export default function Settings() {
|
||||
const [opened, setOpened] = useState<boolean>(false)
|
||||
const [tempApiKey, setTempApiKey] = useState<string>('') // 用于临时存储输入的API Key
|
||||
|
||||
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
|
||||
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
|
||||
const showNodeLabel = useSettingsStore.use.showNodeLabel()
|
||||
|
||||
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
|
||||
const enableNodeDrag = useSettingsStore.use.enableNodeDrag()
|
||||
const enableHideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
|
||||
const showEdgeLabel = useSettingsStore.use.showEdgeLabel()
|
||||
|
||||
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
|
||||
const apiKey = useSettingsStore.use.apiKey()
|
||||
|
||||
useEffect(() => {
|
||||
setTempApiKey(apiKey || '')
|
||||
}, [apiKey, opened])
|
||||
|
||||
const setEnableNodeDrag = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableNodeDrag: !pre.enableNodeDrag })),
|
||||
[]
|
||||
)
|
||||
const setEnableEdgeEvents = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableEdgeEvents: !pre.enableEdgeEvents })),
|
||||
[]
|
||||
)
|
||||
const setEnableHideUnselectedEdges = useCallback(
|
||||
() =>
|
||||
useSettingsStore.setState((pre) => ({
|
||||
enableHideUnselectedEdges: !pre.enableHideUnselectedEdges
|
||||
})),
|
||||
[]
|
||||
)
|
||||
const setShowEdgeLabel = useCallback(
|
||||
() =>
|
||||
useSettingsStore.setState((pre) => ({
|
||||
showEdgeLabel: !pre.showEdgeLabel
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
//
|
||||
const setShowPropertyPanel = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ showPropertyPanel: !pre.showPropertyPanel })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setShowNodeSearchBar = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ showNodeSearchBar: !pre.showNodeSearchBar })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setShowNodeLabel = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ showNodeLabel: !pre.showNodeLabel })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setEnableHealthCheck = useCallback(
|
||||
() => useSettingsStore.setState((pre) => ({ enableHealthCheck: !pre.enableHealthCheck })),
|
||||
[]
|
||||
)
|
||||
|
||||
const setApiKey = useCallback(async () => {
|
||||
useSettingsStore.setState({ apiKey: tempApiKey || null })
|
||||
await useBackendState.getState().check()
|
||||
setOpened(false)
|
||||
}, [tempApiKey])
|
||||
|
||||
const handleTempApiKeyChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTempApiKey(e.target.value)
|
||||
},
|
||||
[setTempApiKey]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={opened} onOpenChange={setOpened}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={controlButtonVariant} tooltip="Settings" size="icon">
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="mb-2 p-2"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<LabeledCheckBox
|
||||
checked={showPropertyPanel}
|
||||
onCheckedChange={setShowPropertyPanel}
|
||||
label="Show Property Panel"
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={showNodeSearchBar}
|
||||
onCheckedChange={setShowNodeSearchBar}
|
||||
label="Show Search Bar"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showNodeLabel}
|
||||
onCheckedChange={setShowNodeLabel}
|
||||
label="Show Node Label"
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableNodeDrag}
|
||||
onCheckedChange={setEnableNodeDrag}
|
||||
label="Node Draggable"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={showEdgeLabel}
|
||||
onCheckedChange={setShowEdgeLabel}
|
||||
label="Show Edge Label"
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableHideUnselectedEdges}
|
||||
onCheckedChange={setEnableHideUnselectedEdges}
|
||||
label="Hide Unselected Edges"
|
||||
/>
|
||||
<LabeledCheckBox
|
||||
checked={enableEdgeEvents}
|
||||
onCheckedChange={setEnableEdgeEvents}
|
||||
label="Edge Events"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<LabeledCheckBox
|
||||
checked={enableHealthCheck}
|
||||
onCheckedChange={setEnableHealthCheck}
|
||||
label="Health Check"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">API Key</label>
|
||||
<form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="w-0 flex-1">
|
||||
<Input
|
||||
type="password"
|
||||
value={tempApiKey}
|
||||
onChange={handleTempApiKeyChange}
|
||||
placeholder="Enter your API key"
|
||||
className="max-h-full w-full min-w-0"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={setApiKey}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="max-h-full shrink-0"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
65
lightrag_webui/src/components/StatusCard.tsx
Normal file
65
lightrag_webui/src/components/StatusCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { LightragStatus } from '@/api/lightrag'
|
||||
|
||||
const StatusCard = ({ status }: { status: LightragStatus | null }) => {
|
||||
if (!status) {
|
||||
return <div className="text-muted-foreground text-sm">Status information unavailable</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[300px] space-y-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">Storage Info</h4>
|
||||
<div className="text-muted-foreground grid grid-cols-2 gap-1">
|
||||
<span>Working Directory:</span>
|
||||
<span className="truncate">{status.working_directory}</span>
|
||||
<span>Input Directory:</span>
|
||||
<span className="truncate">{status.input_directory}</span>
|
||||
<span>Indexed Files:</span>
|
||||
<span>{status.indexed_files_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">LLM Configuration</h4>
|
||||
<div className="text-muted-foreground grid grid-cols-2 gap-1">
|
||||
<span>LLM Binding:</span>
|
||||
<span>{status.configuration.llm_binding}</span>
|
||||
<span>LLM Binding Host:</span>
|
||||
<span>{status.configuration.llm_binding_host}</span>
|
||||
<span>LLM Model:</span>
|
||||
<span>{status.configuration.llm_model}</span>
|
||||
<span>Max Tokens:</span>
|
||||
<span>{status.configuration.max_tokens}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">Embedding Configuration</h4>
|
||||
<div className="text-muted-foreground grid grid-cols-2 gap-1">
|
||||
<span>Embedding Binding:</span>
|
||||
<span>{status.configuration.embedding_binding}</span>
|
||||
<span>Embedding Binding Host:</span>
|
||||
<span>{status.configuration.embedding_binding_host}</span>
|
||||
<span>Embedding Model:</span>
|
||||
<span>{status.configuration.embedding_model}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium">Storage Configuration</h4>
|
||||
<div className="text-muted-foreground grid grid-cols-2 gap-1">
|
||||
<span>KV Storage:</span>
|
||||
<span>{status.configuration.kv_storage}</span>
|
||||
<span>Doc Status Storage:</span>
|
||||
<span>{status.configuration.doc_status_storage}</span>
|
||||
<span>Graph Storage:</span>
|
||||
<span>{status.configuration.graph_storage}</span>
|
||||
<span>Vector Storage:</span>
|
||||
<span>{status.configuration.vector_storage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusCard
|
48
lightrag_webui/src/components/StatusIndicator.tsx
Normal file
48
lightrag_webui/src/components/StatusIndicator.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import StatusCard from '@/components/StatusCard'
|
||||
|
||||
const StatusIndicator = () => {
|
||||
const health = useBackendState.use.health()
|
||||
const lastCheckTime = useBackendState.use.lastCheckTime()
|
||||
const status = useBackendState.use.status()
|
||||
const [animate, setAnimate] = useState(false)
|
||||
|
||||
// listen to health change
|
||||
useEffect(() => {
|
||||
setAnimate(true)
|
||||
const timer = setTimeout(() => setAnimate(false), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [lastCheckTime])
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 bottom-4 flex items-center gap-2 opacity-80 select-none">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex cursor-help items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'h-3 w-3 rounded-full transition-all duration-300',
|
||||
'shadow-[0_0_8px_rgba(0,0,0,0.2)]',
|
||||
health ? 'bg-green-500' : 'bg-red-500',
|
||||
animate && 'scale-125',
|
||||
animate && health && 'shadow-[0_0_12px_rgba(34,197,94,0.4)]',
|
||||
animate && !health && 'shadow-[0_0_12px_rgba(239,68,68,0.4)]'
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{health ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto" side="top" align="end">
|
||||
<StatusCard status={status} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusIndicator
|
57
lightrag_webui/src/components/ThemeProvider.tsx
Normal file
57
lightrag_webui/src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createContext, useEffect, useState } from 'react'
|
||||
import { Theme, useSettingsStore } from '@/stores/settings'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
/**
|
||||
* Component that provides the theme state and setter function to its children.
|
||||
*/
|
||||
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(useSettingsStore.getState().theme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
root.classList.add(systemTheme)
|
||||
setTheme(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
useSettingsStore.getState().setTheme(theme)
|
||||
setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { ThemeProviderContext }
|
37
lightrag_webui/src/components/ThemeToggle.tsx
Normal file
37
lightrag_webui/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Button from '@/components/ui/Button'
|
||||
import useTheme from '@/hooks/useTheme'
|
||||
import { MoonIcon, SunIcon } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
|
||||
/**
|
||||
* Component that toggles the theme between light and dark.
|
||||
*/
|
||||
export default function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const setLight = useCallback(() => setTheme('light'), [setTheme])
|
||||
const setDark = useCallback(() => setTheme('dark'), [setTheme])
|
||||
|
||||
if (theme === 'dark') {
|
||||
return (
|
||||
<Button
|
||||
onClick={setLight}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="Switch to light theme"
|
||||
size="icon"
|
||||
>
|
||||
<MoonIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={setDark}
|
||||
variant={controlButtonVariant}
|
||||
tooltip="Switch to dark theme"
|
||||
size="icon"
|
||||
>
|
||||
<SunIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
37
lightrag_webui/src/components/ZoomControl.tsx
Normal file
37
lightrag_webui/src/components/ZoomControl.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCamera } from '@react-sigma/core'
|
||||
import { useCallback } from 'react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { ZoomInIcon, ZoomOutIcon, FullscreenIcon } from 'lucide-react'
|
||||
import { controlButtonVariant } from '@/lib/constants'
|
||||
|
||||
/**
|
||||
* Component that provides zoom controls for the graph viewer.
|
||||
*/
|
||||
const ZoomControl = () => {
|
||||
const { zoomIn, zoomOut, reset } = useCamera({ duration: 200, factor: 1.5 })
|
||||
|
||||
const handleZoomIn = useCallback(() => zoomIn(), [zoomIn])
|
||||
const handleZoomOut = useCallback(() => zoomOut(), [zoomOut])
|
||||
const handleResetZoom = useCallback(() => reset(), [reset])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomIn} tooltip="Zoom In" size="icon">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button variant={controlButtonVariant} onClick={handleZoomOut} tooltip="Zoom Out" size="icon">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant={controlButtonVariant}
|
||||
onClick={handleResetZoom}
|
||||
tooltip="Reset Zoom"
|
||||
size="icon"
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ZoomControl
|
49
lightrag_webui/src/components/ui/Alert.tsx
Normal file
49
lightrag_webui/src/components/ui/Alert.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
))
|
||||
Alert.displayName = 'Alert'
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = 'AlertTitle'
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
243
lightrag_webui/src/components/ui/AsyncSearch.tsx
Normal file
243
lightrag_webui/src/components/ui/AsyncSearch.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/Command'
|
||||
|
||||
export interface Option {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export interface AsyncSearchProps<T> {
|
||||
/** Async function to fetch options */
|
||||
fetcher: (query?: string) => Promise<T[]>
|
||||
/** Preload all data ahead of time */
|
||||
preload?: boolean
|
||||
/** Function to filter options */
|
||||
filterFn?: (option: T, query: string) => boolean
|
||||
/** Function to render each option */
|
||||
renderOption: (option: T) => React.ReactNode
|
||||
/** Function to get the value from an option */
|
||||
getOptionValue: (option: T) => string
|
||||
/** Custom not found message */
|
||||
notFound?: React.ReactNode
|
||||
/** Custom loading skeleton */
|
||||
loadingSkeleton?: React.ReactNode
|
||||
/** Currently selected value */
|
||||
value: string | null
|
||||
/** Callback when selection changes */
|
||||
onChange: (value: string) => void
|
||||
/** Callback when focus changes */
|
||||
onFocus: (value: string) => void
|
||||
/** Label for the select field */
|
||||
label: string
|
||||
/** Placeholder text when no selection */
|
||||
placeholder?: string
|
||||
/** Disable the entire select */
|
||||
disabled?: boolean
|
||||
/** Custom width for the popover */
|
||||
width?: string | number
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Custom trigger button class names */
|
||||
triggerClassName?: string
|
||||
/** Custom no results message */
|
||||
noResultsMessage?: string
|
||||
/** Allow clearing the selection */
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
export function AsyncSearch<T>({
|
||||
fetcher,
|
||||
preload,
|
||||
filterFn,
|
||||
renderOption,
|
||||
getOptionValue,
|
||||
notFound,
|
||||
loadingSkeleton,
|
||||
label,
|
||||
placeholder = 'Select...',
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
disabled = false,
|
||||
className,
|
||||
noResultsMessage
|
||||
}: AsyncSearchProps<T>) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [options, setOptions] = useState<T[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedValue, setSelectedValue] = useState(value)
|
||||
const [focusedValue, setFocusedValue] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
||||
const [originalOptions, setOriginalOptions] = useState<T[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
setSelectedValue(value)
|
||||
}, [value])
|
||||
|
||||
// Effect for initial fetch
|
||||
useEffect(() => {
|
||||
const initializeOptions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
// If we have a value, use it for the initial search
|
||||
const data = value !== null ? await fetcher(value) : []
|
||||
setOriginalOptions(data)
|
||||
setOptions(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
initializeOptions()
|
||||
}
|
||||
}, [mounted, fetcher, value])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOptions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetcher(debouncedSearchTerm)
|
||||
setOriginalOptions(data)
|
||||
setOptions(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
fetchOptions()
|
||||
} else if (!preload) {
|
||||
fetchOptions()
|
||||
} else if (preload) {
|
||||
if (debouncedSearchTerm) {
|
||||
setOptions(
|
||||
originalOptions.filter((option) =>
|
||||
filterFn ? filterFn(option, debouncedSearchTerm) : true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setOptions(originalOptions)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher, debouncedSearchTerm, mounted, preload, filterFn])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(currentValue: string) => {
|
||||
if (currentValue !== selectedValue) {
|
||||
setSelectedValue(currentValue)
|
||||
onChange(currentValue)
|
||||
}
|
||||
setOpen(false)
|
||||
},
|
||||
[selectedValue, setSelectedValue, setOpen, onChange]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(currentValue: string) => {
|
||||
if (currentValue !== focusedValue) {
|
||||
setFocusedValue(currentValue)
|
||||
onFocus(currentValue)
|
||||
}
|
||||
},
|
||||
[focusedValue, setFocusedValue, onFocus]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(disabled && 'cursor-not-allowed opacity-50', className)}
|
||||
onFocus={() => {
|
||||
setOpen(true)
|
||||
}}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
<Command shouldFilter={false} className="bg-transparent">
|
||||
<div>
|
||||
<CommandInput
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
className="max-h-8"
|
||||
onValueChange={(value) => {
|
||||
setSearchTerm(value)
|
||||
if (value && !open) setOpen(true)
|
||||
}}
|
||||
/>
|
||||
{loading && options.length > 0 && (
|
||||
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CommandList className="max-h-auto" hidden={!open || debouncedSearchTerm.length === 0}>
|
||||
{error && <div className="text-destructive p-4 text-center">{error}</div>}
|
||||
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
options.length === 0 &&
|
||||
(notFound || (
|
||||
<CommandEmpty>{noResultsMessage ?? `No ${label.toLowerCase()} found.`}</CommandEmpty>
|
||||
))}
|
||||
<CommandGroup>
|
||||
{options.map((option, idx) => (
|
||||
<>
|
||||
<CommandItem
|
||||
key={getOptionValue(option) + `${idx}`}
|
||||
value={getOptionValue(option)}
|
||||
onSelect={handleSelect}
|
||||
onMouseEnter={() => handleFocus(getOptionValue(option))}
|
||||
className="truncate"
|
||||
>
|
||||
{renderOption(option)}
|
||||
</CommandItem>
|
||||
{idx !== options.length - 1 && (
|
||||
<div key={idx} className="bg-foreground/10 h-[1px]" />
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultLoadingSkeleton() {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<CommandItem disabled>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="bg-muted h-6 w-6 animate-pulse rounded-full" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
268
lightrag_webui/src/components/ui/AsyncSelect.tsx
Normal file
268
lightrag_webui/src/components/ui/AsyncSelect.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import Button from '@/components/ui/Button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/Command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
|
||||
export interface Option {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export interface AsyncSelectProps<T> {
|
||||
/** Async function to fetch options */
|
||||
fetcher: (query?: string) => Promise<T[]>
|
||||
/** Preload all data ahead of time */
|
||||
preload?: boolean
|
||||
/** Function to filter options */
|
||||
filterFn?: (option: T, query: string) => boolean
|
||||
/** Function to render each option */
|
||||
renderOption: (option: T) => React.ReactNode
|
||||
/** Function to get the value from an option */
|
||||
getOptionValue: (option: T) => string
|
||||
/** Function to get the display value for the selected option */
|
||||
getDisplayValue: (option: T) => React.ReactNode
|
||||
/** Custom not found message */
|
||||
notFound?: React.ReactNode
|
||||
/** Custom loading skeleton */
|
||||
loadingSkeleton?: React.ReactNode
|
||||
/** Currently selected value */
|
||||
value: string
|
||||
/** Callback when selection changes */
|
||||
onChange: (value: string) => void
|
||||
/** Label for the select field */
|
||||
label: string
|
||||
/** Placeholder text when no selection */
|
||||
placeholder?: string
|
||||
/** Disable the entire select */
|
||||
disabled?: boolean
|
||||
/** Custom width for the popover *
|
||||
width?: string | number
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Custom trigger button class names */
|
||||
triggerClassName?: string
|
||||
/** Custom search input class names */
|
||||
searchInputClassName?: string
|
||||
/** Custom no results message */
|
||||
noResultsMessage?: string
|
||||
/** Custom trigger tooltip */
|
||||
triggerTooltip?: string
|
||||
/** Allow clearing the selection */
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
export function AsyncSelect<T>({
|
||||
fetcher,
|
||||
preload,
|
||||
filterFn,
|
||||
renderOption,
|
||||
getOptionValue,
|
||||
getDisplayValue,
|
||||
notFound,
|
||||
loadingSkeleton,
|
||||
label,
|
||||
placeholder = 'Select...',
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
triggerClassName,
|
||||
searchInputClassName,
|
||||
noResultsMessage,
|
||||
triggerTooltip,
|
||||
clearable = true
|
||||
}: AsyncSelectProps<T>) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [options, setOptions] = useState<T[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedValue, setSelectedValue] = useState(value)
|
||||
const [selectedOption, setSelectedOption] = useState<T | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150)
|
||||
const [originalOptions, setOriginalOptions] = useState<T[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
setSelectedValue(value)
|
||||
}, [value])
|
||||
|
||||
// Initialize selectedOption when options are loaded and value exists
|
||||
useEffect(() => {
|
||||
if (value && options.length > 0) {
|
||||
const option = options.find((opt) => getOptionValue(opt) === value)
|
||||
if (option) {
|
||||
setSelectedOption(option)
|
||||
}
|
||||
}
|
||||
}, [value, options, getOptionValue])
|
||||
|
||||
// Effect for initial fetch
|
||||
useEffect(() => {
|
||||
const initializeOptions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
// If we have a value, use it for the initial search
|
||||
const data = await fetcher(value)
|
||||
setOriginalOptions(data)
|
||||
setOptions(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
initializeOptions()
|
||||
}
|
||||
}, [mounted, fetcher, value])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOptions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetcher(debouncedSearchTerm)
|
||||
setOriginalOptions(data)
|
||||
setOptions(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch options')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
fetchOptions()
|
||||
} else if (!preload) {
|
||||
fetchOptions()
|
||||
} else if (preload) {
|
||||
if (debouncedSearchTerm) {
|
||||
setOptions(
|
||||
originalOptions.filter((option) =>
|
||||
filterFn ? filterFn(option, debouncedSearchTerm) : true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setOptions(originalOptions)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher, debouncedSearchTerm, mounted, preload, filterFn])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(currentValue: string) => {
|
||||
const newValue = clearable && currentValue === selectedValue ? '' : currentValue
|
||||
setSelectedValue(newValue)
|
||||
setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null)
|
||||
onChange(newValue)
|
||||
setOpen(false)
|
||||
},
|
||||
[selectedValue, onChange, clearable, options, getOptionValue]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'justify-between',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
triggerClassName
|
||||
)}
|
||||
disabled={disabled}
|
||||
tooltip={triggerTooltip}
|
||||
side="bottom"
|
||||
>
|
||||
{selectedOption ? getDisplayValue(selectedOption) : placeholder}
|
||||
<ChevronsUpDown className="opacity-50" size={10} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn('p-0', className)} onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<Command shouldFilter={false}>
|
||||
<div className="relative w-full border-b">
|
||||
<CommandInput
|
||||
placeholder={`Search ${label.toLowerCase()}...`}
|
||||
value={searchTerm}
|
||||
onValueChange={(value) => {
|
||||
setSearchTerm(value)
|
||||
}}
|
||||
className={searchInputClassName}
|
||||
/>
|
||||
{loading && options.length > 0 && (
|
||||
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CommandList>
|
||||
{error && <div className="text-destructive p-4 text-center">{error}</div>}
|
||||
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
options.length === 0 &&
|
||||
(notFound || (
|
||||
<CommandEmpty>
|
||||
{noResultsMessage ?? `No ${label.toLowerCase()} found.`}
|
||||
</CommandEmpty>
|
||||
))}
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={getOptionValue(option)}
|
||||
value={getOptionValue(option)}
|
||||
onSelect={handleSelect}
|
||||
className="truncate"
|
||||
>
|
||||
{renderOption(option)}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto h-3 w-3',
|
||||
selectedValue === getOptionValue(option) ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultLoadingSkeleton() {
|
||||
return (
|
||||
<CommandGroup>
|
||||
<CommandItem disabled>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="bg-muted h-6 w-6 animate-pulse rounded-full" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
77
lightrag_webui/src/components/ui/Button.tsx
Normal file
77
lightrag_webui/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'size-8'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, tooltip, size, side = 'right', asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
if (!tooltip) {
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }), 'cursor-pointer')}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export type ButtonVariantType = Exclude<
|
||||
NonNullable<Parameters<typeof buttonVariants>[0]>['variant'],
|
||||
undefined
|
||||
>
|
||||
|
||||
export default Button
|
26
lightrag_webui/src/components/ui/Checkbox.tsx
Normal file
26
lightrag_webui/src/components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ComponentRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
143
lightrag_webui/src/components/ui/Command.tsx
Normal file
143
lightrag_webui/src/components/ui/Command.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as React from 'react'
|
||||
import { type DialogProps } from '@radix-ui/react-dialog'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Dialog, DialogContent } from './Dialog'
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// eslint-disable-next-line @stylistic/js/quotes
|
||||
"data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator
|
||||
}
|
102
lightrag_webui/src/components/ui/Dialog.tsx
Normal file
102
lightrag_webui/src/components/ui/Dialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
}
|
21
lightrag_webui/src/components/ui/Input.tsx
Normal file
21
lightrag_webui/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export default Input
|
29
lightrag_webui/src/components/ui/Popover.tsx
Normal file
29
lightrag_webui/src/components/ui/Popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 rounded-md border p-4 shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
24
lightrag_webui/src/components/ui/Separator.tsx
Normal file
24
lightrag_webui/src/components/ui/Separator.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export default Separator
|
49
lightrag_webui/src/components/ui/Text.tsx
Normal file
49
lightrag_webui/src/components/ui/Text.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/Tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Text = ({
|
||||
text,
|
||||
className,
|
||||
tooltipClassName,
|
||||
tooltip,
|
||||
side,
|
||||
onClick
|
||||
}: {
|
||||
text: string
|
||||
className?: string
|
||||
tooltipClassName?: string
|
||||
tooltip?: string
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
onClick?: () => void
|
||||
}) => {
|
||||
if (!tooltip) {
|
||||
return (
|
||||
<label
|
||||
className={cn(className, onClick !== undefined ? 'cursor-pointer' : undefined)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label
|
||||
className={cn(className, onClick !== undefined ? 'cursor-pointer' : undefined)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className={tooltipClassName}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Text
|
27
lightrag_webui/src/components/ui/Tooltip.tsx
Normal file
27
lightrag_webui/src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
17
lightrag_webui/src/hooks/useDebounce.tsx
Normal file
17
lightrag_webui/src/hooks/useDebounce.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
209
lightrag_webui/src/hooks/useLightragGraph.tsx
Normal file
209
lightrag_webui/src/hooks/useLightragGraph.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import Graph, { DirectedGraph } from 'graphology'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { randomColor, errorMessage } from '@/lib/utils'
|
||||
import * as Constants from '@/lib/constants'
|
||||
import { useGraphStore, RawGraph } from '@/stores/graph'
|
||||
import { queryGraphs } from '@/api/lightrag'
|
||||
import { useBackendState } from '@/stores/state'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
import seedrandom from 'seedrandom'
|
||||
|
||||
const validateGraph = (graph: RawGraph) => {
|
||||
if (!graph) {
|
||||
return false
|
||||
}
|
||||
if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (!node.id || !node.labels || !node.properties) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
if (!edge.id || !edge.source || !edge.target || !edge.type || !edge.properties) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
const source = graph.getNode(edge.source)
|
||||
const target = graph.getNode(edge.target)
|
||||
if (source == undefined || target == undefined) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export type NodeType = {
|
||||
x: number
|
||||
y: number
|
||||
label: string
|
||||
size: number
|
||||
color: string
|
||||
highlighted?: boolean
|
||||
}
|
||||
export type EdgeType = { label: string }
|
||||
|
||||
const fetchGraph = async (label: string) => {
|
||||
let rawData: any = null
|
||||
|
||||
try {
|
||||
rawData = await queryGraphs(label)
|
||||
} catch (e) {
|
||||
useBackendState.getState().setErrorMessage(errorMessage(e), 'Query Graphs Error!')
|
||||
return null
|
||||
}
|
||||
|
||||
let rawGraph = null
|
||||
|
||||
if (rawData) {
|
||||
const nodeIdMap: Record<string, number> = {}
|
||||
const edgeIdMap: Record<string, number> = {}
|
||||
|
||||
for (let i = 0; i < rawData.nodes.length; i++) {
|
||||
const node = rawData.nodes[i]
|
||||
nodeIdMap[node.id] = i
|
||||
|
||||
// const seed = node.labels.length > 0 ? node.labels[0] : node.id
|
||||
seedrandom(node.id, { global: true })
|
||||
node.color = randomColor()
|
||||
node.x = Math.random()
|
||||
node.y = Math.random()
|
||||
node.degree = 0
|
||||
node.size = 10
|
||||
}
|
||||
|
||||
for (let i = 0; i < rawData.edges.length; i++) {
|
||||
const edge = rawData.edges[i]
|
||||
edgeIdMap[edge.id] = i
|
||||
|
||||
const source = nodeIdMap[edge.source]
|
||||
const target = nodeIdMap[edge.target]
|
||||
if (source !== undefined && source !== undefined) {
|
||||
const sourceNode = rawData.nodes[source]
|
||||
const targetNode = rawData.nodes[target]
|
||||
sourceNode.degree += 1
|
||||
targetNode.degree += 1
|
||||
}
|
||||
}
|
||||
|
||||
// generate node size
|
||||
let minDegree = Number.MAX_SAFE_INTEGER
|
||||
let maxDegree = 0
|
||||
|
||||
for (const node of rawData.nodes) {
|
||||
minDegree = Math.min(minDegree, node.degree)
|
||||
maxDegree = Math.max(maxDegree, node.degree)
|
||||
}
|
||||
const range = maxDegree - minDegree
|
||||
if (range > 0) {
|
||||
const scale = Constants.maxNodeSize - Constants.minNodeSize
|
||||
for (const node of rawData.nodes) {
|
||||
node.size = Math.round(
|
||||
Constants.minNodeSize + scale * Math.pow((node.degree - minDegree) / range, 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
rawGraph = new RawGraph()
|
||||
rawGraph.nodes = rawData.nodes
|
||||
rawGraph.edges = rawData.edges
|
||||
rawGraph.nodeIdMap = nodeIdMap
|
||||
rawGraph.edgeIdMap = edgeIdMap
|
||||
|
||||
if (!validateGraph(rawGraph)) {
|
||||
rawGraph = null
|
||||
console.error('Invalid graph data')
|
||||
}
|
||||
console.log('Graph data loaded')
|
||||
}
|
||||
|
||||
// console.debug({ data: JSON.parse(JSON.stringify(rawData)) })
|
||||
return rawGraph
|
||||
}
|
||||
|
||||
const createSigmaGraph = (rawGraph: RawGraph | null) => {
|
||||
const graph = new DirectedGraph()
|
||||
|
||||
for (const rawNode of rawGraph?.nodes ?? []) {
|
||||
graph.addNode(rawNode.id, {
|
||||
label: rawNode.labels.join(', '),
|
||||
color: rawNode.color,
|
||||
x: rawNode.x,
|
||||
y: rawNode.y,
|
||||
size: rawNode.size,
|
||||
// for node-border
|
||||
borderColor: Constants.nodeBorderColor,
|
||||
borderSize: 0.2
|
||||
})
|
||||
}
|
||||
|
||||
for (const rawEdge of rawGraph?.edges ?? []) {
|
||||
rawEdge.dynamicId = graph.addDirectedEdge(rawEdge.source, rawEdge.target, {
|
||||
label: rawEdge.type
|
||||
})
|
||||
}
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
const lastQueryLabel = { label: '' }
|
||||
|
||||
const useLightrangeGraph = () => {
|
||||
const queryLabel = useSettingsStore.use.queryLabel()
|
||||
const rawGraph = useGraphStore.use.rawGraph()
|
||||
const sigmaGraph = useGraphStore.use.sigmaGraph()
|
||||
|
||||
const getNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
return rawGraph?.getNode(nodeId) || null
|
||||
},
|
||||
[rawGraph]
|
||||
)
|
||||
|
||||
const getEdge = useCallback(
|
||||
(edgeId: string, dynamicId: boolean = true) => {
|
||||
return rawGraph?.getEdge(edgeId, dynamicId) || null
|
||||
},
|
||||
[rawGraph]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (queryLabel) {
|
||||
if (lastQueryLabel.label !== queryLabel) {
|
||||
lastQueryLabel.label = queryLabel
|
||||
const state = useGraphStore.getState()
|
||||
state.reset()
|
||||
fetchGraph(queryLabel).then((data) => {
|
||||
// console.debug('Query label: ' + queryLabel)
|
||||
state.setSigmaGraph(createSigmaGraph(data))
|
||||
data?.buildDynamicMap()
|
||||
state.setRawGraph(data)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const state = useGraphStore.getState()
|
||||
state.reset()
|
||||
state.setSigmaGraph(new DirectedGraph())
|
||||
}
|
||||
}, [queryLabel])
|
||||
|
||||
const lightrageGraph = useCallback(() => {
|
||||
if (sigmaGraph) {
|
||||
return sigmaGraph as Graph<NodeType, EdgeType>
|
||||
}
|
||||
const graph = new DirectedGraph()
|
||||
useGraphStore.getState().setSigmaGraph(graph)
|
||||
return graph as Graph<NodeType, EdgeType>
|
||||
}, [sigmaGraph])
|
||||
|
||||
return { lightrageGraph, getNode, getEdge }
|
||||
}
|
||||
|
||||
export default useLightrangeGraph
|
65
lightrag_webui/src/hooks/useRandomGraph.tsx
Normal file
65
lightrag_webui/src/hooks/useRandomGraph.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Faker, en, faker as fak } from '@faker-js/faker'
|
||||
import Graph, { UndirectedGraph } from 'graphology'
|
||||
import erdosRenyi from 'graphology-generators/random/erdos-renyi'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import seedrandom from 'seedrandom'
|
||||
import { randomColor } from '@/lib/utils'
|
||||
import * as Constants from '@/lib/constants'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
|
||||
export type NodeType = {
|
||||
x: number
|
||||
y: number
|
||||
label: string
|
||||
size: number
|
||||
color: string
|
||||
highlighted?: boolean
|
||||
}
|
||||
export type EdgeType = { label: string }
|
||||
|
||||
/**
|
||||
* The goal of this file is to seed random generators if the query params 'seed' is present.
|
||||
*/
|
||||
const useRandomGraph = () => {
|
||||
const [faker, setFaker] = useState<Faker>(fak)
|
||||
|
||||
useEffect(() => {
|
||||
// Globally seed the Math.random
|
||||
const params = new URLSearchParams(document.location.search)
|
||||
const seed = params.get('seed') // is the string "Jonathan"
|
||||
if (seed) {
|
||||
seedrandom(seed, { global: true })
|
||||
// seed faker with the random function
|
||||
const f = new Faker({ locale: en })
|
||||
f.seed(Math.random())
|
||||
setFaker(f)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const randomGraph = useCallback(() => {
|
||||
useGraphStore.getState().reset()
|
||||
|
||||
// Create the graph
|
||||
const graph = erdosRenyi(UndirectedGraph, { order: 100, probability: 0.1 })
|
||||
graph.nodes().forEach((node: string) => {
|
||||
graph.mergeNodeAttributes(node, {
|
||||
label: faker.person.fullName(),
|
||||
size: faker.number.int({ min: Constants.minNodeSize, max: Constants.maxNodeSize }),
|
||||
color: randomColor(),
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
// for node-border
|
||||
borderColor: randomColor(),
|
||||
borderSize: faker.number.float({ min: 0, max: 1, multipleOf: 0.1 }),
|
||||
// for node-image
|
||||
pictoColor: randomColor(),
|
||||
image: faker.image.urlLoremFlickr()
|
||||
})
|
||||
})
|
||||
return graph as Graph<NodeType, EdgeType>
|
||||
}, [faker])
|
||||
|
||||
return { faker, randomColor, randomGraph }
|
||||
}
|
||||
|
||||
export default useRandomGraph
|
12
lightrag_webui/src/hooks/useTheme.tsx
Normal file
12
lightrag_webui/src/hooks/useTheme.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useContext } from 'react'
|
||||
import { ThemeProviderContext } from '@/components/ThemeProvider'
|
||||
|
||||
const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export default useTheme
|
144
lightrag_webui/src/index.css
Normal file
144
lightrag_webui/src/index.css
Normal file
@@ -0,0 +1,144 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin 'tailwindcss-animate';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--chart-1: hsl(12 76% 61%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
--chart-5: hsl(27 87% 67%);
|
||||
--radius: 0.6rem;
|
||||
--sidebar-background: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
25
lightrag_webui/src/lib/constants.ts
Normal file
25
lightrag_webui/src/lib/constants.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ButtonVariantType } from '@/components/ui/Button'
|
||||
|
||||
export const backendBaseUrl = ''
|
||||
|
||||
export const controlButtonVariant: ButtonVariantType = 'ghost'
|
||||
|
||||
export const labelColorDarkTheme = '#B2EBF2'
|
||||
export const LabelColorHighlightedDarkTheme = '#000'
|
||||
|
||||
export const nodeColorDisabled = '#E2E2E2'
|
||||
export const nodeBorderColor = '#EEEEEE'
|
||||
export const nodeBorderColorSelected = '#F57F17'
|
||||
|
||||
export const edgeColorDarkTheme = '#969696'
|
||||
export const edgeColorSelected = '#F57F17'
|
||||
export const edgeColorHighlighted = '#B2EBF2'
|
||||
|
||||
export const searchResultLimit = 20
|
||||
|
||||
export const minNodeSize = 4
|
||||
export const maxNodeSize = 20
|
||||
|
||||
export const healthCheckInterval = 15 // seconds
|
||||
|
||||
export const defaultQueryLabel = '*'
|
34
lightrag_webui/src/lib/utils.ts
Normal file
34
lightrag_webui/src/lib/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { StoreApi, UseBoundStore } from 'zustand'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function randomColor() {
|
||||
const digits = '0123456789abcdef'
|
||||
let code = '#'
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += digits.charAt(Math.floor(Math.random() * 16))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
export function errorMessage(error: any) {
|
||||
return error instanceof Error ? error.message : `${error}`
|
||||
}
|
||||
|
||||
type WithSelectors<S> = S extends { getState: () => infer T }
|
||||
? S & { use: { [K in keyof T]: () => T[K] } }
|
||||
: never
|
||||
|
||||
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {
|
||||
const store = _store as WithSelectors<typeof _store>
|
||||
store.use = {}
|
||||
for (const k of Object.keys(store.getState())) {
|
||||
;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
|
||||
}
|
||||
|
||||
return store
|
||||
}
|
10
lightrag_webui/src/main.tsx
Normal file
10
lightrag_webui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
130
lightrag_webui/src/stores/graph.ts
Normal file
130
lightrag_webui/src/stores/graph.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { create } from 'zustand'
|
||||
import { createSelectors } from '@/lib/utils'
|
||||
import { DirectedGraph } from 'graphology'
|
||||
|
||||
export type RawNodeType = {
|
||||
id: string
|
||||
labels: string[]
|
||||
properties: Record<string, any>
|
||||
|
||||
size: number
|
||||
x: number
|
||||
y: number
|
||||
color: string
|
||||
|
||||
degree: number
|
||||
}
|
||||
|
||||
export type RawEdgeType = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
type: string
|
||||
properties: Record<string, any>
|
||||
|
||||
dynamicId: string
|
||||
}
|
||||
|
||||
export class RawGraph {
|
||||
nodes: RawNodeType[] = []
|
||||
edges: RawEdgeType[] = []
|
||||
nodeIdMap: Record<string, number> = {}
|
||||
edgeIdMap: Record<string, number> = {}
|
||||
edgeDynamicIdMap: Record<string, number> = {}
|
||||
|
||||
getNode = (nodeId: string) => {
|
||||
const nodeIndex = this.nodeIdMap[nodeId]
|
||||
if (nodeIndex !== undefined) {
|
||||
return this.nodes[nodeIndex]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
getEdge = (edgeId: string, dynamicId: boolean = true) => {
|
||||
const edgeIndex = dynamicId ? this.edgeDynamicIdMap[edgeId] : this.edgeIdMap[edgeId]
|
||||
if (edgeIndex !== undefined) {
|
||||
return this.edges[edgeIndex]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
buildDynamicMap = () => {
|
||||
this.edgeDynamicIdMap = {}
|
||||
for (let i = 0; i < this.edges.length; i++) {
|
||||
const edge = this.edges[i]
|
||||
this.edgeDynamicIdMap[edge.dynamicId] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface GraphState {
|
||||
selectedNode: string | null
|
||||
focusedNode: string | null
|
||||
selectedEdge: string | null
|
||||
focusedEdge: string | null
|
||||
|
||||
rawGraph: RawGraph | null
|
||||
sigmaGraph: DirectedGraph | null
|
||||
|
||||
moveToSelectedNode: boolean
|
||||
|
||||
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) => void
|
||||
setFocusedNode: (nodeId: string | null) => void
|
||||
setSelectedEdge: (edgeId: string | null) => void
|
||||
setFocusedEdge: (edgeId: string | null) => void
|
||||
clearSelection: () => void
|
||||
reset: () => void
|
||||
|
||||
setMoveToSelectedNode: (moveToSelectedNode: boolean) => void
|
||||
|
||||
setRawGraph: (rawGraph: RawGraph | null) => void
|
||||
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => void
|
||||
}
|
||||
|
||||
const useGraphStoreBase = create<GraphState>()((set) => ({
|
||||
selectedNode: null,
|
||||
focusedNode: null,
|
||||
selectedEdge: null,
|
||||
focusedEdge: null,
|
||||
|
||||
moveToSelectedNode: false,
|
||||
|
||||
rawGraph: null,
|
||||
sigmaGraph: null,
|
||||
|
||||
setSelectedNode: (nodeId: string | null, moveToSelectedNode?: boolean) =>
|
||||
set({ selectedNode: nodeId, moveToSelectedNode }),
|
||||
setFocusedNode: (nodeId: string | null) => set({ focusedNode: nodeId }),
|
||||
setSelectedEdge: (edgeId: string | null) => set({ selectedEdge: edgeId }),
|
||||
setFocusedEdge: (edgeId: string | null) => set({ focusedEdge: edgeId }),
|
||||
clearSelection: () =>
|
||||
set({
|
||||
selectedNode: null,
|
||||
focusedNode: null,
|
||||
selectedEdge: null,
|
||||
focusedEdge: null
|
||||
}),
|
||||
reset: () =>
|
||||
set({
|
||||
selectedNode: null,
|
||||
focusedNode: null,
|
||||
selectedEdge: null,
|
||||
focusedEdge: null,
|
||||
rawGraph: null,
|
||||
sigmaGraph: null,
|
||||
moveToSelectedNode: false
|
||||
}),
|
||||
|
||||
setRawGraph: (rawGraph: RawGraph | null) =>
|
||||
set({
|
||||
rawGraph
|
||||
}),
|
||||
|
||||
setSigmaGraph: (sigmaGraph: DirectedGraph | null) => set({ sigmaGraph }),
|
||||
|
||||
setMoveToSelectedNode: (moveToSelectedNode?: boolean) => set({ moveToSelectedNode })
|
||||
}))
|
||||
|
||||
const useGraphStore = createSelectors(useGraphStoreBase)
|
||||
|
||||
export { useGraphStore }
|
88
lightrag_webui/src/stores/settings.ts
Normal file
88
lightrag_webui/src/stores/settings.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { createSelectors } from '@/lib/utils'
|
||||
import { defaultQueryLabel } from '@/lib/constants'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
interface SettingsState {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
|
||||
showPropertyPanel: boolean
|
||||
showNodeSearchBar: boolean
|
||||
|
||||
showNodeLabel: boolean
|
||||
enableNodeDrag: boolean
|
||||
|
||||
showEdgeLabel: boolean
|
||||
enableHideUnselectedEdges: boolean
|
||||
enableEdgeEvents: boolean
|
||||
|
||||
queryLabel: string
|
||||
setQueryLabel: (queryLabel: string) => void
|
||||
|
||||
enableHealthCheck: boolean
|
||||
setEnableHealthCheck: (enable: boolean) => void
|
||||
|
||||
apiKey: string | null
|
||||
setApiKey: (key: string | null) => void
|
||||
}
|
||||
|
||||
const useSettingsStoreBase = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: 'system',
|
||||
|
||||
showPropertyPanel: true,
|
||||
showNodeSearchBar: true,
|
||||
|
||||
showNodeLabel: true,
|
||||
enableNodeDrag: true,
|
||||
|
||||
showEdgeLabel: false,
|
||||
enableHideUnselectedEdges: true,
|
||||
enableEdgeEvents: false,
|
||||
|
||||
queryLabel: defaultQueryLabel,
|
||||
enableHealthCheck: true,
|
||||
|
||||
apiKey: null,
|
||||
|
||||
setTheme: (theme: Theme) => set({ theme }),
|
||||
|
||||
setQueryLabel: (queryLabel: string) =>
|
||||
set({
|
||||
queryLabel
|
||||
}),
|
||||
|
||||
setEnableHealthCheck: (enable: boolean) => set({ enableHealthCheck: enable }),
|
||||
|
||||
setApiKey: (apiKey: string | null) => set({ apiKey })
|
||||
}),
|
||||
{
|
||||
name: 'settings-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
version: 4,
|
||||
migrate: (state: any, version: number) => {
|
||||
if (version < 2) {
|
||||
state.showEdgeLabel = false
|
||||
}
|
||||
if (version < 3) {
|
||||
state.queryLabel = defaultQueryLabel
|
||||
}
|
||||
if (version < 4) {
|
||||
state.showPropertyPanel = true
|
||||
state.showNodeSearchBar = true
|
||||
state.showNodeLabel = true
|
||||
state.enableHealthCheck = true
|
||||
state.apiKey = null
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const useSettingsStore = createSelectors(useSettingsStoreBase)
|
||||
|
||||
export { useSettingsStore, type Theme }
|
59
lightrag_webui/src/stores/state.ts
Normal file
59
lightrag_webui/src/stores/state.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { create } from 'zustand'
|
||||
import { createSelectors } from '@/lib/utils'
|
||||
import { checkHealth, LightragStatus } from '@/api/lightrag'
|
||||
|
||||
interface BackendState {
|
||||
health: boolean
|
||||
message: string | null
|
||||
messageTitle: string | null
|
||||
|
||||
status: LightragStatus | null
|
||||
|
||||
lastCheckTime: number
|
||||
|
||||
check: () => Promise<boolean>
|
||||
clear: () => void
|
||||
setErrorMessage: (message: string, messageTitle: string) => void
|
||||
}
|
||||
|
||||
const useBackendStateStoreBase = create<BackendState>()((set) => ({
|
||||
health: true,
|
||||
message: null,
|
||||
messageTitle: null,
|
||||
lastCheckTime: Date.now(),
|
||||
status: null,
|
||||
|
||||
check: async () => {
|
||||
const health = await checkHealth()
|
||||
if (health.status === 'healthy') {
|
||||
set({
|
||||
health: true,
|
||||
message: null,
|
||||
messageTitle: null,
|
||||
lastCheckTime: Date.now(),
|
||||
status: health
|
||||
})
|
||||
return true
|
||||
}
|
||||
set({
|
||||
health: false,
|
||||
message: health.message,
|
||||
messageTitle: 'Backend Health Check Error!',
|
||||
lastCheckTime: Date.now(),
|
||||
status: null
|
||||
})
|
||||
return false
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
set({ health: true, message: null, messageTitle: null })
|
||||
},
|
||||
|
||||
setErrorMessage: (message: string, messageTitle: string) => {
|
||||
set({ health: false, message, messageTitle })
|
||||
}
|
||||
}))
|
||||
|
||||
const useBackendState = createSelectors(useBackendStateStoreBase)
|
||||
|
||||
export { useBackendState }
|
1
lightrag_webui/src/vite-env.d.ts
vendored
Normal file
1
lightrag_webui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
30
lightrag_webui/tsconfig.json
Normal file
30
lightrag_webui/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Paths */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
19
lightrag_webui/vite.config.ts
Normal file
19
lightrag_webui/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import path from 'path'
|
||||
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
base: './',
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, '../lightrag/api/webui')
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user