Merge branch 'main' into select-datastore-in-api-server

This commit is contained in:
zrguo
2025-02-13 20:03:38 +08:00
committed by GitHub
73 changed files with 6101 additions and 1322 deletions

View File

@@ -44,7 +44,7 @@ rag = LightRAG(
), ),
kv_storage="MongoKVStorage", kv_storage="MongoKVStorage",
graph_storage="Neo4JStorage", graph_storage="Neo4JStorage",
vector_storage="MilvusVectorDBStorge", vector_storage="MilvusVectorDBStorage",
) )
file = "./book.txt" file = "./book.txt"

View File

@@ -58,7 +58,7 @@ rag = LightRAG(
chunk_overlap_token_size=256, chunk_overlap_token_size=256,
kv_storage="RedisKVStorage", kv_storage="RedisKVStorage",
graph_storage="Neo4JStorage", graph_storage="Neo4JStorage",
vector_storage="MilvusVectorDBStorge", vector_storage="MilvusVectorDBStorage",
doc_status_storage="RedisKVStorage", doc_status_storage="RedisKVStorage",
) )

View File

@@ -101,7 +101,6 @@ def estimate_tokens(text: str) -> int:
return int(tokens) return int(tokens)
def get_default_host(binding_type: str) -> str: def get_default_host(binding_type: str) -> str:
default_hosts = { default_hosts = {
"ollama": os.getenv("LLM_BINDING_HOST", "http://localhost:11434"), "ollama": os.getenv("LLM_BINDING_HOST", "http://localhost:11434"),
@@ -249,6 +248,10 @@ def display_splash_screen(args: argparse.Namespace) -> None:
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/docs") ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/docs")
ASCIIColors.white(" └─ Alternative Documentation (local): ", end="") ASCIIColors.white(" └─ Alternative Documentation (local): ", end="")
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/redoc") 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.yellow("\n📝 Note:")
ASCIIColors.white(""" Since the server is running on 0.0.0.0: ASCIIColors.white(""" Since the server is running on 0.0.0.0:
@@ -1614,8 +1617,8 @@ def create_app(args):
# query all graph # query all graph
@app.get("/graphs") @app.get("/graphs")
async def get_graphs(label: str): async def get_knowledge_graph(label: str):
return await rag.get_graps(nodel_label=label, max_depth=100) return await rag.get_knowledge_graph(nodel_label=label, max_depth=100)
# Add Ollama API routes # Add Ollama API routes
ollama_api = OllamaAPI(rag, top_k=args.top_k) ollama_api = OllamaAPI(rag, top_k=args.top_k)
@@ -1653,19 +1656,18 @@ def create_app(args):
}, },
} }
# webui mount /webui/index.html # Webui mount webui/index.html
# app.mount( webui_dir = Path(__file__).parent / "webui"
# "/webui", app.mount(
# StaticFiles( "/graph-viewer",
# directory=Path(__file__).resolve().parent / "webui" / "static", html=True StaticFiles(directory=webui_dir, html=True),
# ), name="webui",
# name="webui_static", )
# )
# Serve the static files # Serve the static files
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
static_dir.mkdir(exist_ok=True) 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 return app

View File

@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<script src="/js/api.js"></script> <script src="./js/api.js"></script>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>

View 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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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">Titleoptional</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 files0</span>
<span id="storageUsage">Use ratio0%</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()">&times;</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>

View File

@@ -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 FILESGET 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);

View File

@@ -13,6 +13,7 @@ from typing import (
import numpy as np import numpy as np
from .utils import EmbeddingFunc from .utils import EmbeddingFunc
from .types import KnowledgeGraph
class TextChunkSchema(TypedDict): class TextChunkSchema(TypedDict):
@@ -175,7 +176,7 @@ class BaseGraphStorage(StorageNameSpace):
async def get_knowledge_graph( async def get_knowledge_graph(
self, node_label: str, max_depth: int = 5 self, node_label: str, max_depth: int = 5
) -> dict[str, list[dict]]: ) -> KnowledgeGraph:
raise NotImplementedError raise NotImplementedError

View File

@@ -18,7 +18,7 @@ config.read("config.ini", "utf-8")
@dataclass @dataclass
class MilvusVectorDBStorge(BaseVectorStorage): class MilvusVectorDBStorage(BaseVectorStorage):
cosine_better_than_threshold: float = None cosine_better_than_threshold: float = None
@staticmethod @staticmethod
@@ -65,7 +65,7 @@ class MilvusVectorDBStorge(BaseVectorStorage):
), ),
) )
self._max_batch_size = self.global_config["embedding_batch_num"] 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._client,
self.namespace, self.namespace,
dimension=self.embedding_func.embedding_dim, dimension=self.embedding_func.embedding_dim,

View File

@@ -26,6 +26,7 @@ from tenacity import (
from ..utils import logger from ..utils import logger
from ..base import BaseGraphStorage from ..base import BaseGraphStorage
from ..types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge
config = configparser.ConfigParser() config = configparser.ConfigParser()
@@ -379,7 +380,7 @@ class Neo4JStorage(BaseGraphStorage):
async def get_knowledge_graph( async def get_knowledge_graph(
self, node_label: str, max_depth: int = 5 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) Get complete connected subgraph for specified node (including the starting node itself)
@@ -390,32 +391,41 @@ class Neo4JStorage(BaseGraphStorage):
4. Add depth control 4. Add depth control
""" """
label = node_label.strip('"') label = node_label.strip('"')
result = {"nodes": [], "edges": []} result = KnowledgeGraph()
seen_nodes = set() seen_nodes = set()
seen_edges = set() seen_edges = set()
async with self._driver.session(database=self._DATABASE) as session: async with self._driver.session(database=self._DATABASE) as session:
try: try:
# Critical debug step: first verify if starting node exists main_query = ""
validate_query = f"MATCH (n:`{label}`) RETURN n LIMIT 1" if label == "*":
validate_result = await session.run(validate_query) main_query = """
if not await validate_result.single(): MATCH (n)
logger.warning(f"Starting node {label} does not exist!") WITH collect(DISTINCT n) AS nodes
return result 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) # Optimized query (including direction handling and self-loops)
main_query = f""" main_query = f"""
MATCH (start:`{label}`) MATCH (start:`{label}`)
WITH start WITH start
CALL apoc.path.subgraphAll(start, {{ CALL apoc.path.subgraphAll(start, {{
relationshipFilter: '>', relationshipFilter: '>',
minLevel: 0, minLevel: 0,
maxLevel: {max_depth}, maxLevel: {max_depth},
bfs: true bfs: true
}}) }})
YIELD nodes, relationships YIELD nodes, relationships
RETURN nodes, relationships RETURN nodes, relationships
""" """
result_set = await session.run(main_query) result_set = await session.run(main_query)
record = await result_set.single() record = await result_set.single()
@@ -423,35 +433,36 @@ class Neo4JStorage(BaseGraphStorage):
# Handle nodes (compatible with multi-label cases) # Handle nodes (compatible with multi-label cases)
for node in record["nodes"]: for node in record["nodes"]:
# Use node ID + label combination as unique identifier # 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: if node_id not in seen_nodes:
node_data = dict(node) result.nodes.append(
node_data["labels"] = list(node.labels) # Keep all labels KnowledgeGraphNode(
result["nodes"].append(node_data) id=f"{node_id}",
labels=list(node.labels),
properties=dict(node),
)
)
seen_nodes.add(node_id) seen_nodes.add(node_id)
# Handle relationships (including direction information) # Handle relationships (including direction information)
for rel in record["relationships"]: for rel in record["relationships"]:
edge_id = f"{rel.id}_{rel.type}" edge_id = rel.id
if edge_id not in seen_edges: if edge_id not in seen_edges:
start = rel.start_node start = rel.start_node
end = rel.end_node end = rel.end_node
edge_data = dict(rel) result.edges.append(
edge_data.update( KnowledgeGraphEdge(
{ id=f"{edge_id}",
"source": f"{start.id}_{'_'.join(start.labels)}", type=rel.type,
"target": f"{end.id}_{'_'.join(end.labels)}", source=f"{start.id}",
"type": rel.type, target=f"{end.id}",
"direction": rel.element_id.split( properties=dict(rel),
"->" if rel.end_node == end else "<-" )
)[1],
}
) )
result["edges"].append(edge_data)
seen_edges.add(edge_id) seen_edges.add(edge_id)
logger.info( 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: except neo4jExceptions.ClientError as e:

View File

@@ -35,6 +35,7 @@ from .utils import (
logger, logger,
set_logger, set_logger,
) )
from .types import KnowledgeGraph
config = configparser.ConfigParser() config = configparser.ConfigParser()
config.read("config.ini", "utf-8") config.read("config.ini", "utf-8")
@@ -145,12 +146,12 @@ STORAGES = {
"NetworkXStorage": ".kg.networkx_impl", "NetworkXStorage": ".kg.networkx_impl",
"JsonKVStorage": ".kg.json_kv_impl", "JsonKVStorage": ".kg.json_kv_impl",
"NanoVectorDBStorage": ".kg.nano_vector_db_impl", "NanoVectorDBStorage": ".kg.nano_vector_db_impl",
"JsonDocStatusStorage": ".kg.jsondocstatus_impl", "JsonDocStatusStorage": ".kg.json_doc_status_impl",
"Neo4JStorage": ".kg.neo4j_impl", "Neo4JStorage": ".kg.neo4j_impl",
"OracleKVStorage": ".kg.oracle_impl", "OracleKVStorage": ".kg.oracle_impl",
"OracleGraphStorage": ".kg.oracle_impl", "OracleGraphStorage": ".kg.oracle_impl",
"OracleVectorDBStorage": ".kg.oracle_impl", "OracleVectorDBStorage": ".kg.oracle_impl",
"MilvusVectorDBStorge": ".kg.milvus_impl", "MilvusVectorDBStorage": ".kg.milvus_impl",
"MongoKVStorage": ".kg.mongo_impl", "MongoKVStorage": ".kg.mongo_impl",
"MongoDocStatusStorage": ".kg.mongo_impl", "MongoDocStatusStorage": ".kg.mongo_impl",
"MongoGraphStorage": ".kg.mongo_impl", "MongoGraphStorage": ".kg.mongo_impl",
@@ -546,7 +547,9 @@ class LightRAG:
text = await self.chunk_entity_relation_graph.get_all_labels() text = await self.chunk_entity_relation_graph.get_all_labels()
return text 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( return await self.chunk_entity_relation_graph.get_knowledge_graph(
node_label=nodel_label, max_depth=max_depth node_label=nodel_label, max_depth=max_depth
) )

View File

@@ -1,7 +1,26 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import List from typing import List, Dict, Any
class GPTKeywordExtractionFormat(BaseModel): class GPTKeywordExtractionFormat(BaseModel):
high_level_keywords: List[str] high_level_keywords: List[str]
low_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
View 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?

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}

View 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
View 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>

View 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"
}
}

View 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

View 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

View 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

View 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()
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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>
)
}

View 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

View 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

View 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 }

View 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>
)
}

View 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

View 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 }

View 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>
)
}

View 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>
)
}

View 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

View 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 }

View 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
}

View 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
}

View 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

View 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 }

View 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

View 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

View 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 }

View 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
}

View 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

View 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

View 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

View 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;
}
}

View 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 = '*'

View 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
}

View 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>
)

View 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 }

View 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 }

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View 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')
}
})